글 작성자: beaniejoy

Spring Security에서 인증 및 인가 예외가 발생할 수 있는데 이를 Spring의 @ExceptionHandler로 적용해보는 과정을 정리해보고자 합니다. 해당 글을 이해하기 위해서는 기본적인 Spring Security filter 진행 순서와 과정, 메커니즘을 어느정도 알고 있어야 읽기에 수월할 것 같습니다. (그렇다고 어려운 내용도 아니고 쉽습니다.)

 

📌 1. 인증 과정에서의 인증 예외 처리

Spring Security에서 인증 과정을 처리해주는 인증 필터가 존재합니다. 예를 들어, 기본적인 form login 방식에서는 UsernamePasswordAuthenticationFilter가 있습니다.

그리고 custom한 인증 필터를 만들어서 적용해보고 싶다하면 여러가지 방식이 있겠지만 UsernamePasswordAuthenticationFilter의 상위 상속 클래스인 AbstractAuthenticationProcessingFilter를 상속받아서 원하는 방식대로 코드를 doFilter에 구현하고이를 Security Config 파일에 적용할 수도 있고 따로 Filter를 구성해서 직접 Config에 적용할 수 있습니다.

제가 테스트했던 프로젝트에서는 custom한 인증 filter를 적용했고 실제 인증을 처리해주는 AuthenticationProvider와 사용자정보를 조회하는 UserDetailsService를 따로 구현하여 적용했습니다.
이에 대한 내용은 생략하겠습니다. (테스트 프로젝트는 github url로 남겨두겠습니다.)

이야기가 산으로 갔는데 본론으로 들어가면 인증 과정을 처리해주는 Security filter가 따로 있고 이 과정에서 인증 예외가 발생할 수 있습니다. 예를 들어 사용자가 입력한 email(username) 정보가 DB에 없다거나 입력한 비밀번호가 DB에 저장된 비밀번호와 불일치하는 경우 인증 예외가 발생합니다.

여기서 발생하는 인증 예외는 AuthenticationException이고 이를 상속하는 클래스가 여러 개 있습니다.

AuthenticationException을 상속하고 있는 클래스들

 

📌 2. 인증 예외에 대한 ExceptionHandler 적용하기

위의 AuthenticationException에 대한 ExceptionHandler를 정의해서 ControllerAdvice 설정된 클래스에서 예외를 관리하도록 구현합니다.

@RestControllerAdvice
class BasicControllerAdvice {
	
    companion object: KLogging()
    
    /**
     * 인증, 인가 관련 에러 처리
     */
    @ResponseStatus(HttpStatus.OK)
    @ExceptionHandler(AuthenticationException::class)
    fun handleAuthException(e: AuthenticationException): ApplicationResponse<Nothing> {
        logger.error { "[${e::class.simpleName}] <ErrorCode>: ${errorCode.name}, <ErrorMessage>: ${e.message}" }
        
        return ApplicationResponse.fail(errorCode = ErrorCode.AUTH_UNAUTHORIZED).build()
    }
}

위의 코드대로 AuthenticationException에 대한 handling하는 핸들러를 만들어두었습니다.
제가 구현한 AuthenticationProvider, UserDetailsService에서의 인증 절차 내용은 Controller 진입 후 처리되도록 구현했습니다. 

// AuthController
@PostMapping("/authenticate")
fun signIn(@RequestBody signInRequest: SignInRequest): ApplicationResponse<TokenResponse> {
    val authentication = authService.signIn(
        email = signInRequest.email,
        password = signInRequest.password
    )

    val accessToken = jwtTokenUtils.createToken(authentication)

    return ApplicationResponse
        .success("success authenticate")
        .data(TokenResponse(accessToken))
}

// AuthService
fun signIn(email: String, password: String): Authentication {
    val authenticationToken = UsernamePasswordAuthenticationToken(email, password)

    return authenticationManager.authenticate(authenticationToken)
}

Controller 진입 이후 인증 절차가 진행되기에 이 과정에서 발생한 AuthenticationException에 대해서 @ExceptionHandler가 잘 catch해서 처리해줄 것입니다.
(실질적으로 Controller 단에서 발생한 에러를 throw 했을 때 DispatcherServlet까지 에러가 전달이 되고 여기서 ExceptionResolver에 의해서 @ExceptionHandler에 적용된 핸들러로 에러를 전달하게 됩니다.)

 

📌 3. 인가 과정에서의 AuthenticationException과 AccessDeniedException

사용자가 인증 요청이 아닌 어떤 API를 요청했을 때 Spring Security는 해당 요청자가 요청한 API에 접근가능한 권한을 가지고 있는지 판단해서 거르는 과정을 진행해줍니다.

이것이 바로 인가 프로세스인데요. FilterSecurityInterceptor라는 필터에서 진행합니다. 그리고 이 필터에서는 AuthenticationExceptionAccessDeniedException 두 개의 예외가 발생할 수 있습니다. 예외가 발생하면 ExceptionTranslationFilter에서 이 오류들을 catch해서 처리하는 부분이 있습니다.

ExceptionTranslationFilter에서 두 개의 Exception을 처리하는 부분

  • AuthenticationException -> AuthenticationEntryPoint
  • AccessDeniedException -> AccessDeniedHandler

각각의 Exception을 처리해주는 녀석들이 있는데 바로위의 AuthenticationEntryPoint, AccessDeniedHandler입니다.
저는 위 두 개의 인터페이스를 따로 구현해서 제가 원하는 에러를 던져보고자 합니다.

@Component
class CustomAuthenticationEntryPoint : AuthenticationEntryPoint {
    companion object: KLogging()

    override fun commence(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authException: AuthenticationException
    ) {
        logger.info { "Unauthorized Error!!" }
        throw authException
    }
}

@Component
class CustomAccessDeniedHandler : AccessDeniedHandler {
    companion object : KLogging()

    override fun handle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        accessDeniedException: AccessDeniedException
    ) {
        logger.info { "Access Denied!!!!!" }
        throw accessDeniedException
    }
}

이렇게 custom 구현체 두 개를 만들고 SecurityConfig에 적용합니다.

@ResponseStatus(HttpStatus.OK)
@ExceptionHandler(*arrayOf(AuthenticationException::class, AccessDeniedException::class))
fun handleAuthException(e: Exception): ApplicationResponse<Nothing> {
    val errorCode = when (e) {
        is AuthenticationException -> ErrorCode.AUTH_UNAUTHORIZED
        is AccessDeniedException -> ErrorCode.AUTH_ACCESS_DENIED
        else -> ErrorCode.DEFAULT
    }

    logger.error { "[${e::class.simpleName}] <ErrorCode>: ${errorCode.name}, <ErrorMessage>: ${e.message}" }
    return ApplicationResponse.fail(errorCode = errorCode).build()
}

그리고 ExceptionHandler 내용도 수정해줍니다. AccessDeniedHandler도 처리해줘야 하기 때문에  두 개의 Exception을 한꺼번에 처리하는 handler로 수정했습니다.

 

이렇게 만들어놓고 authenticated 설정된 API 하나 호출해보겠습니다. 기대하는 결과는 바로 위의 handleAuthException 핸들러가 동작하면서 제가 정의한 json 형태로 응답값을 받는 것입니다.

$ curl -X GET localhost:9090/auth/check | jq ''                                                             ✔  base 
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  7250    0  7250    0     0   643k      0 --:--:-- --:--:-- --:--:--  643k
{
  "timestamp": "2023-01-20T05:29:51.885+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "trace": "org.springframework.security.authentication.InsufficientAuthenticationException: Full authentication is required to access this resource\n\tat org.springframewo",
  "message": "Full authentication is required to access this resource",
  "path": "/auth/check"
}

오잉 의도치 않는 형태로 응답이 나왔습니다. 

정확한 내용인지는 모르겠지만 ExceptionTranslationFilter를 통해 AuthenticationEntryPoint, AccessDeniedHandler가 동작하는 구간은 Filter 구간입니다. 즉 DispatcherServlet 전에 동작하는 부분입니다. 하지만 @ExceptionHandler는 DispatcherServlet에서 ExceptionResolver를 통해 예외 해결 시도할 때 동작하는 부분입니다.

즉 ExceptionResolver가 처리를 기대하기도 전에 Security Filter 단계에서 Exception을 throw한 셈이 됩니다.

이를 동기화하기 위해 AuthenticationEntryPoint, AccessDeniedHandler에 HandlerExceptionResolver를 통해서 ExceptionResolver가 해당 인증, 인가 예외를 다룰 수 있도록 설정할 수 있습니다.

@Component
class CustomAuthenticationEntryPoint(
    private val handlerExceptionResolver: HandlerExceptionResolver
): AuthenticationEntryPoint {
    companion object: KLogging()

    override fun commence(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authException: AuthenticationException
    ) {
        logger.info { "Unauthorized Error!!" }
        handlerExceptionResolver.resolveException(request, response, null, authException)
    }
}

AccessDeniedHandler 내용도 거의 똑같습니다.
이렇게 하면 ExceptionResolver에게 명시적으로 Exception을 전달해주어 @ExceptionHandler에서 처리되도록 할 수 있습니다.

$ curl -X GET localhost:9090/auth/check | jq ''                                                             ✔  base 
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    49    0    49    0     0    188      0 --:--:-- --:--:-- --:--:--   187
{
  "result": "FAIL",
  "errorCode": "AUTH_UNAUTHORIZED"
}

의도한 형태로 응답값을 받았습니다. 너무 좋네요.

 

📌 4. 정리

제 테스트 프로젝트의 경우 AuthenticationException, AccessDeniedException 발생 케이스를 아래와 같이 나눌 수 있습니다.

  • 인증(로그인) 과정
    - 여기서 발생한 에러는 AuthenticationException
    - Controller 단 내부에서 인증 과정이 진행되도록 구성
    - 여기서 발생한 에러는 ExceptionResolver가 감지하게 되어 @ExceptionHandler로 처리 가능
  • FilterSecurityInterceptor
    - 여기서는 AuthenticationException, AccessDeniedException 둘 다 발생 가능
    - ExceptionTranslationFilter가 해당 오류들을 catch해서 각각 AuthenticationEntryPoint, AccessDeniedHandler에 처리 위임
    - 위 과정은 Filter 단에서 이루어지는 내용이므로 여기서 throw e을 해도 ExceptionResolver가 감지를 못함
    (DispatcherServlet 과정 체크)
    - 명시적으로 ExceptionResolver에게 에러를 전달해야 함
    (HandlerExceptionResolver resolveException)

 

📌 5. References

 

GitHub - beaniejoy/dongne-cafe-api: ☕️ 동네 카페를 위한 사이렌 오더 토이 프로젝트 (~ing)

☕️ 동네 카페를 위한 사이렌 오더 토이 프로젝트 (~ing). Contribute to beaniejoy/dongne-cafe-api development by creating an account on GitHub.

github.com