제가 요즘 개발하고 있는 소프트웨어 13기 팀 몬스테라의 CS Broker 백엔드 서버는 Kotlin 과 Spring으로 이루어진 스택으로 개발을 진행하고 있습니다.
이 API 서버는 인증 및 인가에서 Stateless를 유지하기 위해, 제일 자주 사용되는 JWT를 사용하기로 했습니다.
근데, 권한이 필요한 API는 본 로직을 실행하기 전에 반복적으로 JWT를 통해 얻은 SecurityContext를 체크하는 반복로직을 추가해야했습니다.
예를 들어, 아래처럼 말이죠.
fun some_logic1_need_to_check_authority(){
check_jwt_in_header()
get_user_info_in_jwt()
real_logic1()
}
fun some_logic2_need_to_check_authority(){
check_jwt_in_header()
get_user_info_in_jwt()
real_logic()
}
이 반복작업이 너무 불편하다고 느꼈고, 이것을 해결하기 위해 여러가지 방법을 순차적으로 진행했습니다.
AOP ( Aspect Oriented Programming ) 은 관점 지향 프로그래밍이라고 불리는 기술입니다.
PointCut 등을 통해 반복 작업을 제거 할 수 있고, 특정 상황에 따라 파라미터를 주입한다던지 할 수 있습니다.
헤더에 있는 토큰은 AOP를 통해 해결 할 수 있지 않을까?
위와 같은 생각을 가지고 저는 다음과 같은 코드를 추가했습니다.
헤더에 있는 jwt를 파싱
파싱에 성공하면, jwt를 validation
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class IsLogin
@Component
@Aspect
class IsLoginAspect(private val tokenProvider: AuthTokenProvider) {
@Before("@annotation(com.csbroker.apiserver.common.auth.IsLogin)")
fun isLogin(ProceedingJoinPoint joinPoint) {
var request: HttpServletRequest? = null
for (arg in joinPoint.args) {
if (arg is HttpServletRequest) {
request = arg
break
}
}
if(request != null){
val tokenStr = getAccessToken(request)
val token = tokenProvider.convertAuthToken(tokenStr)
if (!token.isValid) {
throw UnauthroizedException("Access token이 올바르지 않습니다.")
}
}
throw UnauthroizedException("Access token이 없습니다.")
}
}
이제 token 존재 유무 및 validation을 마쳤기 때문에, 각 method에서 토큰이 있는지 그리고, 토큰이 유효한지 체크를 하지 않아도 됩니다.
그래도 HttpServletRequest에 접근하고, 정보를 파싱하는 로직을 계속 넣어야합니다.
다음처럼 말이죠..
val tokenStr = getAccessToken(request)
val token = tokenProvider.convertAuthToken(tokenStr)
val email = token.email
// real logic...
오직 헤더 접근을 위해 받아오는 HttpServletRequest를 사용하기도 싫었고, 파라미터정보를 파싱하는 로직 자체도 계속 반복되는게 너무 스트레스 받아서 이제 Arguemtn Resolver의 마법을 부리기로 했습니다.
ArguemtnResolver는 Spring에서 제공하는 기술 중 하나로, 특정 메소드의 파라미터를 주입해주는 방법입니다.
저는 여기서 ArgumentResolver를 통해 user 정보가 필요한 곳에 AuthToken 객체를 넣어주기로 했습니다.
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
annotation class LoginUser
저는 위와 같이 LoginUser라는 Runtime시에 VALUE_PARAMETER에 사용할 수 있는 어노테이션을 구현하였스브니다.
@Component
class LoginUserArgumentResolver(
private val tokenProvider: TokenProvider
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean {
parameter.getParameterAnnotation(LoginUser::class.java) ?: return false
if (!parameter.parameterType.isAssignableFrom(AuthToken::class.java)) {
return false
}
return true
}
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?
): Any? {
val request = webRequest.nativeRequest as HttpServletRequest
val tokenStr = getAccessToken(request)
val token = tokenProvider.convertAuthToken(tokenStr)
if (!token.isValid) {
throw UnauthorizedException("인증되지 않은 토큰입니다.")
}
return token
}
}
HandlerMethodArgumentResolver는 위에서 구현한 어노테이션의 유무를 체크하여, 특정 파라미터에 값을 넣어 줄 수 있습니다.
이를 통해, 저는 @LoginUser
라는 어노테이션이 붙은 파라미터에 올바른 JWT가 들어와서 authentication이 할당된 경우 그 값을 파라미터에 삽입할 수 있었습니다.
@Configuration
class WebMcvConfig(
private val loginUserArgumentResolver: LoginUserArgumentResolver
) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(loginUserArgumentResolver)
}
}
현재 WebMvcConfigurerAdapter
가 deprecate 되었습니다.
그렇기 때문에 위와 같이 WebMvcConfigurer
를 상속하여 구현한 WebMvcConfig
의 addArgumentResolver
메소드에 새로 만든 loginUserArgumentResolver를 추가해야합니다.
@GetMapping
fun getUser(request: HttpServletRequest): ApiResponse<UserResponseDto> {
val tokenStr = getAccessToken(request)
if(tokenStr == null){
throw UnauthorizedException("access token does not exist")
}
val token = tokenProvider.convertAuthToken(tokenStr)
if(!token.isValid){
throw UnauthorizedException("권한이 없습니다.")
}
val findUser = this.userService.findUserByEmail(token.email)
?: throw UnauthorizedException("${token.email} 은 유효한 이메일이 아닙니다.")
return ApiResponse.success(findUser.toUserResponseDto())
}
@GetMapping
@IsLogin
fun getUser(@LoginUser authToken: AuthToken): ApiResponse<UserResponseDto> {
val findUser = this.userService.findUserByEmail(authToken.email)
?: throw UnauthorizedException("${authToken.email} 은 유효한 이메일이 아닙니다.")
return ApiResponse.success(findUser.toUserResponseDto())
}
위를 살펴보면 알 수 있듯이, @LoginUser가 달린 파라미터에 자동적으로 authToken의 값이 할당 되기 때문에 반복적인 체크 과정을 삭제 할 수 있었습니다.
행복하게 해결 할 수 있었습니다 :)