우당탕탕 JWT로 인증 인가 구현하기

이수진·2024년 6월 1일

이번 프로젝트에서 회원 도메인과 인증, 인가 부분을 맡게 되었다. JWT을 도입해보기로 했는데 개념은 얼핏 알고 있었지만 프로젝트에서 써보는게 처음이라 이것저것 고민해볼 문제가 많았었다. 어떤 문제가 있었는지, 어떻게 해결했는지 그 과정을 정리해 보았다.

JWT 만들고 검증하기

  • build.gradle.kts
    implementation("io.jsonwebtoken:jjwt-api:0.12.3")
    runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")

위 코드를 추가하여 JJWT 라이브러리를 추가해준다.

  • JwtTokenProvider

class JwtTokenProvider {
    companion object {
        const val SECRET_KEY = //Secret Key 입력
    }

    private val key: SecretKey = Keys.hmacShaKeyFor(SECRET_KEY.toByteArray(StandardCharsets.UTF_8))

    private fun generateToken(user: User, subject: String, duration: Duration): String {
        val claims = Jwts.claims().add("userId", user.id).build()
        val now = Instant.now()

        return Jwts.builder()
            .subject(subject)
            .issuer("team6.explorers")
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plus(duration)))
            .claims(claims)
            .signWith(key)
            .compact()
    }

    fun validateToken(token: String): Boolean {
        try {
            Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
        } catch (e: MalformedJwtException) {
            throw IllegalArgumentException("Invalid Token")
        } catch (e: SignatureException) {
            throw IllegalArgumentException("Invalid Token")
        } catch (e: ExpiredJwtException) {
            throw IllegalArgumentException("Expired Token.")
        }
        return true
    }

    fun getSubject(token: String): String = Jwts.parser()
        .verifyWith(key)
        .build()
        .parseSignedClaims(token)
        .payload
        .subject

    fun getUserId(token: String): Long {
        val payload = Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .payload
        return payload["userId"].toString().toLong()
    }
}

이제 만든 JwtTokenProvider를 빈으로 등록해주면 된다.

위의 코드로 이제 JWT를 새로 만들고 JWT이 우리가 만든 토큰이 맞는지 검증까지 할 수 있게 되었다.
인증/인가는 클라이언트에서 요청시 HTTP 헤더 Authorization에 JWT을 담아서 요청한다는 가정하에 구현하려했다. 따라서 이제 넘어온 Request의 헤더에서 JWT을 가져오고 올바른 토큰인지 검증하는 로직을 구현하면 된다.

1번째 시도 : Interceptor

인터셉터는 요청이 컨트롤러에 들어가기전, 후의 HTTP Request 또는 Response를 가지고 작업할 수 있다. 그래서 컨트롤러로 요청이 들어가기 전에 인터셉트로 request를 가로채 토큰을 검증하려했다.

  • AuthInterceptor
@Component  
class AuthInterceptor(  
    private val jwtTokenProvider: JwtTokenProvider  
) : HandlerInterceptor {  
    override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {  
        val header = request.getHeader(HttpHeaders.AUTHORIZATION)?.replace("Bearer ", "")?.trim() ?: ""  
        if (!jwtTokenProvider.validateToken(header)) {  
            responseError(response, HttpStatus.BAD_REQUEST.value(), "Invalid token")  
            return false;  
        }        request.setAttribute("requestUserId", jwtTokenProvider.getUserId(header))  
        return true  
    }  
  
     private fun responseError(response: HttpServletResponse, code: Int, message: String) {  
        response.status = code  
        response.writer.print(message)  
     }
 }
  • WebMvcConfig
@Configuration
class WebMvcConfig(
    private val jwtTokenProvider: JwtTokenProvider
) : WebMvcConfigurer {
   override fun addInterceptors(registry: InterceptorRegistry) {  
    registry.addInterceptor(AuthInterceptor(jwtTokenProvider))  
        .addPathPatterns("/api/v1/**")  
        .excludePathPatterns("/api/v1/sign-up", "/api/v1/login")  
   }
}

HandlerInterceptor 인터페이스의 preHandle 메소드를 구현하여 request를 조작할 수 있도록 했다. 그리고 WebMvcConfigurer 인터페이스의 addInterceptors를 구현하여 스프링이 AuthInterceptor를 인식할 수 있도록 했다.

문제점

인터셉터를 거쳐 검증이 완료된 토큰을 컨트롤러에서 쓰려면

    fun example(request: HttpServletRequest) {
        val requestUserId: Long = request.getAttribute("requestUserId") as Long
    }

위와 같이 인터셉터에서 넣은 attribute를 HttpServletRequest 객체에서 꺼내는 코드가 인가가 필요한 곳에서 반복되게 된다. 물론, 메소드로 만들어서 반복을 줄일 수 있다. 하지만 HttpServletRequest 보다 좀더 명시적으로 인증, 인가를 다룬다는 것을 나타낼 수 있는 방법이 있으면 좋을 것 같았다.
또한, 인터셉터 내에서 발생하는 에러는 기존에 에러를 관리하던 ControllerAdvice로는 핸들링 할 수 없기때문에 별도로 처리하는 코드를 작성해주어야 한다.

이를 해결할 수 있는 방법이 없을까 찾다가 ArgumentResolver를 알게 되었다.

2번째 시도 : ArgumentResolver

ArgumentResolver?

ArgumentResolver는 컨트롤러 메소드의 파라미터 중에 조건에 맞는 파라미터가 있으면 원하는 객체에 바인딩해준다. 그리고 특정 어노테이션이 앞에 붙은 파라미터만 ArgumentResolver를 통하도록 설정할 수도 있다.

@RequestUser로 인가요청을 위한 파라미터임을 명시하고 AuthUser에 인가 요청을 하는 사용자와 검증된 토큰 정보를 담도록 구현하기로 했다.

  • @RequestUser
@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequestUser()
  • AuthUser
data class AuthUser(
    val id: Long,
    val token: String
)
@Component
class AuthArgumentResolver(
    private val jwtTokenProvider: JwtTokenProvider
) : HandlerMethodArgumentResolver {
    override fun supportsParameter(parameter: MethodParameter): Boolean {
        return parameter.hasParameterAnnotation(RequestUser::class.java) && parameter.parameterType.isAssignableFrom(
            AuthUser::class.java
        )
    }

    override fun resolveArgument(
        parameter: MethodParameter,
        mavContainer: ModelAndViewContainer?,
        webRequest: NativeWebRequest,
        binderFactory: WebDataBinderFactory?
    ): AuthUser {
        val request: HttpServletRequest =
            webRequest.getNativeRequest(HttpServletRequest::class.java) ?: throw IllegalArgumentException()
        val token = request.getHeader(HttpHeaders.AUTHORIZATION)?.replace("Bearer ", "")?.trim() ?: ""

        if (!jwtTokenProvider.validateToken(token)) throw IllegalArgumentException("Invalid Token")
        if (jwtTokenProvider.getSubject(token) != TokenType.ACCESS_TOKEN.name) throw IllegalArgumentException("ACCESS_TOKEN 아닙니다.")

        return AuthUser(id = jwtTokenProvider.getUserId(token), token = token)
    }
}

사용방법

    fun updateProfile(
        @PathVariable userId: Long,
        @RequestUser authUser: AuthUser,
        @RequestBody request: UpdateUserProfileRequest
    ): ResponseEntity<UserProfileResponse> {
        if (userId != authUser.id) throw UnauthorizedException("권한이 없습니다.")
        return ResponseEntity
            .status(HttpStatus.OK)
            .body(userService.updateProfile(userId, request))
    }

AuthArgumentResolver를 통해 검증된 토큰을
@RequestUser authUser: AuthUser 를 통해 사용할 수 있게 되었다.

문제점

구현 후 로그인, 인가까지 잘 작동됐다! 이제 기쁜 마음으로 마지막으로 남은 기능을 보았더니 로그아웃이 남아 있었다. 그러고 보니 JWT로 로그아웃을 어떻게 하지...? JWT의 장점인 Stateless 하다는 점 때문에 로그아웃을 구현하기가 상당히 애매했다. 왜냐하면 서버에서 발급되어 나간 토큰을 그 이후에 서버에서 조작할 수 가 없기 때문이다. 세션-쿠키 방식이었다면 로그아웃시 서버에서 해당 세션ID를 삭제하고 쿠키의 만료시간을 0으로 만들어 보내주면 된다. 하지만 JWT는 그럴수가 없다! 그러면 JWT를 사용할때에는 로그아웃을 어떻게 구현하는 걸까?
알아본 결과 결국 Stateless를 포기해야만 구현할 수 있는것 같다. 다만 RefreshToken을 도입하여 최대한 stateless에 가깝게 구현할 수 있을것 같다.

RefreshToken?

토큰을 클라이언트에 최초 발급할때 AccessToken과 RefreshToken 두 가지를 발급한다.

  • AccessToken은 실질적으로 요청시 사용되는 토큰이지만 만료시간이 짧다.
  • RefreshToken은 AccessToken 재발급을 요청할 때만 쓰이지만 만료시간이 길다.

3번째 시도 : RefreshToken

RefreshToken 만으로는 로그아웃을 구현하기가 힘들다. 발급후 DB에 저장한뒤 후에 요청시 비교하는 작업이 필요하다. 따라서 Stateless가 깨지긴 하지만 AccessToken 재발급시에만 이루어지기 때문에 세션-쿠키 방식보다는 훨씬 Stateless에 가깝다.

RefreshToken을 도입하여 구현한 로그아웃 요청 흐름은 다음과 같다.

  1. 로그인 시 발급된 RefreshToken을 userId와 함께 테이블에 저장한다.
  2. AccessToken 재발급 요청시 넘어온 RefreshToken이 테이블에 존재하는지 확인한다.
  3. 해당 토큰이 없다면 권한없음 응답을 내린다.
  4. 해당 토큰이 있다면 새 AccessToken을 발급해준다.
  5. 로그아웃 요청 시 해당 userId를 가진 RefreshToken을 테이블에서 삭제한다.
  6. 위의 RefreshToken으로 AccessToken 재발급 요청시 테이블에 해당 토큰이 존재하지 않기 때문에 재발급 받을 수 없게 된다.

물론 위의 흐름에는 보안상 빈틈이 생기긴한다. 만약 로그아웃 요청 후에도 가지고 있는 AccessToken이 만료되지 않았다면 그 토큰으로 요청을 보낼 수가 있다. 하지만 AccessToken까지 체크하는것은 세션-쿠키 방식과 다를바 없어지기 때문에 클라이언트에서 로그아웃 요청 후 AccessToken을 삭제하도록 해야 할 것 같다.

중간중간 생겼던 질문들

  1. 요청할때 request body에 요청한 사용자의 id를 따로받아와서 토큰에 담긴 값과 비교하는게 좋을까?
    -> NO. 그냥 토큰값만 사용하면 된다. HTTP 요청은 중간에 변조될 위험이 있기 때문에 body에 담긴 값 보다는 토큰의 신뢰성이 더 높기 때문
  2. AccessToken을 담은 객체와 RefreshToken을 담은 객체를 분리하는게 좋을까?
    -> NO. RefreshToken을 사용하는 경우는 AccessToken 재발급 요청때 뿐이다. 따라서 AuthUser는 AccessToken 요청인 경우에만 사용하고 AccessToken 재발급 요청 메소드에서만 @RequestHeader를 사용하여 토큰값을 따로 검증해주면 된다.

0개의 댓글