프론트엔드 작업을 하다가 불편한 점이 하나 생겼다. JWT관련 예외가 발생하였을 때 에러 코드가 ERR_BAD_REQUEST로만 넘어오니 이 예외가 토큰이 만료되어 발생하였는지 토큰이 잘못되어(변형되어) 발생하였는지 알 수가 없다는 것이었다. 그래서 백엔드에서 이 예외처리를 세분화하여 응답하는 과정을 진행하고 정리해보려 한다.
세분화를 하기 전에 우선 기존 코드부터 보자.
TokenProvider의 validateToken 메서드에서는 Bearer 검증을 하고 토큰이 만료되었는지 확인을 한다.
// jwt/JwtTokenProvider.java
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
public boolean validateToken(String token) {
try {
// Bearer 검증
if(!token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
return false;
} else {
token = token.split(" ")[1].trim();
}
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date()); // 만료되었다면 false return
} catch(Exception e) {
return false;
}
}
}
JwtAuthenticationFilter에서는 단순히 요청이 Servlet으로 도달하기 전에 가로채어 토큰을 검증한 후 UsernamePasswordAuthenticationToken 객체를 만들어 이를 AuthenticationManager에게 전달한다.
// jwt/JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
if(token != null && jwtTokenProvider.validateToken(token)) {
token = token.split(" ")[1].trim();
Authentication auth = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
SecurityConfig에서는 인증에 관련된 문제가 발생하였을 때 "Unauthenticated User."라는 응답을 보내도록 하였다.
// configuration/SecurityConfig.java
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// JWT 인증 필터
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
// error handling
.exceptionHandling()
.accessDeniedHandler(new AccessDeniedHandler() {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(403);
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("Unauthorized User.");
}
})
.authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setStatus(401);
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("Unauthenticated User.");
}
});
return http.build();
}
}
위의 코드들을 가지고 간단한 테스트를 해본다.
위와 같이 올바른 응답을 보내주는 정상적인 토큰을 만료시간이 지난 후 요청을 보내보고 훼손하여 요청을 보내보는 테스트이다.
만료시간이 지난 후 요청을 보냈을 때
토큰을 훼손하여 요청을 보냈을 때
이처럼 예외를 세분화하지 않아 예외가 발생하였을 때 동일하게 "Unauthentication User."라는 응답을 보낸다. 이러면 어떤 예외로 인해 발생한 오류인지 알 수가 없어 프론트엔드 쪽에서 대처하기가 불편하다. 이제 예외들을 세분화하여 이와 같은 불편함이 생기지 않게 하려고 한다.
수정은 다음과 같은 방향으로 진행한다.
TokenProvider에서 발생하는 Exception을 AuthenticationFilter에서 catch할 수 있도록 TokenProvider의 try-catch문을 제거한다.
AuthenticationFilter에서 Exception에 따라 catch를 나눠주고 각 Exception에 맞게 처리한다.
클라이언트로 전달할 response는 AuthenticationEntryPoint의 commence 메서드를 오버라이드(Override)하여 set한다.
각 단계의 자세한 설명은 코드를 수정하면서 추가로 하려 한다.
TokenProvider에서는 위에서 언급한대로 try-catch문을 제거하여 기본적인 로직만 남겨둔다. AuthenticationFilter에서 토큰의 Expiration도 체크할 예정이기에 이 부분도 제거한다.
// jwt/JwtTokenProvider.java
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
public boolean validateToken(String token) {
if(token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
token = token.split(" ")[1].trim();
}
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
}
}
AuthenticationFilter와 AuthenticationEntryPoint에서 사용할 상태 코드와 메시지를 간편하게 관리하기 위해 enum을 작성해준다.
// jwt/ErrorCode.java
@Getter
public enum ErrorCode {
UNKNOWN_ERROR(4001, "Unknown Error"),
WRONG_TYPE_TOKEN(4002, "Wrong type Token"),
EXPIRED_TOKEN(4003, "Expired Token"),
UNSUPPORTED_TOKEN(4004, "Unsupported Token"),
ACCESS_DENIED(4005, "Unauthenticated User");
private int status;
private String message;
ErrorCode(int status, String message) {
this.status = status;
this.message = message;
}
}
AuthenticationFilter에서는 try-catch문을 작성하여 Exception을 세부적으로 나누어준다. 각 Exception에서는 상황에 맞게 ErrorCode의 상태 코드 값을 request의 attribute로 set해준다. setAttribute를 해주는 이유는 AuthenticationEntryPoint에서 이 attribute의 값에 따라 response의 메시지를 다르게 작성하기 위해서이다.
여담으로 왜 request의 attribute를 사용하냐면 request객체는 요청의 처리부터 끝날 때까지 사용자의 파라미터 + 내부에서 사용하는 파라미터를 저장하는 역할을 하기 때문이다.
출처 : 김영한님
// jwt/JwtAuthenticationFilter.java
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
try {
if(token != null && jwtTokenProvider.validateToken(token)) {
token = token.split(" ")[1].trim();
Authentication auth = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch(SecurityException | MalformedJwtException | IllegalArgumentException e) {
request.setAttribute("exception", ErrorCode.WRONG_TYPE_TOKEN.getStatus());
log.error("Exception [Err_Msg]: {}", e.getMessage());
} catch(ExpiredJwtException e) {
request.setAttribute("exception", ErrorCode.EXPIRED_TOKEN.getStatus());
log.error("Exception [Err_Msg]: {}", e.getMessage());
} catch(UnsupportedJwtException e) {
request.setAttribute("exception", ErrorCode.UNSUPPORTED_TOKEN.getStatus());
log.error("Exception [Err_Msg]: {}", e.getMessage());
} catch(Exception e) {
request.setAttribute("exception", ErrorCode.UNKNOWN_ERROR.getStatus());
log.error("Exception [Err_Msg]: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
}
인증이 되지 않은 사용자가 인증이 필요한 API 엔드포인트에 접근한다면 기본적으로 401 HTTP Status Code를 응답으로 받게된다. 이 로직을 담당하는 것이 AuthenticationEntryPoint라는 인터페이스이다.
기본적인 응답이 아닌 JSON 데이터로 응답해야 하거나 특정 로직을 수행해야 하는 경우 AuthenticationEntryPoint는 인터페이스이기 때문에 구현체를 작성하여 수행할 수 있다. 이 때 commence 메서드를 오버라이드(Override)해야 한다.
이렇게 작성한 구현체는 Spring Security Configuration에 등록하여 사용한다.
CustomAuthenticationEntryPoint에서는 AuthenticationFilter에서 설정한 request의 attribute에 따라 response의 Status Code와 Message를 설정하여 준다.
// jwt/CustomAuthenticationEntryPoint.java
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Integer exception = (Integer) request.getAttribute("exception");
if (exception == null || exception == 4001) {
setResponse(response, ErrorCode.UNKNOWN_ERROR);
} else if (exception == 4002) {
setResponse(response, ErrorCode.WRONG_TYPE_TOKEN);
} else if (exception == 4003) {
setResponse(response, ErrorCode.EXPIRED_TOKEN);
} else if (exception == 4004) {
setResponse(response, ErrorCode.UNSUPPORTED_TOKEN);
} else {
setResponse(response, ErrorCode.ACCESS_DENIED);
}
}
private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setStatus(401);
response.setContentType("application/json; charset=UTF-8");
JSONObject responseJson = new JSONObject();
responseJson.put("status code", errorCode.getStatus());
responseJson.put("error message", errorCode.getMessage());
response.getWriter().print(responseJson);
}
}
SecurityConfig에서는 위에서 작성한 CustomAuthenticationEntryPoint를 authenticationEntryPoint로 설정한다.
// configuration/SecurityConfig.java
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// JWT 인증 필터
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
// error handling
.exceptionHandling()
.accessDeniedHandler(new AccessDeniedHandler() {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(403);
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("Unauthorized User.");
}
})
.authenticationEntryPoint(new CustomAuthenticationEntryPoint());
return http.build();
}
}
여느 때처럼 올바르게 작동하는지 테스트할 차례이다.
간단하게 세 가지 경우에 대해 테스트 해보려 한다.
모두 의도한대로 응답을 보내는 것을 확인할 수 있었다.
UNSUPPORTED_TOKEN과 ACCESS_DENIED에 대한 오류는 테스트할 방법을 알지 못해 아직 작성하지 못하였다. 추후 검색을 통해 방법을 숙지하고 테스트하여 내용을 추가할 예정이다.
https://velog.io/@seung7152/Spring-Security-JWT-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC
https://medium.com/@OutOfBedlam/jwt-%EC%9E%90%EB%B0%94-%EA%B0%80%EC%9D%B4%EB%93%9C-53ccd7b2ba10