개발 블로그

[Spring] JwtTokenFilter Exception 예외 처리하기(feat. Security Exceptions) 본문

TroubleShooting

[Spring] JwtTokenFilter Exception 예외 처리하기(feat. Security Exceptions)

갹둥 2024. 10. 11. 00:01

Handling Security Exceptions

 

문제 해결 과정

공식 문서도 찾아보고 다양한 포스팅을 참고해보니 여러가지 방법들이 있었습니다.

  1. 예외 처리용 Filter 추가
    • Spring Security 필터 체인에 커스텀 필터를 추가하여 인증 또는 권한 관련 예외를 처리할 수 있는 구조를 만듭니다.
    • 이 필터에서 예외가 발생할 경우, 직접 처리하거나 다른 핸들러로 위임할 수 있습니다.
  1. Filter 내에서 에러 응답 직접 처리
    • 필터 내에서 발생한 예외를 직접 HttpServletResponse를 사용하여 클라이언트에 적절한 HTTP 상태 코드(예: 401, 403)를 응답으로 보낼 수 있습니다.
    • 이를 통해 빠르게 예외를 처리하고 클라이언트에게 응답을 전달할 수 있습니다.
  1. AccessDeniedHandler 및 AuthenticationEntryPoint 구현
    • AccessDeniedHandler는 인증된 사용자가 권한이 없는 리소스에 접근할 때(예: 403 Forbidden) 호출됩니다.
    • AuthenticationEntryPoint는 인증되지 않은 사용자가 보호된 리소스에 접근할 때(예: 401 Unauthorized) 호출되어 인증 절차를 시작하거나 에러를 응답합니다.
  1. HandlerExceptionResolver.resolveException() 호출하여 GlobalExceptionHandler에 위임
    • 필터에서 발생한 예외를 HandlerExceptionResolver를 통해 Spring의 전역 예외 처리기로 넘깁니다.
    • resolveException() 메서드를 호출하여 예외를 GlobalExceptionHandler에 위임하고, 해당 핸들러에서 @ExceptionHandler를 통해 적절한 방식으로 예외를 처리할 수 있습니다.
      handlerExceptionResolver.resolveException((HttpServletRequest) request, (HttpServletResponse) response, null, ex);```
      
      
      

저는 이 중 3번과 4번 중 고민을 하였습니다...
Spring Security와의 원활한 통합을 고려한다면 3번 방식이 적합하지만, 프로젝트의 전체적인 예외 처리 일관성을 유지하기 위해서는 4번 방식도 좋은 선택이 될 수 있다고 생각했습니다.

그러나 Filter에서 발생하는 오류는 주로 Unauthorized(401) 또는 Forbidden(403)과 같은 인증/인가 관련 예외이기 때문에, Spring Security의 표준적인 처리 흐름을 따르는 3번 방식을 선택하여 구현하였습니다.

다만, 추후 로직이 복잡해지거나 다양한 예외 상황이 추가된다면, 예외 처리를 더 유연하고 일관성 있게 관리하기 위해 HandlerExceptionResolver로 넘기는 방식(4번 방식)을 사용할지 고려중입니다. (현재 핸들러 공사중이라 보류...)

 

V1. Filter 내에서 에러 응답 직접 처리

  • 일단 테스트 용도로 jwtTokenUtils에서 발생하는 커스텀 예외를 try-catch문으로 잡아 확인해보고 직접 필터에서 에러 응답을 보내주었습니다.
package com.muud.global.security.filter;

...

@RequiredArgsConstructor
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenUtils jwtTokenUtils;
    private final UserPrincipalService principalService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            String token = jwtTokenUtils.getTokenFromHeader(request);
            if (token != null) {
                String email = jwtTokenUtils.getEmailFromToken(token);
                UserPrincipal userDetails = principalService.loadUserByUsername(email);
                Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }

            filterChain.doFilter(request, response);
        }catch(AuthException authException) {
            log.error(authException.getMessage());
            response.sendError(authException.getErrorCode().defaultHttpStatus().value());
        }
    }
...

}

 

결과 

로그인 없이 api 요청

일반 사용자로 로그인한 후 어드민 권한 api 요청

→ 이렇게 처리해도 StatusCode는 세팅 되었지만, error response body를 보내지 않았습니다. 다른 api 에러 응답들과 같이 일관적인 형식으로 보내주도록 수정하겠습니다.

 

 

V2. AccessDeniedHandler 추가

  • 권한이 없는 사용자가 리소스에 접근했을 때 발생하는 에러(403)를 서비스 형식에 맞게 처리하기 위해 AccessDeniedHandler를 구현한 커스텀 핸들러를 추가하였습니다.
  • AccessDeniedException는 저희 서비스에서 커스텀으로 사용하고 있던 ACCESS_DENIED와 매핑되기 때문에 직접 ApiResponseError를 생성하여 내려주었습니다.
  • 참고로 GlobalHandler에서 처리하도록 넘기고 싶으면
package com.muud.global.security.exception;
...

@Slf4j
@Component
@RequiredArgsConstructor
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        ApiResponseError apiResponseError = ApiResponseError.from(ACCESS_DENIED.defaultException());
        response.setStatus(apiResponseError.status());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(apiResponseError));
    }
}
  • 처음에 JwtToken과 관련된 예외(401) 처리도 AuthenticationEntryPoint를 구현하여 처리 하였지만, 현재까지는 복잡한 로직을 포함하고 있지 않기 때문에 Filter에서 예외 처리를 해주었습니다.
package com.muud.global.security.filter;
...

@RequiredArgsConstructor
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenUtils jwtTokenUtils;
    private final UserPrincipalService principalService;
    private final ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException {
        try {
            String token = jwtTokenUtils.getTokenFromHeader(request);
            if (token != null) {
                String email = jwtTokenUtils.getEmailFromToken(token);
                UserPrincipal userDetails = principalService.loadUserByUsername(email);
                Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }

            filterChain.doFilter(request, response);
        }
        catch (CustomException e) {
            log.error("Exception occurred: {}", e.getMessage());
            setResponse(response, e);
        }
        catch (ServletException | IOException e) {
            log.error("Exception during filter processing: {}", e.getMessage(), e);
            setResponse(response, new AuthException(e.getMessage(), e.getCause()));
        }

    }
...

    private void setResponse(HttpServletResponse response, CustomException e) {
        try {
            ApiResponseError responseError = ApiResponseError.from(e);
            response.setStatus(responseError.status());
            response.setContentType("application/json");
            response.setCharacterEncoding("UTF-8");
            response.getWriter().write(objectMapper.writeValueAsString(responseError));
        } catch (IOException ex) {
            log.error("Exception during filter processing: {}", e.getMessage(), e);
        }
    }
}
  • SecurityConfig에 다음과 같이 등록해줍니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**", "/swagger-ui/**", "/v3/api-docs/**", "/health-check").permitAll()
                        ...
                        .anyRequest().authenticated()
                )
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .accessDeniedHandler(accessDeniedHandlerImpl)
                );

        return http.build();
    }

 

결과 

권한이 없는 경우 응답이 잘 내려옴

토큰이 유효하지 않은 경우도 잘 내려옴


틀린 부분이나 개선 사항 발견하시면 댓글로 공유 부탁드립니다:)
읽어주셔서 감사합니다. 😊