JWT를 이용한 로그인을 구현하고, 로그인 한 토큰에 대해 검증하는 JwtAuthenticationFilter
의 예외처리를 구현하고 있었다. Swagger를 통해 JwtAuthenticationFilter
로 예외 처리가 잘 되는지 API를 테스트 해보고 있었는데, 아래처럼 수행하는 과정에서 내가 생각하지 못한 방식으로 작동 됐다.
- 토큰으로 예외가 터질 값을 넣는다. ( ex : JWT 형태가 아닌 아무 글자 )
JwtAuthenticationFilter
를 거쳐야 하는, 즉 토큰을 검증 로직이 필요한 API를 호출한다.- 2번의 API에서 예외 처리로 ErrorResponse가 발생한다. ( "잘못된 형태의 토큰입니다." 내용의 예외 )
- 잘못된 토큰 헤더를 계속 유지한 채, 유효한 email과 password로 로그인 API를 호출한다.
내가 예상했던 4번의 결과는 정상적으로 로그인 API에 대한 response가 반환 되는 것이었는데, 실제로는 아까 3번에서 나타난 ErrorResponse가 동일하게 발생했다.
4번 과정을 보면, 로그인 API 호출에 헤더는 여전히 아까 설정한 잘못된 토큰이 포함되어 있는 것이다. Filter 순서 상 API를 호출하기 전에 JwtAuthenticationFilter
를 거치기 때문에 토큰 검증 과정을 거치게 된다. 그래서 토큰 검증이 필요 없는 API여도 토큰 검증을 거치게 되며, 아까 헤더에 설정해 둔 잘못된 토큰에서 예외가 발생하게 되는 것이다.
회원가입, 로그인 API는 토큰 정보가 필요 없다. 그래서 회원가입, 로그인 API는 JwtAuthenticationFilter
의 토큰 검증 과정을 거치지 않도록 설정했다. shouldNotFilter
메서드를 사용해 현재 들어온 API 요청이 토큰 검증에서 제외할 API URL인지 확인하고, 이에 해당하면 토큰 검증 과정을 지나친다. 이 외의 URL이면 토큰 검증을 진행한다. shouldNotFilter
에 주목하자!
(JwtAuthenticationFilter를 사용하려면 SpringSecurityConfig 설정이 더 필요하지만, 여기선 Filter를 거칠 URL를 설정하는 내용이 위주여서 SpringSecurityConfig 코드는 포함하지 않았다.)
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter{
private final TokenProvider tokenProvider;
// 토큰 검증 과정을 지나칠 API URL들
private final List<String> excludedPaths = Arrays.asList("/api/user/sign-in","/api/user/register");
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException{
// 로그인, 회원가입 API URL이 포함되는지 확인하는 함수
String path = request.getRequestURI();
return excludedPaths.stream().anyMatch(path::startsWith);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 로그인 회원가입 API가 아닌 경우, 토큰 검증 과정 거침
try{
String token = resolveToken(request);
if (token != null && tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
} catch (JwtRequestException e) {
sendErrorResponse(response,e);
}
}
private String resolveToken(HttpServletRequest request){
String token = request.getHeader("Authorization");
if (StringUtils.hasValue(token) && token.startsWith("Bearer"))
return token.substring(7);
return null;
}
private void sendErrorResponse(HttpServletResponse response, JwtRequestException e) throws IOException {
response.reset();
response.setStatus(e.getCode());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(
new ErrorResponse(e.getCode(), e.getError(), e.getMessages())
));
}
}
처음에는 SpringSecurityConfig
에 Request 요청에 대한 제한을 두면 될 것이라 생각했고, 생각한 방식대로 시도 했었다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity security) throws Exception {
return security
...
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/user/sign-in", "/api/user/register").permitAll()
.anyRequest().authenticated()
)
...
.build();
}
하지만 내 생각대로 작동되지 않았다.
Spring Security의 인증, 권한 부여 로직을 설정하는 것은 맞지만, permitAll()
은 특정 uri에 대해 요청을 허용한다는 의미가 내가 생각했던 것과 달랐다.
permitAll()
에 등록한 요청이어도 Spring Security 필터 체인은 모두 통과한다. 마지막 단에 있는 FilterSecurityInterceptor
에서 진행하는 인증 과정을 생략할 뿐이다. 해당 인터셉터에서 Security Config에 등록되어있는 permitAll()
요청이면 인증 과정을 생략하여 요청을 전달하고, 등록되지 않았다면 SecurityContext
에서 Authentication 객체를 가져오는 것이다.
결론적으로는 FilterSecurityInterceptor
의 앞에서 Security 필터 체인은 모두 거치는 것이고, UsernamePasswordAuthenticationFilter
의 앞에 설정해둔 JwtAuthenticationFilter
를 통과한다. 결국 SpringSecurityConfig
에서 설정한 .permitAll()
은 JwtAuthenticationFilter
통과 여부에는 영향력이 없는 것이다.
JwtAuthenticationFilter
의 shouldNotFilter
방식은 JWT 토큰 검증 로직 자체를 건너뛰게 한다. 지정된 URL에 대해서는 JWT 토큰의 존재 여부나 유효성을 아예 확인하지 않게 하는 것이다. 필터 수준에서 동작하므로, Spring Security의 다른 설정보다 먼저 적용하기에, JWT 유효성 검증을 건너뛰려면 JwtAuthenticationFilter
에서 shouldNotFilter
를 사용해 URL을 특정해주어야 JWT 검증이 필요하지 않은 API에서는 토큰 검증을 하지 않을 수 있다.