이번에는 JWT 토큰을 검증하기 위해 Interceptor 를 적용해보았습니다.
Spring 에서 제공하는 Filter 와 Interceptor는 요청을 중간에 가로채서 무언가 처리할 수 있는 수단입니다.
Spring MVC 의 요청 방식은 다음과 같습니다.
요청은 Client → Filter → Servlet → Interceptor → Spring 순서로 전달되며
DispatcherServlet
앞뒤로 Filter 와 Interceptor 가 동작하게 됩니다.
그래서 저는 Interceptor
를 사용해서 JWT 인증을 구현하였습니다.
토큰 생성, 검증, userId 추출 기능을 구현했습니다.
@Component
class JwtUtil {
private val secret: String = "mysecretmysecretmysecretmysecretmysecretmysecret"
private val key: SecretKey = Keys.hmacShaKeyFor(secret.toByteArray())
fun generateToken(userId: Long, expirationMs: Long = ONE_HOURS_MS): String {
val now = Date()
return Jwts.builder()
.subject(userId.toString())
.issuedAt(now)
.expiration(Date(now.time + expirationMs))
.signWith(key)
.compact()
}
fun validateToken(token: String): Boolean = try {
Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
true
} catch (e: Exception) {
LOG.error("Invalid JWT token", e)
false
}
fun extractUserId(token: String): Long {
val claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).payload
return claims.subject.toLong()
}
companion object {
private const val ONE_HOURS_MS = 60 * 60 * 1000L
private val LOG = LoggerFactory.getLogger(this::class.java)
}
}
JWT 토큰을 꺼내서 검증하고, 성공하면 request 에 userId
를 넣습니다.
@Component
class AuthInterceptor(
private val jwtUtil: JwtUtil,
) : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
): Boolean {
val token = request.getHeader("Authorization")
if (token.isNullOrEmpty() || !token.startsWith("Bearer ")) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
return false
}
val jwt = token.removePrefix("Bearer ").trim()
if (!jwtUtil.validateToken(jwt)) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
return false
}
val userId = jwtUtil.extractUserId(jwt)
request.setAttribute("userId", userId)
return true
}
}
파라미터 이름이 "userId" 이고 타입이 Long 이면 자동으로 넣어줍니다.
@Component
class UserIdArgumentResolver : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean {
return parameter.parameterType == Long::class.java && parameter.parameterName == "userId"
}
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?,
): Any {
val userId = webRequest.getAttribute("userId", RequestAttributes.SCOPE_REQUEST) as? Long
return userId ?: throw IllegalStateException("userId not found in request")
}
}
Interceptor
와 ArgumentResolvers
를 스프링 MVC 설정에 등록합니다.
@Configuration
class WebConfig(
private val authInterceptor: AuthInterceptor,
private val userIdArgumentResolver: UserIdArgumentResolver,
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**")
}
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(userIdArgumentResolver)
}
}
이제 컨트롤러는 그냥 userId
를 파라미터로 받을 수 있습니다.
JWT 검증은 Interceptor
에서 처리하고 userId
는 ArgumentResolvers
가 넣어줍니다.
@RestController
@RequestMapping("/api/v1/users")
class UserController(
val userRepository: UserRepository,
) {
@GetMapping("/me")
fun me(userId: Long): ResponseEntity<UserResponse> {
val user = userRepository.findById(userId)
.orElseThrow { IllegalArgumentException("유저를 찾을 수 없습니다. id=$userId") }
return ResponseEntity.ok(UserResponse.from(user))
}
}
MockMvc 를 통해 내 정보를 가져오는 API 를 테스트해보았습니다.
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest(
val mockMvc: MockMvc,
val jwtUtil: JwtUtil,
val userRepository: UserRepository,
) : FunSpec({
test("GET /api/v1/users/me 요청이 들어왔을 때 200 OK 반환해야 함") {
val user = User(
username = "dustle",
password = "111",
email = "111",
nickname = "111"
).also {
userRepository.saveAndFlush(it)
}
val token = jwtUtil.generateToken(user.id)
mockMvc.get("/api/v1/users/me") {
header("Authorization", "Bearer $token")
}.andDo { print() }
.andExpect {
status { isOk() }
jsonPath("$.username") { value(user.username) }
jsonPath("$.email") { value(user.email) }
jsonPath("$.nickname") { value(user.nickname) }
}
}
})
Spring Security 없이 JWT 인증을 처리해보았습니다.
나중에는 ArgumentResolver
을 커스텀 어노테이션으로 추가하거나
예외 처리/에러 포맷 개선도 해보면 좋을 것 같습니다.