현재 진행하고 있는 프로젝트에서 로그인 로직을 Spring security와 JWT로 구현했다. 구현 중에 인증 예외와 JWT 관련 예외 처리를 분리하기 위해 많은 노력을 했는데 이 과정을 공유하고자 한다.
우선 로그인하지 않은 사용자가 허용되지 않은 API에 접근할때 발생하는 인증 예외의 메커니즘에 대해 살펴보자.
기본 인증 예외 처리 방법
SpringConfig 클래스에는 위의 사진과 같이 권한 설정이 되어있다. /api 로 시작하는 API로는 비 로그인 사용자가 접근할 수 없다.
API를 호출하면 위와 같은 응답이 반환되도록 설정했다. 응답은 AuthenticationEntryPoint에서 커스터마이징했다. AuthenticationEntryPoint의 코드를 살펴보자.
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
final Map<String, Object> body = new HashMap<>();
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
// 응답 객체 초기화
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
body.put("message", authException.getMessage());
body.put("path", request.getServletPath());
final ObjectMapper mapper = new ObjectMapper();
// response 객체에 응답 객체를 넣어줌
mapper.writeValue(response.getOutputStream(), body);
response.setStatus(HttpServletResponse.SC_OK);
}
}
AuthenticationEntryPoint를 구현한 RestAuthenticationEntryPoint 클래스를 만들었다. commence 메서드의 파라미터를 보면 AuthenticationException을 받아오는 걸 알 수 있다.
AuthenticationException으로 들어오는 예외는 Spring security 내의 ExceptionTranslationFilter에서 던져진다. 이 예외는 AuthenticationEntryPoint로 보내지고 이 클래스에서 응답을 처리하게 된다.
위의 사진처럼 SecurityConfig 클래스에서 AuthenticationEntryPoint를 등록할 수 있다.
JWT 관련 예외 처리 방법
처음 JWT 인증 필터를 구현할때는 JWT 관련 예외가 발생했을때 따로 예외를 던지지 않고 로그로 어떤 예외가 발생했는지 지만 기록하는 방향으로 구성했다. 이 방법을 사용하면 접근 권한이 없을때나 JWT 예외가 발생 했을때나 모두 insufficientauthenticationexception이 발생하고 위에서 본 Unauthorized 예외가 응답으로 반환된다.
필자가 생각할때 이 방식은 크게 두 가지의 문제점이 있다.
첫번째 문제점은 401 Unauthorized 예외가 발생했을때와 JWT 만료 예외가 발생 했을때 구분이 되지 않는다는 점이다. 우리는 보통 401 예외가 발생하면 인증 권한을 얻기 위해서 재로그인 시키는 로직을 짠다. 하지만 access token이 만료됐다는 예외가 발생했을때는 클라이언트에게 refresh가 필요하다고 알려줘서 refresh API를 호출하도록 만들어야 한다.
하지만 현재 구조에서는 두가지 작업의 구분이 되지 않는다. AuthenticationEntryPoint를 구현한 이상 AuthenticationException에서 넘어오는 메세지로는 두 로직을 분기할 수 없다.
두번째 문제점은 JWT 토큰의 문제점을 체크하는데 필요없이 인증 로직이 깊게 수행된다는 점이다. 401 예외의 경우 spring security 필터의 마지막인 FilterExceptionInterceptor까지 실행된 다음에 이전 필터인 ExceptionTranslationFilter에서 예외가 반환된다.
하지만 JWT 예외 같은 경우는 JWT 필터에서 유효성을 검증하는 것만으로 검증을 끝낼 수 있다. 때문에 JWT 유효성 검증 후에도 doFilter를 통해서 다음 필터를 수행하는건 비효율적이라고 생각한다.
위의 문제점을 해결할 수 있는 방법을 생각해보다가 JWT Exception만 담당으로 처리할 수 있는 필터를 JWT 인증 필터 앞에 붙이면 어떨까 라는 생각을 하게 됐다.
JWTExceptionFilter 만들기
@Component
public class JwtExceptionFilter extends OncePerRequestFilter {
/*
인증 오류가 아닌, JWT 관련 오류는 이 필터에서 따로 잡아낸다.
이를 통해 JWT 만료 에러와 인증 에러를 따로 잡아낼 수 있다.
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
try {
chain.doFilter(request, response); // JwtAuthenticationFilter로 이동
} catch (JwtException ex) {
// JwtAuthenticationFilter에서 예외 발생하면 바로 setErrorResponse 호출
setErrorResponse(request, response, ex);
}
}
public void setErrorResponse(HttpServletRequest req, HttpServletResponse res, Throwable ex) throws IOException {
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
final Map<String, Object> body = new HashMap<>();
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
// ex.getMessage() 에는 jwtException을 발생시키면서 입력한 메세지가 들어있다.
body.put("message", ex.getMessage());
body.put("path", req.getServletPath());
final ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(res.getOutputStream(), body);
res.setStatus(HttpServletResponse.SC_OK);
}
}
오직 JWTException만을 catch할 수 있는 JWTExceptionFilter를 만들었다.
public Claims getTokenClaims() {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
} catch (SecurityException e) {
log.info("Invalid JWT signature.");
throw new JwtException("잘못된 JWT 시그니처");
} catch (MalformedJwtException e) {
log.info("Invalid JWT token.");
throw new JwtException("유효하지 않은 JWT 토큰");
} catch (ExpiredJwtException e) {
log.info("Expired JWT token.");
throw new JwtException("토큰 기한 만료");
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT token.");
} catch (IllegalArgumentException e) {
log.info("JWT token compact of handler are invalid.");
throw new JwtException("JWT token compact of handler are invalid.");
}
return null;
}
토큰 유효성 검증 로직에서도 이전에는 로그만 출력했지만 지금은 JWTException을 throw하고 각 예외에 대한 메세지를 적어줬다. 다음으로 JwtExceptionFilter를 SecurityConfig.class에 등록해준다. 순서는 위에서 살펴본 토큰 인증 필터의 앞에 등록시켜줘야 한다.
그렇다면 JWTExceptionFilter가 잘 동작하는지, 이후의 필터들은 동작하지 않고 넘어가는지를 직접 확인해보자.
유효 기간이 만료된 토큰을 Authorization Header에 넣고 요청을 보냈다. JWTExceptionFilter의 doFilterInternal에 디버깅을 걸어놓은 상태이다. chain.doFilter()로 들어가보자.
TokenAuthenticationFilter라는 JWT 검증 필터가 나온다. 빨간 줄을 그어놓은 부분에서 실제 JWT가 유효한지에 대한 검증이 일어난다. 디버깅을 쭉 진행하면 filterChain.doFilter가 실행되지 않고 아래의 JwtExceptionFilter로 다시 넘어간다.
JwtExceptionFilter에서는 JWTException을 잡아서 setErrorResponse에서 응답을 반환해준다. 이제 JWT 토큰 관련 예외를 발생시키면 JwtExceptionFilter에서 생성한 응답 형식으로 반환되는걸 알 수 있다. 유효 기간이 지난 토큰으로 테스트 했기 때문에 '토큰 기한 만료'라는 메세지가 나오는걸 알 수 있다.
JwtExceptionFilter의 전체 로직을 요약하면 다음과 같다.
- doFilterInternal에서 chain.doFilter()를 통해 JwtAuthenticationFilter를 호출
- JwtAuthenticationFilter 내의 JWT 유효성 검사 로직에서 예외 발생
- JwtAuthenticationFilter 내에서 chain.doFilter() 실행하지 않고 바로 예외 반환
- JwtExceptionFilter에서 JwtException을 예외로 받으면 serErrorResponse 호출
- JwtException에 들어있는 메세지를 사용해 응답 반환
결론
스프링 시큐리티는 많은 필터들로 구성되어 있다. 그렇기에 공부하기 복잡하지만 커스터마이징한 필터들을 순서에 맞게 잘 등록하면 원하는 기능을 손쉽게 사용할 수 있다. 이번 기회를 통해서 특정 프레임워크를 사용할때 단순히 사용만 하는것이 아니라 내부 동작 원리를 잘 파악해야 한다는 사실을 다시 한번 깨달은 것 같다. 앞으로도 문제가 발생했을때 꼼꼼히 분석해보는 습관을 길러야겠다.