이번 프로젝트에서 회원 도메인과 인증, 인가 부분을 맡게 되었다. JWT을 도입해보기로 했는데 개념은 얼핏 알고 있었지만 프로젝트에서 써보는게 처음이라 이것저것 고민해볼 문제가 많았었다. 어떤 문제가 있었는지, 어떻게 해결했는지 그 과정을 정리해 보았다.
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을 가져오고 올바른 토큰인지 검증하는 로직을 구현하면 된다.
인터셉터는 요청이 컨트롤러에 들어가기전, 후의 HTTP Request 또는 Response를 가지고 작업할 수 있다. 그래서 컨트롤러로 요청이 들어가기 전에 인터셉트로 request를 가로채 토큰을 검증하려했다.
@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)
}
}
@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를 알게 되었다.
ArgumentResolver는 컨트롤러 메소드의 파라미터 중에 조건에 맞는 파라미터가 있으면 원하는 객체에 바인딩해준다. 그리고 특정 어노테이션이 앞에 붙은 파라미터만 ArgumentResolver를 통하도록 설정할 수도 있다.
@RequestUser로 인가요청을 위한 파라미터임을 명시하고 AuthUser에 인가 요청을 하는 사용자와 검증된 토큰 정보를 담도록 구현하기로 했다.
@RequestUser@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequestUser()
AuthUserdata 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에 가깝게 구현할 수 있을것 같다.
토큰을 클라이언트에 최초 발급할때 AccessToken과 RefreshToken 두 가지를 발급한다.
RefreshToken 만으로는 로그아웃을 구현하기가 힘들다. 발급후 DB에 저장한뒤 후에 요청시 비교하는 작업이 필요하다. 따라서 Stateless가 깨지긴 하지만 AccessToken 재발급시에만 이루어지기 때문에 세션-쿠키 방식보다는 훨씬 Stateless에 가깝다.
RefreshToken을 도입하여 구현한 로그아웃 요청 흐름은 다음과 같다.
물론 위의 흐름에는 보안상 빈틈이 생기긴한다. 만약 로그아웃 요청 후에도 가지고 있는 AccessToken이 만료되지 않았다면 그 토큰으로 요청을 보낼 수가 있다. 하지만 AccessToken까지 체크하는것은 세션-쿠키 방식과 다를바 없어지기 때문에 클라이언트에서 로그아웃 요청 후 AccessToken을 삭제하도록 해야 할 것 같다.
AuthUser는 AccessToken 요청인 경우에만 사용하고 AccessToken 재발급 요청 메소드에서만 @RequestHeader를 사용하여 토큰값을 따로 검증해주면 된다.