JWT의 토큰 만료를 확인하면서 HTTP 500 에러가 발생한다는 것을 확인했다.
요구사항에서 주어진 ERROR_CODE를 확인하면 토큰 에러의 경우에는 401, Unauthorized 에러를 반환하라고 지침하고 있기 때문에 토큰과 관련한 에러를 따로 잡아주는 과정이 필요할 것 같다.
첫 번째 시도
isExpired 메서드에서 try-catch
@Slf4j
public class JwtUtil {
...
public static boolean isExpired(String token, String secretKey) {
try {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
.getBody().getExpiration().before(new Date());
} catch (ExpiredJwtException e) {
log.info("token = {}은 만료 되었음.", token);
throw new SNSAppException(ErrorCode.INVALID_TOKEN, "토큰이 만료되었습니다.");
} catch (UnsupportedJwtException e) {
log.info("token = {}은 지원하지 않음.", token);
throw new SNSAppException(ErrorCode.INVALID_TOKEN, "지원하지 않는 토큰입니다..");
} catch (MalformedJwtException e) {
log.info("token = {}은 올바르지 않음.", token);
throw new SNSAppException(ErrorCode.INVALID_TOKEN, "토큰 값이 올바르지 않습니다.");
} catch (SignatureException e) {
log.info("token = {}은 기존 서명을 확인하지 못함.", token);
throw new SNSAppException(ErrorCode.INVALID_TOKEN, "서명을 확인하지 못했습니다.");
} catch (IllegalArgumentException e) {
log.info("token = {}에 문제가 발생하였습니다..", token);
throw new SNSAppException(ErrorCode.INVALID_TOKEN, "토큰에 알 수 없는 에러가 발생하였습니다.");
}
}
}
JwtFilter doFilterInternal에서 try-catch
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final UserService userService;
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
...
// Token의 만료 여부를 확인합니다.
try {
if (JwtUtil.isExpired(token, secretKey)) {
log.info("Token이 만료 되었습니다.");
filterChain.doFilter(request, response);
}
} catch (Exception e) {
throw new SNSAppException(ErrorCode.INVALID_TOKEN, "토큰이 만료되었습니다.");
}
...
}
}
두 방법 모두 다 기존의 io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-12-24T 16:44:28Z.....
예외처리를 뺏어와 본인이 만든 SNS AppException에서 핸들링하여 com.likelion.finalproject.exception.SNSAppException: 토큰이 만료되었습니다.
예외를 발생시켰으나, HTTP Code가 여전히 500으로 Response되었다.
Jwt예외가 Spring으로 들어가기도 전에 발생하기 때문이었다.
함께 프로젝트를 진행하는 팀원의 도움과 구글의 도움으로 다른 방법을 시도해 보았다.
두 번째 시도
JwtFilter
...
public class JwtFilter extends OncePerRequestFilter {
private final UserService userService;
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// http header에 있는 AUTHORIZATION 정보를 받아옵니다.
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
log.info("authorization : {}", authorization);
// header에 있는 AUTHORIZATION이 없거나, AUTHORIZATION이 "Bearer"로 시작하지 않으면 block처리합니다.
if (authorization == null || !authorization.startsWith("Bearer ")) {
log.info("authorization이 없습니다");
filterChain.doFilter(request, response);
return;
}
// AUTHORIZATION에 있는 Token을 꺼냅니다.
String token '
try {
token = authorization.split(" ")[1];
// Token의 만료 여부를 확인합니다.
if (JwtUtil.isExpired(token, secretKey)) {
log.info("Token이 만료 되었습니다.");
filterChain.doFilter(request, response);
}
String userName = JwtUtil.getUserName(token, secretKey);
log.info("userName = {}", userName);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userName, null, List.of(new SimpleGrantedAuthority("USER")));
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} catch (Exception e) {
request.setAttribute("exception", ErrorCode.INVALID_TOKEN.name());
} finally {
filterChain.doFilter(request, response);
}
}
}
JWT는 Spring Security는 Spring 이전에 필터링되기 때문에 Spring 영역인 전역예외 처리를 위해 만들었던 ExceptionManager에 닿지 못하고 에러가 발생하게 된다. 그렇게 때문에 위와 같이 Error 처리를 했음에도 지정했던 HttpStatus가 나오지 않았던 것이다.
- 이번 시도 역시 JwtFilter부분에서 try-catch를 해주지만 에러가 발견되었을 때 HttpServletRequest부분으로 "exception"이라는 key에 원하는 에러 코드를 value로 넘겨주었다.
- 예외가 발생했다면 AuthenticationEntryPoint라는 곳으로 예외가 넘어가게 되므로 AuthenticationEntryPoint를 상속받는 CustomAuthenticationEntryPoint를 만들어 넘어온 request값을 가지고 예외를 처리하게 된다.
CustomAuthenticationEntryPoint
@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String exception = (String) request.getAttribute("exception");
ErrorCode errorCode;
log.debug("log: exception: {} ", exception);
if (exception == null) {
log.info("토큰이 존재하지 않습니다.");
setResponse(response, ErrorCode.NOT_EXIST_TOKEN);
}
if (exception.equals(ErrorCode.INVALID_TOKEN.name())) {
log.info("토큰이 만료되었습니다.");
setResponse(response, ErrorCode.INVALID_TOKEN);
}
if (exception.equals(ErrorCode.INVALID_PERMISSION.name())) {
log.info("권한이 없습니다.");
setResponse(response, ErrorCode.INVALID_PERMISSION);
}
}
/**
* 한글 출력을 위해 getWriter() 사용
*/
private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException, JSONException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(errorCode.getStatus().value());
Map<String, Object> result = new HashMap<>();
result.put("errorCode", errorCode.name());
result.put("message", errorCode.getMessage());
response.getWriter().println(
objectMapper.writeValueAsString(
ResponseEntity.status(errorCode.getStatus())
.body(Response.error("ERROR", result))));
}
}
JwtFilter에서 넘어온 Attribute "exception"을 받아 와서 exception의 내용이 무엇인지 파악한 후에 어떤 에러인지 파악하고 setResponse를 통해서 Http에 Response 한다.
SpringSecurityConfig
...
public class SecurityConfig {
...
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
return httpSecurity.
httpBasic().disable()// http basic이 아닌 token으로 인증할 것 이기 때문에 disable 처리
...
.and()
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
...
.build();
}
}
시큐리티 설정에서 CustomAuthenticationEntryPoint를 추가해 줍니다.
결과
드디어 500 에러가 아닌 401 에러가 발생하면서 원하는 Error 형식을 보여주기 시작했다.
그러나 headers, body, statusCode, statusCodevalue라는 원하지 않은 정보도 함께 반환하고 있어서 수정이 필요했다.
세 번째 시도
CustomAuthenticationEntryPoint
...
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
...
private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(errorCode.getStatus().value());
Map<String, Object> result = new HashMap<>();
result.put("errorCode", errorCode.name());
result.put("message", errorCode.getMessage());
response.getWriter().println(
objectMapper.writeValueAsString(
Response.error("ERROR", result)));
}
}
- 처음에는 result가 Map 형식이라 그런 것일까 가정한 후 JSONObject에 put 하여 넘겨봤지만 똑같은 결과물을 받았다.
- response.setStatus에서 이미 status의 값을 보내고 있기 때문에 굳이 ResponseEntity로 반환할 필요가 있을까? 생각하고 ResponseEntity를 삭제하고 바로 Response.error를 사용하여 반환하였더니 성공했다.
결과
코드 정리
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
public static final String BEARER_PREFIX = "Bearer ";
private final UserService userService;
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// http header에 있는 AUTHORIZATION 정보를 받아옵니다.
final String token = getToken(request);
log.info("token : {}", token);
// AUTHORIZATION에 있는 Token을 꺼냅니다.
try {
String userName = JwtUtil.getUserName(token, secretKey);
log.info("userName = {}", userName);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userName, null, List.of(new SimpleGrantedAuthority("USER")));
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} catch (SecurityException | MalformedJwtException | ExpiredJwtException e) {
request.setAttribute("exception", ErrorCode.INVALID_TOKEN.name());
} catch (UnsupportedJwtException | IllegalArgumentException e) {
request.setAttribute("exception", ErrorCode.INVALID_PERMISSION.name());
} catch (Exception e) {
log.error("JwtFilter - doFilterInternal() 오류발생");
log.error("token : {}", token);
log.error("Exception Message : {}", e.getMessage());
log.error("Exception StackTrace : {");
e.printStackTrace();
log.error("}");
request.setAttribute("exception", ErrorCode.UNKNOWN_ERROR.name());
} finally {
filterChain.doFilter(request, response);
}
}
private String getToken(HttpServletRequest request) {
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)) {
return token.substring(7);
}
return null;
}
}
- token을 받아오는 메서드를 따로 분리시켰다.
- StringUtils.hasText를 사용하여 null이거나 공백일 때를 확인하여 값이 있을 때에만 true를 받고
- token의 시작이 "Bearer "인지 확인합니다.
- 두 조건 모두 true라면 "Bearer "이후인 7번째 문자부터 끝까지 잘라서 token에 반환한다.
- 아니라면 null을 반환합니다.
- if문을 다 없애버렸다.
- try-catch문을 통해 예외가 발생했을 때 CustomAuthenticationEntryPoint로 넘어가는 것을 확인했다.
- try-catch 이전 if문을 통해 setAttribute를 사용했을 때, 즉시 넘어가는 것이 아니다.
- 그렇다면 불필요한 if문을 없애고 관련된 에러가 발생했을 때 모두 잡아주는 것이 조금 더 깔끔한 코드를 유지할 수 있을 것이라고 생각했다.
- try-catch문을 통해 예외가 발생했을 때 CustomAuthenticationEntryPoint로 넘어가는 것을 확인했다.
결론
- JWT Filter를 try-cath문으로 잡는다.
- AuthenticationEntryPoint를 상속받는 CustomAuthenticationEntryPoint를 만들어서 예외를 처리한다.
- Spring Seuciry에 authenticationEntryPoint를 추가하여 CustomAuthenticationEntryPoint를 거치도록 설정한다.
참조
'프로젝트 > Archive' 카테고리의 다른 글
[04] Post Test 코드 작성 - 1 (Controller Test) (0) | 2022.12.27 |
---|---|
[04] User Test 코드 작성 (0) | 2022.12.26 |
[03] 게시된 포스트 삭제 (0) | 2022.12.23 |
[03] 게시된 포스트 수정 (0) | 2022.12.23 |
[03] 게시된 모든 포스트 목록 보기 (0) | 2022.12.23 |