JwtAutheticationFilter에서 custom exception을 잡지 못하는 이유
문제 개요
사용자의 권한 제어를 위해 작성한 커스텀 어노테이션에서 발생하는 문제를 해결하던 도중 JwtAuthenticationFilter에서 예외 처리가 제대로 되지 않는 것을 발견했다.
- 기존 JwtAuthenticationFilter의 doFilterInternal()
// loadUserByUsername이 사용되는 곳
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 헤더에서 토큰 추출
String headerToken = extractTokenFromHeader(request);
// 쿠키에서 토큰 추출
String cookieToken = extractTokenFromCookie(request);
// 둘 중 하나라도 있으면 있증 처리
String token = headerToken != null ? headerToken : cookieToken;
if (token != null) {
try {
// 블랙리스트 확인
if (isTokenBlacklisted(token)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "로그아웃된 토큰입니다.");
return;
}
Claims claims = jwtUtils.getClaims(token);
String email = claims.getSubject();
String role = claims.get("role", String.class);
Boolean isVerified = claims.get("isEmailVerified", Boolean.class);
if (email != null && Boolean.TRUE.equals(isVerified)) {
// UserDetails 객체 생성 (DB에서 사용자 정보 조회)
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
// UserDetails 기반으로 Authentication 객체 생성
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
// SecurityContextHolder에 인증 정보 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "이메일 인증이 완료되지 않았습니다.");
return;
}
} catch (ExpiredJwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰이 만료되었습니다.");
return;
} catch (JwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다.");
return;
}
}
// 검증 후 다음 필터로
filterChain.doFilter(request, response);
}
오류 메시지 및 원인 분석
의도한 바는 다음과 같다.
- db에 이메일이 존재하지 않는 경우 UsernameNotFoundException을 던진다.
- jwt가 잘못된 경우 CustomAuthException을 던진다.
그러나 스웨거로 테스트 해보았을 때 아래와 같은 response를 받아볼 수 있었다.
- 수정 전, jwt에 담긴 정보가 잘못된 경우: jwt에 존재하지 않는 이메일을 넣음
UserDetailsService에서 UsernameNotFoundException을 던지지만 잡지 못하고 500 에러를 반환함
- 수정 전, jwt가 잘못된 경우: accessToken이 임의의 문자열을 가지고 있음
작성한 CustomAuthException이나 에러 메시지가 사용되지 않고 401 stauts만 반환됨
이유는 크게 두 가지였다.
- JwtUtils.isValidToken()이 사용되지 않아서 충분한 예외 처리가 이루어지지 않음
- Spring Security의 필터 순서를 고려하지 못함
해결 및 고민 과정
Spring Security의 필터 순서는 아래와 같다.
이 필터들은 순서대로 동작하고 필터를 통과해야 이후 로직에 영향을 줄 수 있다.
- Filter
- DisableEncodeUrlFilter
- ForceEagerSessionCreationFilter
- ChannelProcessingFilter
- WebAsyncManagerIntegrationFilter
- SecurityContextHolderFilter
- SecurityContextPersistenceFilter
- HeaderWriterFilter
- CorsFilter
- CsrfFilter
- LogoutFilter
- X509AuthenticationFilter
- AbstractPreAuthenticatedProcessingFilter
- UsernamePasswordAuthenticationFilter
- DefaultLoginPageGeneratingFilter
- DefaultLogoutPageGeneratingFilter
- ConcurrentSessionFilter
- DigestAuthenticationFilter
- BasicAuthenticationFilter
- RequestCacheAwareFilter
- SecurityContextHolderAwareRequestFilter
- JaasApiIntegrationFilter
- RememberMeAuthenticationFilter
- AnonymousAuthenticationFilter
- SessionManagementFilter
- ExceptionTranslationFilter
- FilterSecurityInterceptor
- AuthorizationFilter
- SwitchUserFilter
- https://github.com/spring-projects/spring-security/blob/6.2.2/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java
JwtAuthenticationFilter의 경우 사용자 인증을 수행하는 필터이기 때문에 일반적으로 UsernamePasswordFilter 근처에 배치한다.
여기서 문제가 발생하는데, 보안과 관련된 예외를 잡아서 적절한 HTTP 응답으로 변환하는 필터는 거의 끝 순서에 위치한 ExceptionTranslationFilter다. JwtAuthenticationFilter에서 발생한 예외는 따로 처리되지 않으면 서블릿 컨테이너로 전파되고 ExceptionTranslationFilter까지 도달하지 않는다.
결과적으로 Spring Security의 예외 변환 메커니즘을 우회한 셈이 되어 예외가 제대로 처리되지 않았던 것이다.
최종 해결책 및 구현
- JwtUtils.isValidToken()을 호출하는 코드를 추가해 CustomAuthException을 던지게 했다.
if (token != null && jwtUtils.isValidToken(token)) { ...(생략)
- 토큰 검증과 사용자 인증 부분에서 try-catch문으로 예상되는 모든 예외를 잡았다.
try { ...(생략) } catch (CustomAuthException e) { setErrorResponse(response, Integer.parseInt(e.getAuthErrorType().getHttpStatus()), e.getAuthErrorType().getDefaultMessage()); return; } catch (ExpiredJwtException e) { setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "만료된 토큰입니다."); return; } catch (JwtException e) { setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."); return; } catch (UsernameNotFoundException e) { setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "사용자를 찾을 수 없습니다."); return; }
- response에 직접 예외를 작성해 필터 내에 예외 처리 로직을 구현했다.
private void setErrorResponse(HttpServletResponse response, Integer status, String message) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
RsData<Void> errorResponse = new RsData<>(status.toString(), message);
response.getWriter().write(new ObjectMapper().writeValueAsString(errorResponse));
}
- 수정된 JwtAuthenticationFilter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final RedisTemplate<String, String> redisTemplate;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
// 쿠키에서 토큰 추출
String token = extractTokenFromCookie(request);
try {
// 토큰 유효성 검사
if (token != null && jwtUtils.isValidToken(token)) {
// 블랙리스트 확인
if (isTokenBlacklisted(token)) {
setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "로그아웃된 토큰입니다.");
return;
}
// 정보 추출
Claims claims = jwtUtils.getClaims(token);
String email = claims.getSubject();
String role = claims.get("role", String.class);
Boolean isVerified = claims.get("isEmailVerified", Boolean.class);
if (email != null && Boolean.TRUE.equals(isVerified)) {
// UserDetails 객체 생성 (DB에서 사용자 정보 조회)
UserDetails userDetails = new CustomUserDetails(email, role, isVerified);
// UserDetails 기반으로 Authentication 객체 생성
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
// SecurityContextHolder에 인증 정보 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "이메일 인증이 완료되지 않았습니다.");
return;
}
}
} catch (CustomAuthException e) {
setErrorResponse(response,
Integer.parseInt(e.getAuthErrorType().getHttpStatus()), e.getAuthErrorType().getDefaultMessage());
return;
} catch (ExpiredJwtException e) {
setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "만료된 토큰입니다.");
return;
} catch (JwtException e) {
setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다.");
return;
} catch (UsernameNotFoundException e) {
setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "사용자를 찾을 수 없습니다.");
return;
}
// 검증 후 다음 필터로
filterChain.doFilter(request, response);
}
// 쿠키에서 토큰 추출하는 메서드
private String extractTokenFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("accessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
// Redis에서 토큰 블랙리스트 확인
private boolean isTokenBlacklisted(String token) {
return Boolean.TRUE.equals(redisTemplate.hasKey("blackList:" + token));
}
// 에러 핸들링
private void setErrorResponse(HttpServletResponse response, Integer status, String message) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
RsData<Void> errorResponse = new RsData<>(status.toString(), message);
response.getWriter().write(new ObjectMapper().writeValueAsString(errorResponse));
}
}
https://github.com/prgrms-web-devcourse-final-project/WEB4_5_GAEPPADAK_BE/pull/208
결과 및 성능 분석
이후 의도한 대로 예외가 처리되는 것을 확인할 수 있었다.
-수정 후, jwt에 담긴 정보가 잘못된 경우
jwt를 신뢰하는 보안 정책을 채택했기 때문에 필터는 정상 통과하고 이후 사용자 확인 시 예외가 발생함
- 수정 후, jwt가 잘못된 경우
JwtUtils.isValidToken()에서 토큰 유효성을 검사해 예외를 던지면 필터에서 잡아 response에 커스텀한 예외를 작성해 반환함
추가 개선점
일단 모든 예외를 잡아서 response에 직접 작성하는 방법을 채택했는데 더 깔끔한 방법이 있을 것 같다.
별개로 이제 jwt에 담긴 정보가 잘못된 경우에도 filter를 정상 통과한다는 점에서 JwtAuthenticationFilter에서 요청할 때마다 Member DB 조회하는 문제와 공통된 의문점이 남는다. jwt를 얼마나 신뢰할 지는 팀 내의 정책에 달린 문제인 것 같은데 어떤 게 정답인지는 모르겠다.