[spring webflux] spring security 없이 인증 구현하기

모지리 개발자·2023년 2월 22일
0

spring

목록 보기
4/8

Intro

기존 프로젝트에서 spring security 설정을 없애고 간단한 인증시스템을 구현한 것을 기록한 글입니다.

문제상황

as-is는 다음과 같았습니다.

굉장히 단순한 시스템입니다.
회원가입, 인증, 인가 모두 spring security의 의존성을 추가하여 사용했었습니다.

하지만 to-be는 다음과 같았습니다.

keycloak에 대한 설명은 링크로 대신하겠습니다. keycloak docs
간단히 말씀드리면 인증(Authentication)과 인가(Authorization)을 쉽게 해주고 SSO(Single-Sign-On)을 가능하게 해주는 오픈소스입니다.

위와 같이 변경되고 나면 사실 서버에서의 역할은 토큰 받아서 토큰 검증한다음에 인증된 유저면 api호출가능하도록 하면 되는 것이었습니다.

서버에서의 역할이 많이 줄었고 위에서 말한 기능은 spring security를 사용하지 않고도 구현가능할 것 같아서 spring security를 제거하고 인증시스템을 구현하기로 하였습니다.

아래 예시 코드는 실제와 다릅니다.

HandlerMethodArgumentResolver란?

우선 첫번째로 활용하고자 했던 것은 HandlerMethodArgumentResolver 활용하는 것이었습니다.
HandlerMethodArgumentResolver란 컨트롤러 메서드에서 특정 조건에 맞는 파라미터가 있을 때 원하는 값을 바인딩해주는 인터페이스 입니다.

HandlerMethodArgumentResolver를 사용하기 위해 우선 어노테이션 클래스를 추가합니다.

@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class LoginUser()

그 후 HandlerResolverArgumentResolver를 상속받는 클래스를 추가합니다.

class LoginUserArgumentResolver : HandlerMethodArgumentResolver {
    
    override fun supportsParameter(parameter: MethodParameter): Boolean {
        TODO("Not yet implemented")
    }

    override fun resolveArgument(
        parameter: MethodParameter,
        mavContainer: ModelAndViewContainer?,
        webRequest: NativeWebRequest,
        binderFactory: WebDataBinderFactory?
    ): Any? {
        TODO("Not yet implemented")
    }
    
}

간단하게 말씀드리면 supportsParameter 함수의 결과가 참이라면 resolveArgument함수가 실행됩니다.

하고자 하는 것은 다음과 같습니다.
1. 만약 컨트롤러 메서드에 위에서 생성한 어노테이션이 들어가있다면
2. 아래에서 토큰을 인증하고 문제 있는 토큰이면 에러를 뱉는다.

해당 함수를 구현하기 위해 원하는 값을 담을 dto를 생성했습니다.
해당 dto에는 jwt로부터 추출할 수 있는 정보를 담으시면 됩니다.

data class UserDto(
    val email: String,
    val id: String,
)

저는 아래와 같이 HandlerMethodArgumentResolver를 구현했습니다.

@Component
class LoginUserArgumentResolver : HandlerMethodArgumentResolver {
    override fun supportsParameter(parameter: MethodParameter): Boolean {
        return parameter.hasParameterAnnotation(LoginUser::class.java)
    }

    override fun resolveArgument(
        parameter: MethodParameter,
        bindingContext: BindingContext,
        exchange: ServerWebExchange
    ): Mono<Any> {
        val jwtToken = exchange.request.headers.getFirst("Authorization")?.split(" ")?.get(1)

        //todo 아래에서 검증하고...

        if (!jwtToken.isNullOrEmpty()) {
            //token이 있다면 payload로 부터 정보를 가져오기 위해 jwt의 payload만 따로 추출
            val payload = jwtToken.split(".")[1]

            //todo payload를 인증하고...

            val claims = ObjectMapper().readValue(String(Base64Utils.decodeFromString(payload)), Map::class.java)

            val email = claims["email"]?.toString()
            val id = claims["id"]?.toString()

            return UserDto(
                email = email!!,
                id = id!!
            ).toMono()
        } else {
            throw AuthenticationException("JWT 토큰 없거나 문제있음")
        }
    }

}

마지막으로 생성한 resolver를 적용하기 위해서는 아래와 같이 webConfig에 custom resolver를 추가해줘야합니다.

@Configuration
class WebConfig(
    private val loginUserArgumentResolver: LoginUserArgumentResolver
) : WebFluxConfigurer {

    override fun configureArgumentResolvers(configurer: ArgumentResolverConfigurer) {
        configurer.addCustomResolver(loginUserArgumentResolver)
        super.configureArgumentResolvers(configurer)
    }
}

테스트 해보기

테스트 컨트롤러는 다음과 같이 작성했습니다.

@RestController
class TestController {

    @GetMapping("/test1")
    suspend fun test1(@LoginUser userDto: UserDto): UserDto {
        return userDto
    }

    @GetMapping("/test2")
    suspend fun test2(): String {
        return "인증이 필요하지 않음"
    }

}

토큰은 https://jwt.io/에 들어가서 다음과 같이 생성했습니다.

생성된 토큰을 사용해서 test해보겠습니다.

토큰을 넣어서 test1을 호출하게 되면

제가 기대했던 데이터를 추출합니다.

만약 토큰이 없거나 잘못되면

에러를 뱉습니다.

문제점 발견

이렇게 구현하고 나니 맘에 들지않는 문제점을 발견했습니다. 현재 가지고 있는 대부분의 api는 인증된 유저만 사용할 수 있는 api인데 모든 api의 파라미터에 생성한 어노테이션을 붙여야하고 심지어 대부분의 api에서 받아온 데이터를 사용하지도 않습니다.

그래서 class 레이어에 붙이는 방법을 생각했습니다.

위에서 눈치채신 분들도 계시겠지만 저는 미리 target어노테이션에 CLASS도 추가해둔 상태입니다.

@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class LoginUser()

이후

@RestController
@LoginUser
class TestController {
	...
}

이렇게 하면 왠지 될거같아서 테스트해봤는데 안됐습니다. 이유는 간단합니다.
제가 추가한 HandlerMethodArgumentResolver는 메소드의 파라미터에 한해서 동작합니다. Class에 어노테이션을 암만 추가해봐도 HandlerMethodArgumentResolver에서 동작하지는 않습니다.

HandlerAdapter 활용하기

컨트롤러에 붙은 어노테이션을 제어하기 위해 스프링의 동작원리를 다시 되새깁니다.

요청을 받고
1. 핸들러 조회
2. 핸들러를 처리할 수 있는 핸들러 어댑터를 조회
3. handle
4. handler 호출
...
와 같은 과정을 거치기 때문에 저는 핸들러 어댑터를 상속받는 클래스를 구현하기로 했습니다.
코드는 아래와 같습니다.

@Component
class AuthzWebHandlerAdapter : RequestMappingHandlerAdapter() {

    override fun supports(handler: Any): Boolean {
        val bean = (handler as HandlerMethod).bean
        val isSupported = bean.javaClass.declaredAnnotations.any {
            it.annotationClass.java == LoginUser::class.java
        }
        return isSupported
    }

    override fun handle(exchange: ServerWebExchange, handler: Any): Mono<HandlerResult> {

        try {
            val jwtToken = exchange.request.headers.getFirst("Authorization")?.split(" ")?.get(1)
            if (!jwtToken.isNullOrEmpty()) {
                
                //todo 추가 인증이 필요하면 추가한다.

            } else {
               throw AuthenticationException("인증필요")
            }
        } catch (e: Exception) {
            throw AuthenticationException("인증필요")
        }
        return super.handle(exchange, handler)
    }

}

이렇게 코드를 추가하고 TestController를 다음과 같이 수정해보겠습니다.

@RestController
@LoginUser
class TestController {

    @GetMapping("/test1")
    suspend fun test1(@LoginUser userDto: UserDto): UserDto {
        return userDto
    }

    @GetMapping("/test2")
    suspend fun test2(): String {
        return "인증이 필요하지 않음"
    }

}

분명 test2()는 인증이 필요하지 않았지만 이제는 호출하게 되면 인증이 필요하게 됩니다.

handler에는 testController#test2가 들어오고 해당 handler의 bean을 추출한다음에(여기서는 TestController가 되겠죠) 해당 클래스에 LoginUser 어노테이션이 있으면 아래 handle 메서드가 실행되는 방식입니다.

이렇게 모든 메서드에 LoginUser 어노테이션을 붙이지 않고 class에만 어노테이션을 할당하면서 코드 중복을 줄일 수 있었습니다.

근데 사실

이렇게 안하고 Filter쓰면 더 간단하게 할 수 있습니다.
Filter써서 url path 확인한다음에 인증이 필요하지 않은 url은 filter 안타게 함으로써 현재보단 더 간단하게 구현할 수 있을 것 같습니다.

단점

HandlerAdapter레벨에서 exception을 뱉으면 @ControllerAdvice에서 선언한 custom exception이 동작하지 않습니다. 더 상위레벨에서 exception을 뱉기 때문인거같습니다.

결론

spring security만 써서 인증시스템을 구현했었는데 직접 해보니 재밌기도하고...뭔가 깊숙히 들어가는 재미를 느낄수 있어서 좋았습니다.

profile
항상 부족하다 생각하며 발전하겠습니다.

0개의 댓글