티스토리 뷰

저번 포스팅에 이어서 Spring Security와 Jwt을 사용하여 회원 인증/인가 시스템을 만들어 보겠다.

 

Spring Security를 사용하게 되면 전달받은 JWT를 Filter를 통해 Authorization(인가), Authentication(인증) 을 처리하게 된다. 이를 위해서 User 정보를 Jwt의 Payload에 Claim들을 넣고, 꺼내서 처리할 수 있는 시스템을 만들면 된다.

 

우리가 갖고 있는 User를 그대로 클레임으로 넣고 꺼내서 처리하기에는 모든 User 모델이 제각각일테니 이를 처리하기 위한 공통의 인터페이스가 존재한다. UserDetails Interface이며 이를 implements한 User 객체를 새로 만들어서 사용하면 된다.

 

Member implements UserDetails

data class PrincipalUserDetails(
    private val member: Member
) : UserDetails {

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
        val authorities: MutableList<GrantedAuthority> = mutableListOf()
        member.getRoleTypeList().map {
            authorities.add(GrantedAuthority { it.name })
        }
        return authorities
    }

    override fun getPassword() = member.password
    override fun getUsername() = member.username
    override fun isAccountNonExpired() = true
    override fun isAccountNonLocked() = true
    override fun isCredentialsNonExpired() = true
    override fun isEnabled() = true
}

 

Member를 토큰에 넣기 전 일련의 처리를 위한 UserDetails를 상속받은 Member를 만들어 줬으니 이 Member를 꺼내기 위한 서비스도 만들어줘야 한다. 이를 위한 Service도 이미 존재하는데 UserDetailsService이다.

 

PrincipalUserDetailsService 

@Service
class PrincipalUserDetailsService(
    private val memberRepository: MemberRepository
) : UserDetailsService {
    private val log: Logger = LoggerFactory.getLogger(this::class.simpleName)

    @Throws(UsernameNotFoundException::class)
    override fun loadUserByUsername(username: String): UserDetails {
        log.info("<UserPrincipal 조회 후 반환>")

        val member: Member = memberRepository.findMemberByUsername(username)
            ?: throw UsernameNotFoundException("$username 회원이 존재하지 않습니다.")

        return PrincipalUserDetails(member)
    }
}

 

loadUserByUsername()은 authenticationManager가 UsernamePasswordAuthenticationToken 객체를

authenticate()를 할 때 호출된다.

 

이제 일련의 필터링을 위한 과정을 위해 필요한 파일들을 생성해보자


Spring Web Securtiy를 위한 SecurityConfig 파일부터 생성해준다.

SecurityConfig.class

package hackathon.boilerplate.jwt.config

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
class SecurityConfig(
    private val passwordEncoder: BCryptPasswordEncoder,
    private val authenticationConfiguration: AuthenticationConfiguration,
    private val jwtProviderService: JwtProviderService,
    private val principalUserDetailsService: PrincipalUserDetailsService,
    private val customAuthenticationEntryPoint: CustomAuthenticationEntryPoint,
    private val customAccessDeniedHandler: CustomAccessDeniedHandler,
) {
    @Bean
    fun webSecurityCustomizer(): WebSecurityCustomizer {
        return WebSecurityCustomizer {
            it.ignoring()
                .antMatchers(
                    "/swagger-resources/**",
                    "/swagger-ui.html",
                    "/swagger/**",
                    "/webjars/**",
                )
            it.ignoring()
                .antMatchers("/api/v1/signUp") // 회원가입 필터 제외
                .antMatchers("/playground")
        }
    }

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http
            // JWT 방식의 무상태 인증/인가 처리를 적용하므로 세션 STATELESS 처리
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

            // 비활성화 필터와 커스텀 필터를 추가
            .and()
            .csrf().disable()
            .formLogin().disable()
            .httpBasic(Customizer.withDefaults())
            .addFilter(corsFilter())
            .addFilter(usernamePasswordAuthenticationFilter())
            .addFilterBefore(CustomJwtAuthorizationFilter(jwtProviderService), BasicAuthenticationFilter::class.java)
            .addFilterBefore(JwtAuthorizationExceptionFilter(jwtProviderService), CustomJwtAuthorizationFilter::class.java)
            .authorizeRequests()
            .antMatchers("/graphql").permitAll()
            .antMatchers("/graphiql").permitAll()
            .anyRequest().permitAll()

            // 인증,인가 exception handling 시 커스텀 파일로 처리
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(customAuthenticationEntryPoint)
            .accessDeniedHandler(customAccessDeniedHandler)

            // 로그아웃 시 로그인 페이지로 이동
            .and()
            .logout()
            .logoutSuccessUrl("/api/v1/login") // 로그아웃에 대해서 성공하면 "/"로 이동

        return http.build()
    }

    /***
     * 로그인 시 authentication 필터 적용
     */
    fun usernamePasswordAuthenticationFilter(): UsernamePasswordAuthenticationFilter? {
        return CustomUsernamePasswordAuthenticationFilter(
            authenticationManager(authenticationConfiguration),
            jwtProviderService
        ).apply { setFilterProcessesUrl("/api/v1/login") }
    }

    @Bean
    fun authenticationManager(authenticationConfiguration: AuthenticationConfiguration): AuthenticationManager {
        return authenticationConfiguration.authenticationManager
    }

    @Bean
    fun authProvider() : DaoAuthenticationProvider {
        return DaoAuthenticationProvider()
            .also { it.setPasswordEncoder(passwordEncoder) }
            .also { it.setUserDetailsService(principalUserDetailsService) }
    }
}

이제 위 SecurityConfig 파일을 바탕으로 인증/인가 처리에 대해 설명해보겠다.

 

Endpont

graphql은 /graphql을 단일 엑세스 포인트로 사용하므로 url 단위로 인증/인가 처리를 할 수가 없다. 따라서 @EnableMethodSecurity를 추가하여 @Secured, @PreAuthorize 를 이용하여 authentication이 필요한 fetcher에 달아서 적용되도록 했다.

 

패스워드

고객의 패스워드를 그대로 db에 저장하면 절대 안되므로 passwordEncoder를 @Bean으로 등록해서 암호화를 적용할 수 있도록 한다.

PassowrdConfig.class

@Configuration
class PasswordConfig {
    @Bean
    fun passwordEncoder(): BCryptPasswordEncoder {
        return BCryptPasswordEncoder(JwtConfig.STRENGTH)
    }
}

 

JWT

JWT 관련 각종 설정 값들을 갖고 있는 Config 파일도 하나 만들어준다.

JwtConfig.class

interface JwtConfig {
    companion object {
        const val ACCESS = "AccessToken"
        const val REFRESH = "RefreshToken"
        const val EXCEPTION = "EXCEPTION"
        const val EXPIRED_EXCEPTION = "EXPIRED_EXCEPTION"
        const val USERNAME = "USERNAME"
        const val ACCESS_TOKEN_HEADER = "Authorization"
        const val REFRESH_TOKEN_HEADER = "Authorization-refresh"
        const val TOKEN_PREFIX = "Bearer "
        const val STRENGTH = 10
        const val ACCESS_TOKEN_EXPIRATION = 30L // 30min
        const val REFRESH_TOKEN_EXPIRATION = 14L // 14day
    }
}

 

인증, 인가

인증/인가 처리를 처리하기 위해 2가지 Filter를 생성하였다.

 

첫번째로 인증, 즉 login 처리를 위한 필터로 UsernamePasswordAuthenticationFilter를 상속받은

CustomUsernamePasswordAuthenticationFilter

클래스를 선언하여 인증(authentication) 처리를 담당하도록 했다. login 처리를 위한 url은 "/api/v1/login"으로 수정하였다.

 

두번째로 인가, 이미 로그인 된 유저에 대한 처리를 위한 필터로

CustomJwtAuthorizationFilter

를 선언하였다. 이것은 MethodSecurity 애노테이션(@PreAuthorize)이 달린 곳에 대해 적용이 될 것이다. 참고로 해당 프로젝트는 무상태 처리를 위한 JWT를 사용하였으므로 토큰을 헤더에 담아 전달하면, 이를 가지고 처리하는 방법으로 진행된다.

유저 -> 로그인 시도 -> (성공 시) 서버에서 엑세스토큰, 리프레시 토큰 발급 -> 클라이언트에서 액세스토큰으로 요청 -> 만료 여부에 따라 리프레시 토큰을 포함하여 재시도 -> 만료여부에 따라 로그인 재요청

 

(인증)Authentication / (인가)Authorization 중 에 오류가 발생 시 error handling을 위한 클래스도 재구현한다.

@Component
class CustomAuthenticationEntryPoint(
    private val jwtProviderService: JwtProviderService
) : AuthenticationEntryPoint {
    private val log: Logger = LoggerFactory.getLogger(this::class.simpleName)

    override fun commence(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authException: AuthenticationException
    ) {
        log.warn("[인증 오류] ${authException.message}")
        val exceptionMessage = request.getAttribute(JwtConfig.EXCEPTION).toString()
        jwtProviderService.setResponseMessage(false, response, exceptionMessage)
    }
}
@Component
class CustomAccessDeniedHandler(
    private val jwtProviderService: JwtProviderService
) : AccessDeniedHandler {
    private val log: Logger = LoggerFactory.getLogger(this::class.simpleName)

    override fun handle(
        request: HttpServletRequest?,
        response: HttpServletResponse?,
        accessDeniedException: AccessDeniedException?
    ) {
        log.warn("<Authorization(인가) 실패 시 message 설정>")
    }
}

 

 

이제 핵심 필터 2개에 대해 알아보자.

  1. CustomUsernamePasswordAuthenticationFilter
  2. CustomJwtAuthorizationFilter

인증(Authentication)

1번 필터에 대해 설명하면, 유저 로그인 시 JWT를 발급해주기 위한 필터이다. 

  1. requset의 body를 읽어서 username, password를 꺼낸다.
  2. 해당 유저-패스워드를 가지고 UsernamePasswordAuthenticationToken()을 생성한다.
    이 객체는 Authentication 인터페이스를 상속받은 클래스이다.
  3. authenticationToken을 넘겨서 authenticationManager.authenticate(authentication)를 호출한다.
  4. 그러면 UserDetailsService의 loadUserByUsername()를 호출하게 된다.
    • 이 부분이 필요한 이유: 서버에 존재하는 유저인지 확인을 해야한다. 따라서 토큰에 user를 구분할 수 있는 어떠한 값이 필요하다. (pk는 지양한다)
  5. 회원을 조회해서 PrincipalUserDetails(member) 객체로 감싸서 넘겨준다.

위 처리 중

  • 성공 시 -> successfulAuthentication() 로 가서 JWT를 발급 후 헤더에 넣어서 반환해준다.
  • 실패 시 -> unsuccessfulAuthentication() 로 가서 error을 던지고 종료된다.

위 정리를 바탕으로 코드를 봐보자.

CustomUsernamePassowrdAuthenticationFilter.class

class CustomUsernamePasswordAuthenticationFilter(
    private val authenticationManager: AuthenticationManager,
    private val jwtProviderService: JwtProviderService
) : UsernamePasswordAuthenticationFilter() {

    private val log: Logger = LoggerFactory.getLogger(this::class.simpleName)

    override fun attemptAuthentication(
        request: HttpServletRequest,
        response: HttpServletResponse
    ): Authentication {
        log.info("<로그인 시 Authentication(인증) 시도>")
        try {
            val om = ObjectMapper()
            val loginInput = om.readValue(request.inputStream, LoginInput::class.java)
            val authentication = UsernamePasswordAuthenticationToken(loginInput.username, loginInput.password)
            return authenticationManager.authenticate(authentication)
        } catch (e: IOException) {
            e.printStackTrace()
            throw DataNotFoundException(ErrorCode.ITEM_NOT_EXIST, "회원이 존재하지 않습니다.")
        }
    }

    override fun successfulAuthentication(
        request: HttpServletRequest?,
        response: HttpServletResponse,
        chain: FilterChain?,
        authResult: Authentication?
    ) {
        log.info("[인증 성공] JWT 발급")
        val principal: PrincipalUserDetails = authResult!!.principal as PrincipalUserDetails

        val accessToken: String = jwtProviderService.createAccessToken(principal.username)
        val refreshToken: String = jwtProviderService.createRefreshToken()

        jwtProviderService.saveRefreshToken(principal.username, refreshToken)

        jwtProviderService.setHeaderOfAccessToken(response, accessToken)
        jwtProviderService.setHeaderOfRefreshToken(response, refreshToken)

        jwtProviderService.setResponseMessage(true, response, "로그인 성공")
    }

    override fun unsuccessfulAuthentication(
        request: HttpServletRequest?,
        response: HttpServletResponse,
        failed: AuthenticationException?
    ) {
        log.info("[인증 실패]")
        val failMessage = when (failed!!.message) {
            ErrorCode.ITEM_NOT_EXIST.name -> ErrorCode.ITEM_NOT_EXIST.name
            ErrorCode.WRONG_PASSWORD.name -> ErrorCode.WRONG_PASSWORD.name
            else -> ErrorCode.UNKNOWN_ERROR.name
        }
        jwtProviderService.setResponseMessage(false, response, "로그인 실패: $failMessage")
        throw UsernameNotFoundException(failMessage)
    }
}

의도한대로 로그가 출력되는 것을 볼 수 있다.

인가(Authorization)

이어서 2번째 필터인 CustomJwtAuthorizationFilter에 대해 알아보자.

요 필터는 어떠한 요청들이 왔을 때 Jwt에 대해 authentication & authorization 하기 위한 필터이다. 간략히 절차를 말하면

    1. header로 넘어온 jwt를 추출한다.
    2. jwt 문자열을 통해 JWT을 생성하여 validation을 체크해준다.
      • 변조 여부, private 서명키를 통한 검증, 만료 여부
    3. 이상이 없다면 payload를 확인하여 claims 정보를 통해 회원을 조회한다.
    4. 회원 존재 여부가 확인되면, 해당 회원을 Authentication 객체로 Encapsule하기 위해 PrincipalUserDetails로 감싸서 반환한다.

 

위 내용을 바탕으로 코드를 확인해보자

CustomJwtAuthorizationFilter.class

class CustomJwtAuthorizationFilter(
    private val jwtProviderService: JwtProviderService,
) : OncePerRequestFilter() {
    private val log: Logger = LoggerFactory.getLogger(this::class.simpleName)

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        log.info("<Authorization(인가) 필터>")
        try {
            request
                .apply {
                    if (checkValidHeader().not()) {
                        jwtProviderService.setErrorResponseMessage(
                            response = response,
                            status = HttpStatus.BAD_REQUEST,
                            errorType = JwtConfig.HEADER_EXCEPTION,
                            message = "잘못된 헤더입니다."
                        )
                        return
                    }
                }
                .let {
                    if (jwtProviderService.onlyAccessToken(request)) {
                        TokenPair(
                            jwtProviderService.extractAccessToken(request),
                            null
                        )
                    } else {
                        TokenPair(
                            jwtProviderService.extractAccessToken(request),
                            jwtProviderService.extractRefreshToken(request)
                        )
                    }
                }
                .apply { SecurityContextHolder.getContext().authentication = getAuthentication(response) }
        } catch (expiredJwtException: ExpiredJwtException) {
            throw expiredJwtException
        } catch (jwtException: JwtException) {
            throw jwtException
        } catch (e: Exception) {
            e.printStackTrace()
            throw e
        }
        filterChain.doFilter(request, response)
    }

    private fun HttpServletRequest.checkValidHeader(): Boolean {
        return (jwtProviderService.checkValidAccessHeader(this) && !jwtProviderService.checkValidRefreshHeader(this)) ||
                (jwtProviderService.checkValidAccessHeader(this) && jwtProviderService.checkValidRefreshHeader(this))
    }

    private fun TokenPair.getAuthentication(response: HttpServletResponse): UsernamePasswordAuthenticationToken {
        val principal = if (refresh == null) {
            check(jwtProviderService.checkValidToken(access))
            check(jwtProviderService.checkTokenExpired(access).not())
            val member = jwtProviderService.findMemberByAccessToken(access)
            PrincipalUserDetails(member)
        } else {
            check(jwtProviderService.checkValidToken(access))
            check(jwtProviderService.checkValidToken(refresh))
            check(jwtProviderService.checkTokenExpired(refresh).not())
            val member = jwtProviderService.findMemberByRefreshToken(refresh)
            val expireIn7Day = jwtProviderService.checkExpireInSevenDayToken(refresh)
            if (expireIn7Day) reissueRefreshToken(member.username, response)
            reissueAccessToken(member.username, response)
            PrincipalUserDetails(member)
        }

        return UsernamePasswordAuthenticationToken(principal, null, principal.authorities)
    }

    private fun reissueAccessToken(
        username: String,
        response: HttpServletResponse
    ) {
        log.info("[ACCESS TOKEN] 액세스 토큰 재발급")
        val reissuedAccessToken = jwtProviderService.createAccessToken(username)
        jwtProviderService.setHeaderOfAccessToken(response, reissuedAccessToken)
    }

    private fun reissueRefreshToken(
        username: String,
        response: HttpServletResponse
    ) {
        log.info("[REFRESH TOKEN] 리프레쉬 토큰 재발급")
        val reissuedRefreshToken = jwtProviderService.reissueRefreshToken(username)
        jwtProviderService.setHeaderOfRefreshToken(response, reissuedRefreshToken)
    }
}

data class TokenPair(
    val access: String,
    val refresh: String?,
)

위 코드에 대해 설명해보겠다.

  1. header 유효 여부 확인
  2. 헤더 여부에 따라 access_token, 추가적으로 refresh_token 추출
    1. access_token만 갖고 있는 경우 처리
      1. access_token의 Unsupoort, Malformed, Signature, Expired, IllegalArgument 경우 확인
      2. 타당하다면 access_token에 들어있는 username을 꺼내서 회원을 조회
      3. 회원을 authentication 객체인 PrincipalUserDetails로 감싸서 반환
    2. access_token & refresh_token 둘다 갖고 있는 경우 처리
      (access_token이 만료된 경우 클라에서 refresh_token을 헤더에 추가로 담아서 재요청이 필요)
      1. access_token & refresh_token의 Unsupoort, Malformed, Signature, Expired, IllegalArgument 경우 확인
        • refresh_token이 만료된 경우 -> 로그아웃 후 재로그인 한다
        • refresh_token의 만료일이 7일 이내인 경우 -> 리프레시 토큰 재발급
      2. access_token을 재발급
      3. refresh_token으로 회원을 조회 이를 authentication 객체인 PrincipalUserDetails로 감싸서 반환

 

이제 Json Web Token을 실제 생성하고, 검증하는 JwtProviderService 클래스에 대해 알아보자.

JwtProviderService.class

@Service
class JwtProviderService(
    private val memberService: MemberService
) {
    @Value("\${jwt.secret-key}")
    private val secretValue: String? = null
    private fun getSecretKey(): SecretKey = Keys.hmacShaKeyFor(secretValue!!.encodeToByteArray())
    private fun getParser(): JwtParser = Jwts.parserBuilder().setSigningKey(getSecretKey()).build()
    private fun parseToken(token: String) = getParser().parseClaimsJws(token)

    fun createAccessToken(username: String): String {
        return Jwts
            .builder()
            .setSubject(JwtConfig.ACCESS)
            .setClaims(mapOf(JwtConfig.USERNAME to username))
            .setIssuedAt(currentKSTDate())
            .setExpiration(plusKSTDate(min = JwtConfig.ACCESS_TOKEN_EXPIRATION))
            .signWith(getSecretKey())
            .compact()
    }

    fun createRefreshToken(): String {
        return Jwts
            .builder()
            .setSubject(JwtConfig.REFRESH)
            .setIssuedAt(currentKSTDate())
            .setExpiration(plusKSTDate(day = JwtConfig.REFRESH_TOKEN_EXPIRATION))
            .signWith(getSecretKey())
            .compact()
    }

    fun onlyAccessToken(request: HttpServletRequest): Boolean {
        return StringUtils.hasText(request.getHeader(JwtConfig.ACCESS_TOKEN_HEADER)) &&
                StringUtils.hasText(request.getHeader(JwtConfig.REFRESH_TOKEN_HEADER)).not()
    }

    fun checkValidAccessHeader(request: HttpServletRequest): Boolean {
        request
            .apply { getHeader(JwtConfig.ACCESS_TOKEN_HEADER)?.startsWith(JwtConfig.TOKEN_PREFIX) ?: return false }
        return true
    }

    fun checkValidRefreshHeader(request: HttpServletRequest): Boolean {
        request
            .apply { getHeader(JwtConfig.REFRESH_TOKEN_HEADER)?.startsWith(JwtConfig.TOKEN_PREFIX) ?: return false }
        return true
    }

    fun extractAccessToken(request: HttpServletRequest): String {
        return request
            .getHeader(JwtConfig.ACCESS_TOKEN_HEADER)
            .replace(JwtConfig.TOKEN_PREFIX, "")
    }

    fun extractRefreshToken(request: HttpServletRequest): String {
        return request
            .getHeader(JwtConfig.REFRESH_TOKEN_HEADER)
            .replace(JwtConfig.TOKEN_PREFIX, "")
    }

    fun checkValidToken(token: String): Boolean {
        return try {
            parseToken(token).body
            true
        } catch (e: ExpiredJwtException) {
            true
        } catch (e: JwtException) {
            false
        }
    }

    fun checkTokenExpired(token: String): Boolean {
        return try {
            parseToken(token).body
            false
        } catch (e: ExpiredJwtException) {
            throw e
        }
    }

    fun checkExpireInSevenDayToken(token: String): Boolean {
        return parseToken(token).body.expiration.before(plusKSTDate(day = 7))
    }

    fun getUsername(token: String): String {
        return parseToken(token).body[JwtConfig.USERNAME].toString()
    }

    fun findMemberByRefreshToken(refreshToken: String): Member {
        return memberService.findMemberByToken(refreshToken)
    }

    fun findMemberByAccessToken(accessToken: String): Member {
        return memberService.findMemberByUsername(getUsername(accessToken))
    }

    @Transactional
    fun saveRefreshToken(username: String, token: String) {
        memberService
            .findMemberByUsername(username)
            .updateToken(token)
    }

    @Transactional
    fun reissueRefreshToken(username: String): String {
        val reissuedRefreshToken = createRefreshToken()
        memberService
            .findMemberByUsername(username)
            .updateToken(reissuedRefreshToken)
        return reissuedRefreshToken

    }

    fun setResponseMessage(result: Boolean, response: HttpServletResponse, message: String) {
        response.contentType = "application/json;charset=UTF-8"
        val content = JSONObject()
            .apply { put("success", result) }
            .apply { put("message", message) }
        response
            .writer
            .print(content)
    }

    fun setHeaderOfAccessToken(response: HttpServletResponse, token: String) {
        response.addHeader(JwtConfig.ACCESS_TOKEN_HEADER, JwtConfig.TOKEN_PREFIX + token)
    }

    fun setHeaderOfRefreshToken(response: HttpServletResponse, token: String) {
        response.addHeader(JwtConfig.REFRESH_TOKEN_HEADER, JwtConfig.TOKEN_PREFIX + token)
    }
    
    
    fun setErrorResponseMessage(response: HttpServletResponse, status: HttpStatus, errorType: String, message: String) {
        response.status = status.value()
        response.contentType = "application/json; charset=UTF-8"
        response.writer.write(JwtExceptionResponse(status, "$errorType: $message").toJsonString())
    }
}

코드들을 보면 이해가 가능할 것이다.

 

이때 추가적으로 봐볼 만한 부분이 있는데

  • checkValidToken()
  • checkTokenExpired()

이 부분이다.

 

해당 메소드는 결과에 따라 Exception을 던지게 된다. 그러면 해당 메소드를 호출한 CustomJwtAuthorizationFilter의 doFilterInternal()에서 에러를 던지고, try - catch에서 이어서 exception을 던지게 된다.

 

이때 던지는 exception을 graphql에서 처리하게 되는데 이렇게 되면 ExpiredJwtException과 JwtException, 그 외 Exception 들을 구분하여 처리할 수가 없다.

 

따라서 CustomJwtAuthorizationFilter 바깥에서 exception을 처리해주는 Filter를 추가적으로 만들어서 적절한 에러메시지를 포함한 반환값을 반환하도록 하겠다.

이 필터는 SecurityConfig에서 필터앞에 위치하도록 한다.

.addFilterBefore(jwtAuthorizationExceptionFilter, CustomJwtAuthorizationFilter::class.java)

 

JwtAuthorizationExceptionFilter.class

class JwtAuthorizationExceptionFilter(
    private val jwtProviderService: JwtProviderService
) : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        try {
            filterChain.doFilter(request, response) // -> CustomJwtAuthorizationFilter 진행
        } catch (ex: ExpiredJwtException) {
            setErrorResponse(HttpStatus.UNAUTHORIZED, response, JwtConfig.EXPIRED_EXCEPTION, ex)
        } catch (ex: JwtException) {
            setErrorResponse(HttpStatus.UNAUTHORIZED, response, JwtConfig.JWT_EXCEPTION, ex)
        } catch (ex: Exception) {
            setErrorResponse(HttpStatus.BAD_REQUEST, response, JwtConfig.EXCEPTION, ex)
        }
    }

    fun setErrorResponse(status: HttpStatus, res: HttpServletResponse, errorType: String, ex: Throwable) {
        jwtProviderService.setErrorResponseMessage(res, status, errorType, ex.message ?: "")
    }
}

data class JwtExceptionResponse(
    val status: HttpStatus,
    val message: String,
) {
    fun toJsonString(): String {
        return Gson().toJson(this)
    }
}

 

회원 조회 테스트

@DgsQuery
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
fun getMember(
    @InputArgument(name = "member_id") memberId: ID
): MemberResponse {
    return memberService.findMemberById(memberId.toLong())
}

graphql

query getMember($id: ID!) {
	getMember(member_id: $id) {
		id
		username
		profile_name
		auditor
		role_list_string
	}
}

 

테스트

1. 헤더를 포함하지 않고 보내는 경우

2. 만료된 access_token만 보낸 경우

3. 유효한 access_token만 보낸 경우

4. 만료된 access_token & 유효한 refresh_token을 함께 보낸 경우

재발급 된 access_token

5. refresh_token만 보낸 경우

6. 잘못된 토큰값을 보낸 경우 (refresh_token에서 g -> n으로 한글자 변경)

 

이렇게 jwt를 통한 인가/인증 처리를 완료하였다. 다음 챕터에서는 레디스를 적용하여 쿼리 최적화를 적용해보겠다.

반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday