JwtException 예외 처리 방법
SpringSecurity 필터 체인에 JWT를 검증하는 JwtAuthenticationFilter를 두었다. 그리고 JWT가 유효하지 않을 경우, JwtException이 발생한다.
방법 1. JwtExceptionFilter
JwtAuthenticationFilter 앞단에 JwtException을 처리하는 JwtExceptionFilter를 두었다. 따라서 JwtAuthenticationFilter에서 JwtException가 발생하면 예외가 JwtExceptionFilter로 넘어와서 이 곳에서 예외 응답을 한다.
문제점
그런데 이 방법에서 의도치 않은 문제가 발생했다. 인증이 필요하지 않은, 즉 JWT가 필요하지 않은 URL 요청의 경우 SpringSecurity의 permitAll 설정을 해줬다.
그런데 permitAll로 설정한 경로 또한 SpringSecurity 필터 체인을 타고, 따라서 JwtAuthenticationFilter에서 JwtException이 발생하고, JwtExceptionFilter에서 예외 응답을 내리는 것이다. 즉, 인증이 필요 없는 경로임에도 토큰이 없기 때문에 JwtException이 발생하며 이에 대한 예외 응답이 나간다.
문제 원인: permitAll 설정의 의미
permitAll 설정을 하면 해당 URL 요청은 필터를 타지 않을 것이라고 생각했다. 그러나 permitAll로 설정한 경로든 아니든 필터를 타는 것은 같다.
단, permitAll로 설정한 경로는 모든 필터를 처리한 후에 SecurityContext에 인증 정보(Authentication)가 없어도 예외를 발생시키지 않는다. 반면 permitAll로 설정하지 않은 경로는 모든 필터를 처리한 후에 SecurityContext에 인증 정보가 없으면 AuthenticationException 예외를 발생시킨다. 그리고 AuthenticationEntryPoint라는 핸들러가 호출되고 이 곳에서 예외 처리를 하면 된다.
따라서 나의 경우, permitAll로 설정한 경로도 JwtAuthenticationFilter를 통해 JWT 검증이 진행되었고, 이때 JWT가 없으므로 JwtException이 발생한 것이다. 그리고 이 예외가 JwtExceptionFilter로 넘어가 이 곳에서 예외 응답을 한 것이다.
(전에 정리해놨던 글을 참고하자: https://olsohee.tistory.com/94)
* 참고: SpringSecurity 필터 체인에서의 예외와 예외 처리 핸들러
SpringSecurity의 필터 체인에서 다음 2가지 예외가 발생할 수 있다.
- 인증에 대한 예외(AuthenticationException)
- 인가에 대한 예외(AccessDeniedException)
이들은 필터 단에서 발생하는 예외이므로 스프링의 ControllerAdvice에서 예외 처리가 불가하다. SpringSecurity는 이들을 처리할 핸들러를 제공한다.
- AuthenticationEntryPoint: 인증 예외(AuthenticationException)를 처리
- AccessDeniedHandler: 인가 예외(AccessDeniedException)를 처리
방법 2. JwtAuthenticationEntryPoint
별도의 JwtExceptionFilter를 정의하지 않고, SpringSecurity가 제공하는 인증 예외 처리 핸들러를 사용하는 방법이다. 따라서 AuthenticationEntryPoint를 구현한 JwtAuthenticationEntryPoint를 정의해주었다.
즉, JwtAuthenticationFilter에서 JwtException을 잡아준다. 그리고 모든 필터를 처리한 후에 SecurityContext에 인증 정보가 없으므로 AuthenticationException이 발생하고 AuthenticationEntryPoint 핸들러가 호출된다.
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("JwtFilter 실행");
try {
// 요청 헤더에 토큰 유무 검증
String token = jwtUtils.getTokenFromHeader(request);
// 토큰 유효성 검증
if (jwtUtils.validateToken(token)) {
// 토큰이 유효하면 Authentication을 SecurityContextHolder에 저장
Authentication authentication = jwtUtils.generateAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("인증 정보(authentication) 저장 = {}", authentication.getPrincipal());
}
} catch (CustomJwtException e) {
// 예외 발생시 예외를 잡음
log.error(e.toString());
}
filterChain.doFilter(request, response);
}
}
@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
log.info("JwtAuthenticationEntryPoint 실행");
ObjectMapper objectMapper = new ObjectMapper();
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("httpStatus", HttpServletResponse.SC_UNAUTHORIZED);
errorResponse.put("errorName", "AuthenticationException");
errorResponse.put("message", e.getMessage() + ": 인증되지 않은 사용자입니다.");
String responseBody = objectMapper.writeValueAsString(errorResponse);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpStatus.FORBIDDEN.value());
response.getWriter().write(responseBody);
}
}
문제점
그런데 이 방법은 핸들러에서 예외 응답을 내리게 되는데, 이때의 예외는 JwtException이 아니라 AuthenticationException이다. 따라서 발생한 JwtException을 세부적으로 관리(만료된 토큰인지, 잘못된 토큰인지 등)하며 예외 응답을 내려줄 수 없다.
방법 3. JwtAuthenticationEntryPoint + 예외 처리 위임
JwtException 예외 정보를 예외 응답에 포함시키기 위해, JwtAuthentication에서 JwtException을 잡을 때 예외 정보를 HttpServletRequest에 담아주었다. 그리고 AuthenticationEntryPoint 핸들러에서 이 예외를 HandlerExceptionResolver로 넘겨준다. 따라서 적절한 HandlerExceptionResolver가 선택되고 그곳에서 예외 처리가 이뤄진다. 그리고 @RestControllerAdvice 클래스에서 CustomJwtException을 처리하는 메소드를 정의해 주었다. 따라서 ExceptionHandlerExceptionResolver가 HandlerExceptionResolver로 선택되고, 해당 클래스에서 예외 처리가 이뤄진다.
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("JwtFilter 실행");
try {
// 요청 헤더에 토큰 유무 검증
String token = jwtUtils.getTokenFromHeader(request);
// 토큰 유효성 검증
if (jwtUtils.validateToken(token)) {
// 토큰이 유효하면 Authentication을 SecurityContextHolder에 저장
Authentication authentication = jwtUtils.generateAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("인증 정보(authentication) 저장 = {}", authentication.getPrincipal());
}
} catch (CustomJwtException e) {
// 예외 발생시 잡고, request에 예외 정보를 담음
log.error(e.toString());
request.setAttribute("exception", e);
}
filterChain.doFilter(request, response);
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final HandlerExceptionResolver handlerExceptionResolver;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.info("JwtAuthenticationEntryPoint 실행");
// CustomJwtException이 발생했었으면, 예외를 컨트롤러로 넘기기
if (request.getAttribute("exception") != null) {
handlerExceptionResolver.resolveException(request, response,
null, (CustomJwtException) request.getAttribute("exception"));
}
}
}
@RestControllerAdvice
@RequiredArgsConstructor
public class ExceptionController {
@ExceptionHandler
@ResponseStatus(value = HttpStatus.BAD_REQUEST)
public ErrorResponse handleJwtException(CustomJwtException e) {
return new ErrorResponse(e.getErrorCode().getHttpStatus(), e.getErrorCode().getMessage());
}
...
}
Reference
- https://docs.spring.io/spring-security/reference/index.html
- https://olsohee.tistory.com/94
- https://colabear754.tistory.com/172#%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0%EC%97%90%EC%84%9C_%EB%B0%9C%EC%83%9D%ED%95%9C_%EC%98%88%EC%99%B8_%EA%B7%B8%EB%8C%80%EB%A1%9C_%EC%B2%98%EB%A6%AC%ED%95%98%EA%B3%A0_%EC%8B%B6%EC%96%B4!
'프로젝트' 카테고리의 다른 글
Redis를 통한 좋아요 수 동시성 문제 해결 (0) | 2024.11.26 |
---|---|
반정규화를 통한 조회 성능 개선 (1) | 2024.11.26 |
예약 동시성 제어 과정 (0) | 2024.08.20 |
반정규화를 통한 성능 향상 (0) | 2024.05.21 |