[트러블슈팅] Spring Security 인증/인가 예외 처리

김지현·2025년 2월 18일
0

Spring Boot 프로젝트

목록 보기
21/21

예외 처리

이번 delivery 프로젝트에서 Spring Security를 사용하여 인증/인가 과정을 구현하고 Global Exception을 정의해 비지니스 로직에서의 전역적인 예외 처리를 구현해두었다. 그런데 필터에서 발생한 예외는 @ContollerAdvice에 잡히지 않으며 상태 코드만 클라이언트로 반환해준다.

우선 필터의 예외가 @RestControllerAdvice에 잡히지 않는 이유는 시큐리티 필터 체인의 구조 때문이다. 일반적인 비지니스 로직에서의 예외는 서버가 받은 요청이 컨트롤러에 도달한 이후 발생한다. 그러나 스프링 시큐리티는 요청이 컨트롤러에 도달하기 전, 필터 체인에서 예외를 발생시킨다. 즉 @RestControllerAdvice가 처리 가능한 영역이 아니다.

그래서 필터 체인에서 예외가 발생하면 시큐리티가 예외를 자체적으로 처리하고 status code만 클라이언트로 전송한다. 예외의 발생 원인을 클라이언트에서 알 수 있도록 커스터마이징하기 위해 예외 처리를 시도했다.

Authentication Entry Point

우선 인증(Authentication) 실패 시의 예외를 처리하기 위해서 AuthenticationEntryPoint을 구현해준다.
인증 실패시 401 Unauthorized 에러 코드와 메시지를 반환한다.

// 인증 예외 처리
@Component
@Slf4j(topic = "JwtAuthenticationEntryPoint")
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException authException) throws IOException, ServletException {

    String errorMessage = authException.getMessage();
    log.error("Unauthorized error: {}", errorMessage);

    // 응답 에러 메시지 반환
    response.setStatus(HttpStatus.UNAUTHORIZED.value());
    response.setCharacterEncoding("UTF-8");
    response.getWriter().write(errorMessage);
  }
}

Access Denined Handler

인가(Authorization) 과정에서의 예외 처리는 AccessDeninedHandler에서 구현한다.
인가 실패 시 403 Forbidden 상태 코드와 에러 메시지를 반환한다.

// 인가 예외 처리
@Component
@Slf4j(topic = "JwtAccessDeniedHandler")
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

  @Override
  public void handle(HttpServletRequest request, HttpServletResponse response,
      AccessDeniedException accessDeniedException) throws IOException, ServletException {

    String errorMessage = accessDeniedException.getMessage();
    log.error("Forbidden error: {}", errorMessage);

    response.setStatus(HttpStatus.FORBIDDEN.value());
    response.setCharacterEncoding("UTF-8");
    response.getWriter().write(errorMessage);
  }
}

트러블슈팅

여기까진 이론이고 실제로 프로젝트에 적용하면서는 원하는 대로 응답이 오지 않아 꽤 많은 시행착오를 거쳤다. 그 중 가장 골치 아팠던 문제들이 아래와 같다.

1. unsuccessfulAuthentication()

첫 번째는 validateToken()에서 던져진 예외들이 잡히지 않아서 응답이 제대로 오지 않는 다는 것이었다.

로그인 실패 로그를 보니 JwtAuthenticationEntryPoint까지 예외가 전파되지 않고 JwtAuthenticationFilter에서 처리되어 끝나버렸는데 이유는 내가 이 필터 안에 unsuccessfulAuthentication() 메서드를 오버라이딩 해놨기 때문이었다..... 여기서 401로 response를 던졌기 때문에 예외 처리가 이미 된 것이다... (안돼서 삽질을 엄청나게 했는데 말이다

  @Override
  protected void unsuccessfulAuthentication
      (HttpServletRequest request, HttpServletResponse response, AuthenticationException failed)
      throws IOException, ServletException {
    log.error(failed.getMessage());
    response.setStatus(401);
  }

resopnse 를 던지는 대신 entrypoint의 commence를 호출하도록 수정해서 예외를 전파시켰다.

@Override
  protected void unsuccessfulAuthentication
      (HttpServletRequest request, HttpServletResponse response, AuthenticationException failed)
      throws IOException, ServletException {
    jwtAuthenticationEntryPoint.commence(request, response, failed);
  }

2. Global Exception 처리

두 번째는 response에 Global Exception의 메시지가 응답으로 오는 것이 아니라 spring security의 기본 에러 메시지가 온다는 문제였다. 에러 로그를 보면 발생시킨 exception이 확인이 되는데 정작 메시지는 Full authentication is required to access this resource 이런게 오더라... 이걸 해결하기 위해 Global Exception을 던지지 않고 AuthenticationException을 상속받은 CustomAuthenticationException을 정의해서 던졌는데도 잡히지 않았다.


그래서 디버깅을 돌렸더니 AuthenticationException의 인스턴스가 InsufficientAuthenticationException인 걸 발견하게 되었다. 찾아보니 스프링 시큐리티의 예외 처리 흐름에서 내 custom exception이 wrap 되어 버려서 커스텀 예외처리의 의미가 없다나... 간단하게 말하자면 덮어쓰기 된거다.

이걸 해결하고 원하는 응답값을 주려면 InsufficientAuthenticationException을 직접 또 정의해서 던져주면 되기야 하겠지만 이렇게 되면 커스텀 응답 처리의 의미가 있나 싶어졌고... 이 부분은 그냥 Exception Filter를 통해 처리하기로 결정했다.

Exceptoin Filter를 가장 앞단에 배치시켜서 Authorization 인가 과정에서 발생하는 Global Exception을 잡아 처리했다.

// web security config
http.addFilterBefore(jwtExceptionFilter, UsernamePasswordAuthenticationFilter.class);

// jwt exception filter
try {
      filterChain.doFilter(request, response);
    } catch (GlobalException e) {
      log.error("ERROR: {}, URL: {}, MESSAGE: {}", e.getStatus(),
          request.getRequestURI(), e.getMessage());

      response.setStatus(HttpStatus.UNAUTHORIZED.value());
      response.setCharacterEncoding("UTF-8");
      response.getWriter().write(e.getMessage());

드디어 원하는 응답으로 예외 처리를 성공했다!

0개의 댓글

관련 채용 정보