Spring Security에서 JWT를 통한 인증 / 인가를 위해 FilterChain 단 개발 중 JWT가 만료되어서 블랙리스트에 있는 AccesToken일 경우 예외를 발생시켜 아래와 같이 ExceptionHandler에서 핸들링하려고 하였다. 하지만 원하는 예외 메세지 "ACCESS_TOKEN_IS_BLACKLIST("블랙리스트에 포함된 Access Token 입니다." 가 출력되지 않았다. 왜일까?
결론부터 말하면, 필터에서 발생한 예외는 필터 체인 내에서 처리되어야 하며, 필터가 실행되는 시점에서 발생한 예외는 DispatcherServlet을 넘어가지 않기 때문에 @RestControllerAdvice 같은 컨트롤러 기반의 예외 처리기가 이를 인식하지 못한다.
- @RestControllerAdvice로 GlobalExceptionHandler에 대한 설정
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<Object> handleCustomException(CustomException e) {
ErrorResponse errorResponse = e.getErrorResponse();
return handleExceptionInternal(errorResponse);
}
}
- 블랙리스트 토큰인지 유효성 검사를 판단하는 JwtFilter 코드 일부
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final CustomUserDetailsService customUserDetailsService;
private final RedisService redisService;
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = parsingBearerToken(request.getHeader("Authorization"));
// JWT가 헤더에 있는 경우
if (token != null) {
// 블랙리스트에 포함된 토큰으로 접근하는 경우, 접근 차단
try {
if (redisService.isContainBlacklistToken(token)) {
throw new CustomException(ErrorResponse.of(HttpStatus.UNAUTHORIZED.value(), ErrorCode.ACCESS_TOKEN_IS_BLACKLIST.getMessage()));
}
} catch (CustomException e) {
filterChain.doFilter(request, response);
return;
}
}
...
}
}
시나리오는 다음과 같다.
- 사용자가 로그인을 하여 AccessToken을 발급받는다.
- Authorization에 AccessToken을 담아 비즈니스 API를 호출한다.
- JWT 인증/인가 검증 후 API를 정상적으로 요청하여 반환한다.
- 사용자가 로그아웃을 한다.
- 사용자가 지닌 AccessToken은 Redis 블랙리스트에 저장된다. (블랙리스트 유효기간 = AccessToken 유효기간 = 30분)
- Authorization에 블랙리스트에 담긴 AccessToken을 담아 비즈니스 API를 재호출한다.
4번을 진행 후, Spring Security에서 JwtFilter에서 해당 토큰이 블랙리스트 토큰임을 예외를 발생하여 ExceptionHandler에서 처리한 응답 값을 반환할 것이라 생각했지만, AuthenticationEntryPoint에서 401 상태코드와 함께 "인증에 실패했습니다." 예외가 발생했다.
요청 처리 흐름을 보면 Filter Chain에서 예외가 발생했을 경우에는 @RestControllerAdvice까지 전파되지 않았던 것이다.
- 클라이언트 요청: 클라이언트가 서버에 HTTP 요청
- Filter: 요청은 먼저 Servlet Filter를 거친다.
- DispatcherServlet: 필터를 지나면 요청이 DispatcherServlet에 도달하여 요청을 처리할 적절한 핸들러(컨트롤러)를 찾는다.
- HandlerInterceptor: DispatcherServlet이 핸들러를 찾은 후, 해당 핸들러에 대한 HandlerInterceptor가 호출, Interceptor는 요청을 가로채어 추가 작업을 수행
- Controller: Interceptor를 통과한 요청은 최종적으로 지정된 컨트롤러로 전달되어 여기서 비즈니스 로직이 실행된다. @ExceptionHandler: 컨트롤러 내에서 예외가 발생하면, 해당 예외는 @ExceptionHandler 메서드로 전달되어 처리된다.
그럼 필터 단에 있는 예외들은 어떻게 처리하는게 맞을까?
필터 내에서 직접 HTTP 응답 처리: 필터 내에서 직접 예외를 처리하는 방법은 간단하고 명확한 응답을 제공한다. 하지만 이 경우, 커스텀 예외를 던져서 전역 예외 처리기로 처리하는 방식과 별개로 CustomException의 예외가 발생했을 경우 에러 코드와 메시지를 포함한 Response를 응답하는 필터는 다음과 같이 구현된다.
@Component
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;
private final CustomAccessDeniedHandler accessDeniedHandler;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final ExceptionHandlerFilter exceptionHandlerFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
http.authorizeHttpRequests( ... )
.addFilterBefore(new JwtFilter(customUserDetailsService, redisTokenService, jwtUtil, objectMapper), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(exceptionHandlerFilter, JwtFilter.class);
http.exceptionHandling((exceptionHandling) -> exceptionHandling
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
);
}
지금까지는 모든 예외가 authenticationEntryPoint 또는 accessDeniedHandler로 바라보게 되었었다.
JwtFilter에서 발생한 예외는 exceptionHandlerFilter로 전달된다. 이제는 필터에서 CustomException을 잡아 처리할 수 있게 되었다.
exceptionHandlerFilter에서 처리하지 못한 다른 예외들은 authenticationEntryPoint 또는 accessDeniedHandler로 넘어간다. authenticationEntryPoint와 accessDeniedHandler는 Spring Security의 기본 처리기이므로, 인증 및 권한 관련 예외를 처리한다.
@Component
@RequiredArgsConstructor
public class ExceptionHandlerFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (CustomException ex) {
handleCustomException(ex, response);
}
}
private void handleCustomException(CustomException e, HttpServletResponse response) throws IOException {
response.setStatus(e.getErrorResponse().status());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(e.getErrorResponse()));
}
}
ExceptionHandlerFilter 설명 :
OncePerRequestFilter: 이 클래스는 Spring의 필터로, 각 요청에 대해 한 번만 실행되도록 보장한다. 즉, 동일한 요청에 대해 여러 번 호출되지 않도록 한다.
doFilterInternal: 요청을 처리하기 위해 filterChain.doFilter(request, response)를 호출한다. 이 메서드 내에서 try-catch 블록을 사용하여 CustomException이 발생했을 때 이를 잡아 처리하고, 만약 CustomException이 발생하면, handleCustomException() 메서드를 호출하여 해당 예외를 처리한다.
handleCustomException:
response.setStatus(e.getErrorResponse().status()): 발생한 예외에 따라 HTTP 응답 상태 코드를 설정
response.setContentType(MediaType.APPLICATION_JSON_VALUE): 응답의 콘텐츠 타입을 JSON으로 설정
response.setCharacterEncoding("UTF-8"): 응답의 문자 인코딩을 UTF-8로 설정
response.getWriter().write(...): ObjectMapper를 사용하여 ErrorResponse를 JSON 문자열로 변환 후, 응답 본문에 작성
ExceptionHandlerFilter를 구현하고 블랙리스트 토큰으로 다시 비즈니스 API를 호출해보자.
정상적으로 예외에 따른 에러 메세지가 잘 전달되는것을 확인할 수 있었다.
ExceptionHandlerFilter는 @RestControllerAdvice와 @ExceptionHandler의 역할을 유사하게 보인다. 특정 요청에서 발생하는 예외를 처리하여 클라이언트에게 JSON 응답을 반환한다. ExceptionHandlerFilter와 @RestControllerAdvice를 사용하여 Spring Security와 Spring MVC의 예외 처리 책임을 분리함으로써 각 영역의 책임을 명확히 하고, 코드의 유지보수성을 높일 수 있게 된 것 같다.
- 보안 책임: ExceptionHandlerFilter는 보안 관련 예외(예: 인증 실패, 토큰 유효성 검사 실패 등)를 처리하여 보안 로직이 독립적으로 관리
- 비즈니스 로직 책임: @RestControllerAdvice는 비즈니스 로직에서 발생하는 예외를 처리
'Spring' 카테고리의 다른 글
[Spring] Logback 로그 관리 (logback-spring.xml 설정) (2) | 2024.12.07 |
---|