본문 바로가기
  • 개발하는 곰돌이
Development/Spring & Spring Boot

[Spring Security] Spring Security 예외를 @ControllerAdvice와 @ExceptionHandler를 사용하여 전역으로 처리해보자

by 개발하는 곰돌이 2023. 5. 24.
이 글은 이전의 [Spring Security] Spring Security와 JWT를 사용하여 사용자 인증 구현하기(Spring Boot 3.0.0 이상)에서 진행했던 예제 프로젝트에서 이어집니다.
[수정사항]
2023-05-25 : 스프링 시큐리티 설정이 잘못되어 발생했던 문제의 필터 관련 내용 제거

목차

    들어가기 전에

    예외 처리를 해놓지 않은 예제 프로젝트 Github : Kotlin, Java

    애플리케이션을 개발하다 보면 다양한 이유로 예외 처리를 하게 된다. 그중 스프링이나 스프링부트로 개발되는 경우라면 중복되는 try-catch를 줄이기 위해 @ControllerAdvice 또는 @RestControllerAdvice에서 @ExceptionHandler를 사용하여 API 호출 과정에서 발생하는 예외를 전역적으로 처리하는 경우가 있다.

     

    하지만 @ExceptionHandler를 사용하는 방법은 기본적으로 컨트롤러 계층의 예외만 처리하게 된다. 이전에 스프링 시큐리티 + JWT로 사용자 인증을 구현하는 과정에서 예외 처리는 진행하지 않았는데, 이번 글에서 추가적인 설정을 통해 @ControllerAdvice에서 스프링 시큐리티에서 발생하는 예외를 전역적으로 처리해보자.

     

    @RestControllerAdvice@ControllerAdvice@ResponseBody가 합쳐진 형태이기 때문에 본문에서는 @ControllerAdvice만 언급할 것이다.

    왜 스프링 시큐리티 예외는 @ControllerAdvice에서 처리할 수 없을까?

    일반적으로 예외는 클라이언트 측에서 서버로 보낸 요청을 애플리케이션의 컨트롤러가 받은 후 요청에 대한 비즈니스 로직을 처리하는 과정에서 발생한다. 즉, 일단 요청이 컨트롤러에 도달한 다음에 예외가 발생하는 것이다.

     

    하지만 스프링 시큐리티는 요청이 컨트롤러에 도달하기 전에 필터 체인에서 예외를 발생시킨다. 앞서 이야기했듯이 @ControllerAdvice는 컨트롤러 계층에서 발생하는 예외를 처리하는데, 요청이 컨트롤러에 도달하기도 전에 이미 예외가 발생해서 요청이 컨트롤러에 도달하지도 못했기 때문에 @ControllerAdvice에서 처리를 할 수가 없다.

    그렇다면 어떻게 해야 할까?

    스프링 시큐리티에서는 사용자가 인증되지 않았거나 AuthenticationException이 발생했을 때  AuthenticationEntryPoint에서 예외 처리를 시도한다. 따라서 AuthenticationEntryPoint의 구현체를 적절하게 이용한다면 @ControllerAdvice로 스프링 시큐리티 예외를 처리할 수 있을 것이다.

     

    우선 어떤 상황에서 어떤 예외가 발생하는지 확인해 보고 차근차근 예외 처리를 진행해 보자.

    아무런 설정을 하지 않은 상태에서 발생하는 예외

    변조된 토큰을 사용하는 경우, 토큰으로 아무 값이나 사용하는 경우, 토큰이 만료된 경우를 각각 테스트해 보면 콘솔에 다음과 같이 예외가 발생하는 것을 확인할 수 있다.

    변조된 토큰을 사용하는 경우.
    토큰으로 아무 값이나 사용하는 경우.
    토큰이 만료된 경우.

    헤더에 담긴 토큰의 상태에 따라 각기 다른 예외를 발생시킨다. 문제는 이렇게 발생한 예외가 따로 처리되지 않아서 콘솔에 Stack trace가 모조리 출력된다는 것이다. 

     

    차근차근 해당 문제를 해결해 보도록 하자.

    예외들을 어떻게 처리할 지부터 정해놓자

    위 과정에서 발생한 예외는 SignatureException, MalformedJwtException, ExpiredJwtException이다. handler 패키지의 Exception(Response)Handler에서 해당 예외들도 처리하도록 설정해 놓자.

     

    Kotlin

    @RestControllerAdvice
    class ExceptionHandler {
        ...
        // 아래 내용 모두 추가
        @ExceptionHandler(SignatureException::class)
        fun handleSignatureException() = 
            ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error("토큰이 유효하지 않습니다."))
    
        @ExceptionHandler(MalformedJwtException::class)
        fun handleMalformedJwtException() = 
            ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error("올바르지 않은 토큰입니다."))
    
        @ExceptionHandler(ExpiredJwtException::class)
        fun handleExpiredJwtException() = 
            ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error("토큰이 만료되었습니다. 다시 로그인해주세요."))
    }

    Java

    @RestControllerAdvice
    public class ExceptionResponseHandler {
        ...
        // 아래 내용 모두 추가
        @ExceptionHandler(SignatureException.class)
        public ResponseEntity<ApiResponse> handleSignatureException() {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error("토큰이 유효하지 않습니다."));
        }
    
        @ExceptionHandler(MalformedJwtException.class)
        public ResponseEntity<ApiResponse> handleMalformedJwtException() {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error("올바르지 않은 토큰입니다."));
        }
    
        @ExceptionHandler(ExpiredJwtException.class)
        public ResponseEntity<ApiResponse> handleExpiredJwtException() {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error("토큰이 만료되었습니다. 다시 로그인해주세요."));
        }
    }

    여기서 SignatureException은 세 종류가 있는데, io.jsonwebtoken.security에 있는 것을 사용해야 한다.

     

    일단은 토큰의 상태에 따라 반환할 응답을 지정했다. 물론 당장은 여기서 이 예외들을 처리하지 못한다.

    @ControllerAdvice에서 시큐리티 예외를 처리하고 싶다!

    AuthenticationEntryPoint 구현체 작성

    앞서 @ControllerAdvice에서 스프링 시큐리티 예외를 처리하려면 AuthenticationEntryPoint의 구현체를 적절하게 이용하면 된다고 언급하였다. @ExceptionHandler로 스프링 시큐리티 예외를 처리하기 위해 security 패키지에 AuthenticationEntryPoint 구현체를 작성한다.

     

    Kotlin

    @Component
    class JwtAuthenticationEntryPoint(
        @Qualifier("handlerExceptionResolver")
        private val resolver: HandlerExceptionResolver
    ) : AuthenticationEntryPoint {
        override fun commence(request: HttpServletRequest?, response: HttpServletResponse?, authException: AuthenticationException?) {
            resolver.resolveException(request!!, response!!, null, authException!!)
        }
    }

    Java

    @Component
    public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
        private final HandlerExceptionResolver resolver;
    
        public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
            this.resolver = resolver;
        }
    
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
            resolver.resolveException(request, response, null, authException);
        }
    }

    이 엔트리포인트는 스프링 시큐리티에서 인증과 관련된 예외가 발생했을 때 이를 처리하는 로직을 담당한다. 여기서 HandlerExceptionResolver의 빈을 주입받고 있는데, HandlerExceptionResolver의 빈이 두 종류가 있기 때문에 @QualifierhandlerExceptionResolver를 주입받을 것이라고 명시해줘야 한다.

     

    commence()에서 스프링 시큐리티의 인증 관련 예외를 처리하게 된다. 이 예제에서는 @ControllerAdvice에서 모든 예외를 처리하여 응답할 것이기 때문에 여기에 별다른 로직은 작성하지 않고 HandlerExceptionResolver에 예외 처리를 위임한다.

     

    그 후 SecurityConfig의 필터 체인에 작성한 엔트리포인트를 추가해 준다.

     

    Kotlin

    @EnableMethodSecurity
    @Configuration
    class SecurityConfig(
        private val jwtAuthenticationFilter: JwtAuthenticationFilter,
        private val entryPoint: AuthenticationEntryPoint,	// 추가
        @Value("\${security.allowed-uris}")
        private val allowedUris: Array<String>
    ) {
        @Bean
        fun filterChain(http: HttpSecurity) = http
            .csrf().disable()
            .headers { it.frameOptions().sameOrigin() }
            .authorizeHttpRequests {
                it.requestMatchers(*allowedUris).permitAll()
                    .anyRequest().authenticated()
            }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter::class.java)
            .exceptionHandling { it.authenticationEntryPoint(entryPoint) }	// 추가
            .build()!!
    
        ...
    }

    Java

    @EnableMethodSecurity
    @RequiredArgsConstructor
    @Configuration
    public class SecurityConfig {
        private final JwtAuthenticationFilter jwtAuthenticationFilter;
        private final AuthenticationEntryPoint entryPoint;	// 추가
        @Value("${security.allowed-uris}")
        private String[] allowedUris;
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            return http
                    .csrf().disable()
                    .headers(headers -> headers.frameOptions().sameOrigin())
                    .authorizeHttpRequests(request ->
                            request.requestMatchers(allowedUris).permitAll()
                                    .anyRequest().authenticated()
                    )
                    .sessionManagement(sessionManagement ->
                            sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    )
                    .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter.class)
                    .exceptionHandling(handler -> handler.authenticationEntryPoint(entryPoint))	// 추가
                    .build();
        }
    
        ...
    }

    여기서 AuthenticationEntryPoint는 빈으로 등록된 엔트리포인트를 주입받는다. 이 경우에는 앞서 작성했던 JwtAuthenticationEntryPoint를 주입받게 된다.

     

    이제 다시 예외를 발생시켜 보면 콘솔에 여전히 Stack trace는 출력되지만 Exception(Response)Handler에서 설정했던, 예기치 않은 예외가 발생했을 때 반환하는 응답이 나오는 것을 볼 수 있다.

    토큰 검증 과정에서 발생한 예외가 처리된 것은 아니지만 어쨌든 @ControllerAdvice에서 예외를 처리하게 되었다.

    시큐리티에서 발생한 예외 그대로 처리하고 싶어!

    위 과정까지 진행한 후 콘솔에 찍힌 로그를 보면 JwtAuthenticationFilter에서 발생한 예외의 Stack trace가 출력된 후에 추가적으로 InsufficientAuthenticationException이 발생한 것을 볼 수 있다. 그런데 이 예외는 @ControllerAdvice에서 처리하고 싶던 예외가 아니다. 인증 과정에서 발생하는 예외 그대로 처리해 보자.

    JwtAuthenticationFilter에서 예외를 넘겨주기

    콘솔의 Stack trace를 보면 JwtAuthenticationFilter의 doFilterInternal()에서 JWT 관련 예외가 발생한다는 것을 알 수 있다. 필터에서 로직을 수행하는 과정을 잘 처리하면 스프링 시큐리티에서 발생하는 예외를 그대로 처리할 수 있을 것이다.

     

    JwtAuthenticationFilter의 내용을 다음과 같이 수정한다.

     

    Kotlin

    @Order(0)
    @Component
    class JwtAuthenticationFilter(
        ...
    ) : OncePerRequestFilter() {
        override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
            try {
                val token = parseBearerToken(request)
                val user = parseUserSpecification(token)
                UsernamePasswordAuthenticationToken.authenticated(user, token, user.authorities)
                    .apply { details = WebAuthenticationDetails(request) }
                    .also { SecurityContextHolder.getContext().authentication = it }
            } catch (e: Exception) {
                request.setAttribute("exception", e)	// try-catch로 예외를 감지하여 request에 추가
            }
    
            filterChain.doFilter(request, response)
        }
    
        ...
    }

    Java

    @Order(0)
    @RequiredArgsConstructor
    @Component
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
        ...
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            try {
                String token = parseBearerToken(request);
                User user = parseUserSpecification(token);
                AbstractAuthenticationToken authenticated = UsernamePasswordAuthenticationToken.authenticated(user, token, user.getAuthorities());
                authenticated.setDetails(new WebAuthenticationDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authenticated);
            } catch (Exception e) {
                request.setAttribute("exception", e);	// try-catch로 예외를 감지하여 request에 추가
            }
    
            filterChain.doFilter(request, response);
        }
    
        ...
    }

    이전에 작성한 엔트리포인트의 commence의 파라미터에서 request를 받고 있는 것을 볼 수 있는데, 이는 예외가 발생한 시점의 HTTP 요청을 의미한다. 이 글의 최종 목표는 인증 과정에서 발생하는 예외를 그대로 처리하는 것이기 때문에 필터에서 예외가 발생하면 해당 예외를 그대로 request에 담아둔다.

     

    그다음 JwtAuthenticationEntryPoint의 내용을 다음과 같이 수정한다.

     

    Kotlin

    @Component
    class JwtAuthenticationEntryPoint(
        ...
    ) : AuthenticationEntryPoint {
        override fun commence(request: HttpServletRequest?, response: HttpServletResponse?, authException: AuthenticationException?) {
            // JwtAuthenticationFilter에서 request에 담아서 보내준 예외를 처리
            resolver.resolveException(request!!, response!!, null, request.getAttribute("exception") as Exception)
        }
    }

    Java

    @Component
    public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
        ...
        
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
            // JwtAuthenticationFilter에서 request에 담아서 보내준 예외를 처리
            resolver.resolveException(request, response, null, (Exception) request.getAttribute("exception"));
        }
    }

    resolveException()의 인자로 넘겨주던 예외를 AuthenticationException 대신 request에서 가져온 예외로 변경해 줬다.

     

    이제 다시 토큰을 바꿔가면서 API를 실행해 보면 Exception(Response)Handler에서 설정했던 대로 예외를 처리해서 응답하는 것을 확인할 수 있다!

    변조된 토큰
    잘못된 토큰
    만료된 토큰

    스프링 시큐리티 예외 처리 완료!

    예외 처리를 완료한 프로젝트 Github : Kotlin, Java

    돌고 돌아서 스프링 시큐리티 예외를 @ControllerAdvice에서 처리할 수 있도록 설정을 완료했다! 스프링 시큐리티가 확실히 강력한 보안 기능을 제공하지만 제대로 사용하려면 이해할 것과 설정할 것이 많다고 느껴진다.

     

    이 방법을 사용했을 때 @ControllerAdvice에서 전역으로 예외를 처리할 범위를 지정하면 Handler에 지정한 응답값이 반환되지 않고 그냥 200 OK만 떨어지는 현상이 있는데 이 부분은 좀 더 찾아보고 보완해야할 것 같다.

    [추가내용] 
    이 상태에서 시큐리티 필터에서 발생하는 예외들을 @ControllerAdvice에서 처리하려면 별도로 예외 처리 범위를 제한하지 않은 별도의 @ControllerAdvice를 추가로 작성하거나 @ControllerAdvice의 예외 처리 범위 제한을 하는 것으로 해결할 수 있다.

    참조 링크

     

    [JWT] 스프링 시큐리티 + JWT 예외처리하기 및 고찰

    JWT 예외 처리 하기!

    velog.io

     

    Handle Spring Security Exceptions With @ExceptionHandler

    In this tutorial, we will learn how to globally handle Spring security exceptions with @ExceptionHandler and @ControllerAdvice. The controller advice is an interceptor that allows us to use the same exception handling across the application.

    www.baeldung.com

     

    Architecture :: Spring Security

    Spring Security’s Servlet support is based on Servlet Filters, so it is helpful to look at the role of Filters generally first. The following image shows the typical layering of the handlers for a single HTTP request. The client sends a request to the ap

    docs.spring.io

     

    댓글