이전에 Spring Security를 사용하여 JWT를 구현하였습니다.
https://velog.io/@da_na/Spring-Security-OAuth2-로그인-JWT-구현하기
따라서 JWT를 사용하여 인증과 인가가 성공적으로 구현되었고, 그대로 프로젝트 개발을 진행하였습니다.
그러다가,,, JWT 만료된 시간이 다가왔고, 만료된 JWT를 사용하여 API를 요청했을 때 아래와 같이 500 Internal Server Error가 나왔습니다. 🥲
따라서 클라이언트쪽에서는 위의 에러가 어떠한 에러인지 자세하게 알 수 없기 때문에 다시 JWT를 발급하기 위해서 로그인으로 돌아가는 과정을 추가할 수 없었습니다.
즉, JWT 예외가 발생하는 경우에 이전에 유효성 검사와 같이 에러 응답과 동일한 형태로 아래의 사진 형태처럼 resultCode와 message를 전달해야했습니다.
하지만 유효성 검사의 에러 응답 같은 경우에는 @RestControllerAdvice에서 처리가 가능했지만, 이번에 발생한 JWT 만료 에러의 경우에는 Spring Security 필터에서 발생한 에러이기 때문에 Controller 단까지 가지 않기 때문에 Filter 자체에서 에러를 처리하는 로직이 추가되어야만 했습니다.
이전에 작성한 JWT 처리하는 로직을 천천히 다시 살펴보면서 어떤 부분을 추가해야 할지를 생각해보겠습니다! 그리고 이 과정에서 발생한 문제점과 해결책을 같이 이야기하겠습니다.
위의 JWT 만료시에 스프링 부트 내에서 발생한 오류 기록을 보면, ExpiredJwtException이 발생한 것을 알 수 있습니다.
따라서 Spring Security의 Filter에서 예외가 발생할 경우, 예외를 처리해줄 부분을 추가해주어야 했습니다.
JWT 만료 처리를 하는 방법이 아래와 같이 크게 2가지로 있었습니다.
.exceptionHandling().authenticationEntryPoint(new JwtAuthenticationEntryPoint())
와 .addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class)
중에서 addFilterBefore를 사용하여 SecurityFilterChain의 filterChain에 추가하였습니다.
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final TokenService tokenService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final OAuth2FailureHandler oAuth2FailureHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.cors().configurationSource(corsConfigurationSource())
.and()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/h2-console/**", "/actuator/**",
"/", "/api-docs/**", "/swagger-ui/**").permitAll()
.antMatchers("/v1/**", "/login/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.addFilterBefore(new JwtAuthFilter(tokenService),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class)
.oauth2Login()
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler)
.userInfoEndpoint()
.userService(customOAuth2UserService);
return http.build();
}
}
@Slf4j
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
response.setCharacterEncoding("utf-8");
try{
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e){
request.setAttribute("exception", e.getMessage());
}
filterChain.doFilter(request, response);
}
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {
private final TokenService tokenService;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
String token = tokenService.resolveToken((HttpServletRequest) request);
if (token != null && tokenService.validateToken(token)) {
String email = tokenService.getEmail(token);
Authentication auth = new UsernamePasswordAuthenticationToken(email, "",
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(request, response);
}
}
따라서 JwtExceptionFilter에서 filterChain.doFilter를 1개 지워졌더니, null에러가 나오지 않았지만 JWT 만료시에도 예외처리가 되지 않은 모습을 볼 수 있었습니다.
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
response.setCharacterEncoding("utf-8");
try{
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e){
//만료 에러
request.setAttribute("exception", e.getMessage());
}
// filterChain.doFilter(request, response);
}
}
따라서, JwtExceptionFilter에서 dofilter가 2번 되어 있기 때문에 만료되지 않은 JWT를 사용하면 2번의 API가 실행됨을 알 수 있습니다.
하지만 dofilter를 지우게 되면 만료된 경우를 예외 처리할 수 없게 됩니다.
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.cors().configurationSource(corsConfigurationSource())
.and()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/h2-console/**", "/actuator/**",
"/", "/api-docs/**", "/swagger-ui/**").permitAll()
.antMatchers("/v1/**", "/login/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.exceptionHandling()
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.and()
.addFilterBefore(new JwtAuthFilter(tokenService),
UsernamePasswordAuthenticationFilter.class)
.oauth2Login()
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler)
.userInfoEndpoint()
.userService(customOAuth2UserService);
return http.build();
}
}
public class JwtAuthFilter extends GenericFilterBean {
private final TokenService tokenService;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
try {
String token = tokenService.resolveToken((HttpServletRequest) request);
if (token != null && tokenService.validateToken(token)) {
String email = tokenService.getEmail(token);
Authentication auth = new UsernamePasswordAuthenticationToken(email, "",
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception e) {
request.setAttribute("exception", e.getMessage());
}
chain.doFilter(request, response);
}
}
@Service
@Slf4j
public class TokenService {
public boolean validateToken(String token) {
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey)
.build().parseClaimsJws(token);
return claims.getBody().getExpiration()
.after(new Date(System.currentTimeMillis()));
}
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setCharacterEncoding("utf-8");
response.setContentType("application/json");
JwtErrorResponse jwtErrorResponse = new JwtErrorResponse(
ErrorCode.INVALID_AUTH_TOKEN.getCode(), ErrorCode.INVALID_AUTH_TOKEN.getMessage());
ObjectMapper objectMapper = new ObjectMapper();
String result = objectMapper.writeValueAsString(jwtErrorResponse);
response.getWriter().write(result);
}
}