본문 바로가기
  • 개발 삽질 블로그
TroubleShooting

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

by 갹둥 2024. 10. 11.

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();
    }

 

결과 

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

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


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