![[Spring-Security] JWT 검증 시에 발생하는 예외는 ExceptionHandler에서 처리할 수 없다.](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCuGE6%2FbtsI8P2BFlm%2FqpsYFjGPSKcdqd5aEnoSFk%2Fimg.png)
1. 내가 기대했던 결과
- 사용자가 Authorization 헤더에 Bearer 타입의 Access Token을 담아서 서버로 요청한다.
- 서버에서 Access Token을 받아서 만료된 토큰인지 검증한다.
- 만료된 토큰이라면 ExpiredJwtException 예외를 발생시킨다.
- @ExceptionHandler를 사용하여 예외를 핸들링한다.
- JWT 토큰을 검증하는 Filter
- doInternal()에서 사용자의 요청을 가로챈다.
- HttpServletRequest에서 1-1에서 담은 Access Token을 가져온다.
- try문에서 parseToken()를 호출한다.
- validateToken()를 통해 토큰을 검증한다.
- 이때, 토큰이 만료되었는지 확인한다.
- 토큰이 만료되었다면 ExpiredException 예외가 발생한다.
- try-catch에서 ExpiredException 예외를 잡아서 TokenExpiredException(커스텀 예외 클래스)를 던진다.
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String jwtToken = parseBearToken(request);
if (jwtToken == null) {
filterChain.doFilter(request, response);
return;
}
try {
CustomUserDetails user = parseToken(jwtToken);
UsernamePasswordAuthenticationToken authenticated = UsernamePasswordAuthenticationToken.authenticated(
user, "", user.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authenticated);
filterChain.doFilter(request, response);
} catch(ExpiredJwtException e) {
throw new TokenExpiredException(JwtErrorCode.EXPIRED_TOKEN);
}
}
private CustomUserDetails parseToken(String jwtToken) {
if (jwtToken == null) {
return null;
}
Claims claims = jwtTokenProvider.validateToken(jwtToken);
if (claims == null) {
return null;
}
String email = claims.getSubject();
User findUser = findUserByEmail(email); // GET /users/me에서 findById()를 사용하기 위해 User 조회가 필요함.
return new CustomUserDetails(findUser);
}
private User findUserByEmail(String email) {
return userRepository.findByEmail(email)
.orElseGet(() -> userRepository.save(User.builder().email(email).build()));
}
private String parseBearToken(HttpServletRequest request) {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
return Optional.ofNullable(authorization)
.filter(token -> token.substring(0, 7).equalsIgnoreCase("Bearer "))
.map(token -> token.substring(7))
.orElse(null);
}
}
- 토큰을 검증하는 코드
// JWT 토큰 검증
public Claims validateToken(String jwtToken) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jwtToken)
.getBody();
}
- 전역 핸들러
TokenExpiredException 예외 클래스가 발생하면 아래 핸들러에서 처리한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(TokenExpiredException.class)
public ResponseEntity<Object> handleTokenExpiredException(TokenExpiredException e) {
ErrorCode errorCode = JwtErrorCode.EXPIRED_TOKEN;
return handleException(errorCode);
}
private ResponseEntity<Object> handleException(ErrorCode errorCode) {
return ResponseEntity.status(errorCode.getHttpStatus())
.body(createErrorResponse(errorCode));
}
}
2. 기대와 다른 결과를 반환한다.
유효 시간이 만료된 Access Token을 요청 헤더에 담아서 요청하면 검증하는 과정에서 ExpiredJwtException 예외가 발생한다. ExpiredJwtException 예외는 토큰이 만료된 경우 발생하는 예외 클래스이다.
PostMan에서 호출한 결과 403 Forbidden 예외만 보여줄 뿐, 응답 메시지를 보여주지 않는다.
여기서 한 가지 의문점이 생겼는데, 토큰 검증과정에서 ExpiredJwtException 예외가 발생하면 TokenExpiredException(커스텀 예외 클래스)를 던지도록 하였다. TokenExpiredException 예외는 전역 핸들러인 GlobalExceptionHandler에서 처리해야 한다. 그런데 응답 메시지에는 아무 값도 담기지 않는다.
3. ExceptionHanlder는 Spring MVC에서만 작동한다.
Filter의 경우 Spring MVC에 요청이 도달하기 전에, 사용자의 요청을 가로채어 처리한다. DispatcherServlet의 경우 사용자의 모든 요청을 받는 프론트 컨트롤러인데, Filter는 이것보다 앞에 위치한다. 심지어 Filter는 Spring의 관리 영역이 아니다.
ExceptionHandlerExceptionResolver는 @ExceptionHandler를 처리하는 Resolver이다. Spring에서 발생하는 예외를 대부분 이곳에서 처리한다.
@ExceptionHandler는 스프링의 영역에 있었기 때문에, 스프링 영역 밖에 있는 Filter에서 발생하는 예외를 잡지 못하는 것이었다.
4. Filter에서 예외를 직접 처리한다.
doFilterInternal()의 경우 매개변수로 HttpServletResponse를 포함하기 때문에, 직접 response에서 예외 메시지와 예외 코드를 담으면 된다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String jwtToken = parseBearToken(request);
if (jwtToken == null) {
filterChain.doFilter(request, response);
return;
}
try {
CustomUserDetails user = parseToken(jwtToken);
UsernamePasswordAuthenticationToken authenticated = UsernamePasswordAuthenticationToken.authenticated(
user, "", user.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authenticated);
filterChain.doFilter(request, response);
} catch(ExpiredJwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Token has expired");
}
}
HttpServletResponse에 직접 예외 메시지를 담았기 때문에, 직접 작성한 예외 메시지와 예외 코드가 응답된다.
5. 인증 예외는 스프링 시큐리티에서 처리할 수 있다.
내가 진행 중인 프로젝트에서는 예외가 발생한 경우 아래의 포맷으로 반환하도록 하고 있다. Filter에서 발생한 예외도 아래 형태를 만들 수 있다.
5-1. AuthenticationEntryPoint 구현하기
AuthenticationEntryPoint 인터페이스는 Spring Security에서 인증되지 않은 사용자가 보호되고 있는 자원에 접근하는 경우, 이 요청을 어떻게 처리할지 로직을 작성할 수 있다. 인증에 실패한(토큰이 만료된 경우) 사용자에게 응답 메시지를 커스터마이징하여 응답 메시지로 전달할 수 있다.
- response.setContentType("application/json;charset=UTF-8")
- 응답 메시지의 형태로 JSON을 사용한다.
- response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
- 응답 코드로 401 Unauthorized를 설정한다.
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
ErrorCode errorCode = JwtErrorCode.EXPIRED_TOKEN;
ErrorResponse errorResponse = ErrorResponse.builder()
.code(errorCode.name())
.message(errorCode.getMessage())
.build();
ObjectMapper objectMapper = new ObjectMapper();
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
}
}
5-2. SecurityConfig에 EntryPoint 등록
exceptionHandling으로 5-1에 작성한 AuthenticationEntryPoint를 등록한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService customUserDetailsService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AuthenticationEntryPoint authenticationEntryPoint;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf().disable()
.cors(cors -> {
cors.configurationSource(CorsConfig.corsConfiguration());
})
.headers(headers -> headers.frameOptions().disable())
.authorizeHttpRequests(
authorizeRequest -> {
authorizeRequest
.requestMatchers("/api/users").permitAll() // 회원가입
.requestMatchers("/api/users/login").permitAll() // 로그인
.requestMatchers("/swagger-ui/**", "/swagger-resource/**", "/api-docs/**", "/v3/api-docs/**").permitAll() // Swagger
.requestMatchers("/api/users/check-email", "/api/users/password").permitAll() // 이메일 검증
.anyRequest().authenticated();
}
)
.userDetailsService(customUserDetailsService)
.sessionManagement(sessionManagement -> {
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
})
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exceptionHandling -> {
exceptionHandling.authenticationEntryPoint(authenticationEntryPoint);
})
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
AuthenticationEntryPoint를 사용하여 미인증 사용자에 대하여 커스터마이징 된 예외 메시지를 던질 수 있다.