// 에드센스


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을 뱉으며 잘 쳐내는 것을 볼 수 있다.

 

 

 

 

 

 

참고:

 

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring()
    	.antMatchers("/")
        .antMatchers("/ping");
}

@Override
public void configure(HttpSecurity http) throws Exception {
     http.csrf().disable()
        .authorizeRequests()
        .antMatchers("/v1/**").authenticated();
}

개발하며 위처럼 파라미터가 다른 두 configure를 재정의해서 사용할 일이 있었다.

 

 

HttpSecurity 패턴은 보안처리

WebSecurity 패턴은 보안예외처리 (정적리소스나 health check)

 

WebSecurity 패턴은 HttpSecurity패턴이 정의되어야 사용할 수 있다.

 

 

 

configure (WebSecurity)

  • antMatchers에 파라미터로 넘겨주는 endpoints는 Spring Security Filter Chain을 거치지 않기 때문에 '인증' , '인가' 서비스가 모두 적용되지 않는다.
  • 또한 Security Context를 설정하지 않고, Security Features(Secure headers, CSRF protecting 등)가 사용되지 않는다.
  • Cross-Site-Scripting(XSS), content-sniffing에 대한 endpoints 보호가 제공되지 않는다.
  • 일반적으로 로그인 페이지, public 페이지 등 인증, 인가 서비스가 필요하지 않은 endpoint에 사용한다.

 

configure (HttpSecurity)

  • antMatchers에 있는 endpoint에 대한 '인증'을 무시한다.
  • Security Filter Chain에서 요청에 접근할 수 있기 때문에(요청이 security filter chain 거침) 인증, 인가 서비스와 Secure headers, CSRF protection 등 같은 Security Features 또한 사용된다.
  • 취약점에 대한 보안이 필요할 경우 HttpSecurity 설정을 사용해야 한다.

 

 

 

 

참고:

더보기

+ Recent posts