// 에드센스


1. 무슨일이야

Canary branch를 생성하며 기존의 [빈스톡+S3+RDS+Firebase] 세트의 인프라를 똑같이 하나 더 생성하려고했다.

순조롭게 하나씩 뚝딱뚝딱 만들어갔다..

아예 새로운 환경이기에 firebase나 kas api등 모든 키값들을 새로 넣어주어야했다.

따라서 NodeJS의 .env파일과 firebase 설정파일인 firebase.json을 불러오는 script까지 canary버전으로 새로 작성해주었다.

 

그런데 이게 무슨일인가

 

빈스톡에 배포를 하고나니 여전히 Dev DB, Dev Firebase, Dev KAS api를 가리키고있는게 아닌가?

데이터는 데브 디비로 저장되고 Firebase JWT는 프로젝트가 다르다며 풀리지 않았다.

왜! 분명 나는 잘 한거같은데!!!(물론 역시 내 실수였다)

 

빈스톡 로그파일을 까보면 데브환경으로 구성돼있음을 확인할 수 있었다.

 

 

 


2. 어디서 꼬였을까

의심1. 내가 S3에 env를 잘못올려놨겠지

  • 아니었다. 다시 다운받아서 열어보니까 잘 올라가 있더라.
  • Github Actions에서 env, firebase.json을 가져오는 스크립트에도 문제가 없었다.
  • 그 외에 모든 S3와 관련된 부분에서도 문제는 없었다.

깃헙액션에서 로그를 찍어가며 canary env파일임을 확인했다.

 

 

의심2. POSTMAN의 환경변수 문제인줄 알았다.

테스트를 포스트맨으로 하는데 엔드포인트를 데브 빈스톡으로 설정한줄 알았다.

  • 역시 아니었다. 잘 해놨다.

 

 

의심3. 빈스톡 deploy.zip이 생성될 때 뭔가 env가 누락이 되는가 싶었다.

  • 로컬에선 잘 포함되어 만들어지는데.. 이것도 아닌것 같았다.

 

 

 

 


3. 등잔밑이 어두웠다

깃헙액션 스크립트 곳곳에 로그를 찍어가며 어디서부터 잘못된 env로 바뀌는지 찾아갔고..

결국 Generate deployment packageDeploy to EB 사이 어딘가에서 문제가 생김을 알 수 있었다.

 

 

근데 조금 자세히 보면 

[deploy.zip 생성 -> 배포시작 -> 배포완료 -> 애플리케이션 구동] 의 흐름인건데

이 흐름에서 deploy.zip 생성에는 문제가 없었다면,

그 이후의 과정에는 내가 관여할 수 없는것 아닌가? 싶었다.

즉, 배포가 시작되고부터는 내 손을 떠난 상태라고 생각했다.

 

 

결국 선임님께 헬프를 쳤는데 30초만에 "혹시 이거 아닌가요?" 라고 답이 왔다.

 

정답은 빈스톡을 처음 설정할 때 멋모르고 만들어 놓은 .ebextension파일이었다.

 

 

 

 


4. ebextension이란?

한마디로

"빈스톡이 배포될때 실행되는 커맨드"

라고 할 수 있다.

 

프로젝트 최상위 경로에 .ebextension 폴더를 생성하고 폴더 안에 .config 파일을 생성하여 커맨드를 지정한다.

 

commands와 container_commands 이렇게 두가지 종류의 명령이 있다.

이 둘은 실행시점이 다르다.

 

  • commands는 웹서버가 설정되고 애플리케이션 버전이 추출되기 전에 실행되고,
  • container_commands는 웹서버가 설정되고 애플리케이션 버전 아카이브의 압축이 풀린 후에 실행된다.

 

위 이미지에는 없지만 나는 container_commands에 엉뚱한 env를 불러오는 S3 스크립트를 넣어놨었다.

그래서 깃헙액션에서 canary env를 불러와서 deploy.zip에 넣어줘도 빈스톡 배포 직후 엉뚱한 env로 덮어씌워졌던 것이다!!!!

바로 해당 커맨드를 빼주니 정상 동작하더라.

 

 

 

해결완료-

 

 

참고:


1. 구현하고자 하는 것

 

인증은 Firebase에서,

인가는 Spring Security로,

구성하려고 한다.

이후 Controller에서 @PreAuthorize를 통해서 요청을 제한하고자 한다.

즉, Firebase에서 발급한 JWT를 Spring Security에서 사용하기.

 

인증 - 유저가 누구인지 확인하는 절차

인가 - 유저가 요청한 요청(request)을 실행할 수 있는지를 확인하는 절차

 

 

 

 


2. 현재까지 구현된 것

nodejs로 구현한 회원가입 로직에 firebase-admin를 사용하여 Firebase에 유저 생성 및 권한등록을 해주었다.

 

 

import * as admin from 'firebase-admin';

export const createFirebaseUser = (email: string, password: string, name: string) => {
  return admin.auth().createUser({
    email: email,
    password: password,
    displayName: name,
    disabled: false,
  });
};

firebase-admin 라이브러리로 firebase에 새로운 user를 등록해 주었고

 

 

import * as admin from 'firebase-admin';

export const setRole = (uid: string, role: string) => {
  let claims = {
      group: role,
  };
  admin.auth().setCustomUserClaims(uid, claims)
};

firebase-admin 라이브러리로 { group: role } 이라는 customUserClaim을 생성해주었다.

이것 자체로 권한으로써의 역할을 하진 않고 그저 사용자 설정 클레임일 뿐이다.

 

여기까지가 현재 진행 상태였고,

우리의 목표는 Spring에서 이 클레임을 사용자의 ROLE로 인식되게 하는 것이다.

 

 

 

 

 

 


3. JwtFilter 구현

    @PostMapping("")
    @PreAuthorize("hasAnyRole('admin', 'partner')")
    fun postEvent(@Valid @RequestBody req: PostEventRequest): ResponseEntity<Any> {
        println("postEvent")
        return ResponseEntity.status(201).body(object {
            val data = eventService.createOneEvent(req)
        })
    }

이렇게 컨트롤러에 @PreAuthorize 어노테이션을 통해서 사용자의 접근을 걸러낼 수 있어야한다.

저 컨트롤러로 들어가는 request에서 권한을 어떻게 추출하여 걸러낼 수 있을까?

 

  1. Firebase JWT에서 group을 추출하여 role을 얻어낸다.
  2. 이 role을 UserDetails 객체를 만들고 넣어준다.
  3. UserDetails 객체를 SecurityContextHolder에 등록해준다.

이 로직을 Filter로 구현하여 Filter를 등록해주자.

 

참고로, @PreAuthorize 안에서 다양한 표현식을 사용할 수 있다.

https://docs.spring.io/spring-security/site/docs/3.0.x/reference/el-access.html#el-common-built-in

 

15. Expression-Based Access Control

There are some built-in expressions which are specific to method security, which we have already seen in use above. The filterTarget and returnValue values are simple enough, but the use of the hasPermission() expression warrants a closer look. The Permiss

docs.spring.io

 

 

1. Firebase JWT로부터 Custom Claim 추출하기

val accessToken = request.getHeader("Authorization").split(" ")[1]

JWT 토큰이 Header에 Authorization: Bearer {jwt token} 형식으로 요청되어지기에 공백을 기준으로 뒷 덩어리를 가져온다.

 

 

 

2. JWT Token 검증하기

val decodedToken: FirebaseToken = FirebaseAuth.getInstance().verifyIdToken(accessToken)
val uid: String = decodedToken.uid
val role: String = decodedToken.claims["group"] as String

Firebase Admin를 통해서 verify한다.

시간이 만료되었거나 잘못된 토큰일 경우 FirebaseAuthException이나 IllegalArgumentException을 뱉는다.

 

 

 

3. SecurityContextHolder에 UserDetails 객체를 생성하여 등록하기

val userDetails: UserDetails =
    User.builder().username(uid).authorities(SimpleGrantedAuthority(role)).roles(role).password("").build()
val authentication = UsernamePasswordAuthenticationToken(
    userDetails, null, userDetails.authorities
)
SecurityContextHolder.getContext().authentication = authentication

 

 

위 3단계를 합치고 예외 몇 가지를 지정해준 JwtFilter 코드는 다음과 같다.

class JwtFilter: OncePerRequestFilter() {

    @Throws(ServletException::class, IOException::class)
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        try {
            val accessToken =
                request.getHeader("Authorization").split(" ")[1]
            val decodedToken: FirebaseToken = FirebaseAuth.getInstance().verifyIdToken(accessToken)
            val uid: String = decodedToken.uid
            val role: String = decodedToken.claims["group"] as String
            val userDetails: UserDetails =
                User.builder().username(uid).authorities(SimpleGrantedAuthority(role)).roles(role).password("").build()
            val authentication = UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.authorities
            )
            SecurityContextHolder.getContext().authentication = authentication
        } catch (e: FirebaseAuthException) {
            response.status = HttpStatus.SC_UNAUTHORIZED
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"INVALID_TOKEN\", \"message\":\"" + e.message + "\"}")
            return
        } catch (e: IllegalArgumentException) {
            response.status = HttpStatus.SC_UNAUTHORIZED
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"INVALID_TOKEN\", \"message\":\"" + e.message + "\"}")
            return
        } catch (e: NoSuchElementException) {
            response.status = HttpStatus.SC_UNAUTHORIZED
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"USER_NOT_FOUND\"}")
            return
        } catch (e: NullPointerException) {
            response.status = HttpStatus.SC_BAD_REQUEST
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"AUTHORIZATION_HEADER_NOT_FOUND\"}")
            return
        } catch (e: Exception) {
            e.printStackTrace()
            return
        }
        filterChain.doFilter(request, response)
    }
}

 

 

 

 


4. Filter 등록

이렇게 구현한 Filter를 Spring Security의 Filter Chain에 등록해준다.

 

@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
class WebSecurityConfig: WebSecurityConfigurerAdapter(){

    @Throws(java.lang.Exception::class)
    override fun configure(web: WebSecurity) {
        web.ignoring()
            .antMatchers("/")
            .antMatchers("/ping")
    }

    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        http
            .cors().disable()
            .csrf().disable()
            .authorizeRequests()
            .antMatchers("/v1/**").authenticated().and()
            .addFilterBefore(
                JwtFilter(),
                UsernamePasswordAuthenticationFilter::class.java
            )
            .exceptionHandling()
            .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
    }
}

 

@PreAuthorize가 동작하지 않으면 다음을 추가해주자

@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)

 


5. 동작확인

만료된 JWT Token으로 접근을 시도하면 401을 뱉으며 잘 쳐내는 것을 볼 수 있다.

 

권한에 맞지 않는 api에 접근을 시도하면 403을 뱉으며 잘 쳐내는 것을 볼 수 있다.

 

 

 

 

 

 

참고:

+ Recent posts