[이룸] 240223 토큰 재발급 포스트맨 테스트
[문제]
토큰 재발급 로직을 구현하였는데, 테스트를 어떻게 진행해야 할지 몰라 먼저 배포를 진행하였다. 이후 프론트와 연결해 보니 구동이 되지 않았다. 나는 토큰 재발급에 대해 무엇을 이해하지 못하고 있는걸까? 토큰 재발급 테스트는 어떻게 진행해야 할까?
문제의 원인
- 토큰 재발급을 테스트하기 위해 기존의 access token이 만료되기를 하염없이 기다렸다. → 1시간에서 20초로 token time을 수정하였다.
- 이후 프론트와 테스트를 해 보려 하니, 다른 로직을 테스트하고 있던 팀원들이 access token 만료로 불편함을 겪게 되었다. → 로컬에서 테스트 진행해야 함을 인지하였다.
- 또한, 프론트와의 테스트에서 아무리 401을 채가서 리디렉션을 해도 500 에러가 터지는 문제가 발생함을 인지하였다. 해당 500이 어디서 터지는지 로그 확인이 필요하다.
- 이전에 진행한 로컬 테스트가 잘못되었음을 알아차렸다. 만료되지 않은 access token을 가지고 /api/token 으로 재발급 요청을 보냈고, 200과 함께 토큰이 재발급된 것을 보고 기능이 잘 구동되었다고 착각한 것. → api/token은 만료된 access token이 오는 상황임을 인지하였다.
[해결방안 고민]
1. 로컬호스트에서 /api/token을 실행해 보았다. → doFilter 에서 계속해서 500 에러를 반환하는 것을 확인하였다. 아래는 해당 로직.
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getTokenFromRequest(req);
if (StringUtils.hasText(tokenValue)) {
tokenValue = jwtUtil.substringToken(tokenValue);
log.info("tokenValue : " + tokenValue);
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Invalid JWT Token");
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error("Authentication failed: {}", e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
2. /api/token 요청 또한 이 필터를 통과한다는 것을 인지하였다. → /api/token에 들어오는 요청들은 access token이 만료된 채로 들어오기 때문에, 자꾸 validateToken 메서드를 수행하는 과정에서 500 에러가 터진 것 또한 알 수 있었다. “Expired JWT token, 만료된 JWT token 입니다.”가 출력됨을 확인.
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
logger.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
3. 그렇다면, /api/token으로 들어오는 요청들은 doFilter를 지나쳐가도록 따로 설정해주어야 한다.
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getTokenFromRequest(req, JwtUtil.AUTHORIZATION_HEADER);
// 토큰 재발급 요청이 오면 refresh token을 확인하고 validate를 진행하도록 수정.
if(req.getRequestURI().equals("/api/token")){
tokenValue = jwtUtil.getTokenFromRequest(req, JwtUtil.REFRESH_TOKEN_HEADER);
// 만약 refresh token이 만료되었으면 403 에러 반환.
if(!StringUtils.hasText(tokenValue)){
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
tokenValue = jwtUtil.substringToken(tokenValue);
jwtUtil.validateToken(tokenValue);
filterChain.doFilter(req, res);
return;
}
if (StringUtils.hasText(tokenValue)) {
tokenValue = jwtUtil.substringToken(tokenValue);
log.info("tokenValue : " + tokenValue);
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Invalid JWT Token");
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error("Authentication failed: {}", e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
4. 포스트맨 테스트를 진행하였다. 순서는 다음과 같다.
- api/login : 로그인 진행
- api/mypage : 마이페이지 get 요청 (토큰이 잘 발급되었는지 확인한다.)
- api/mypage: 20초(토큰 타임) 뒤, 다시 마이페이지 get 요청 (access token 만료를 확인한다.) → 401 에러가 반환되면 성공!
- api/token: 토큰 재발급 요청 → 200과 함께 access token이 재발급되었으면 성공!
- api/mypage → 200과 함께 mypage가 잘 뜨면 최종 성공!
5. 그런데, 3번에서 500에러가 반환되었다. 이유는 api/mypage로 들어온 요청을 처리하는 과정에서 validateToken 메서드를 통과하지 못했기 때문. 따라서 해당 부분에 401을 반환하도록 로직을 수정하였다.
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Invalid JWT Token");
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
6. 3번에서 401 에러를 반환하는 걸 확인한 후 테스트를 계속 진행하였다. 이번에는 4번에서 NPE가 터졌다. 범인은 컨트롤러에 있었다! → @AuthenticationPrincipal 어노테이션을 통해 현재 인증된 사용자 정보(UserDetailsImpl 객체)를 직접 받는데, userDetailsImpl을 쓴다는 게 시큐리티 필터를 모두 통과했다는 뜻이기 때문. 현재 로직에 따르면 doFilter에서 401을 반환했으니 필터를 온전히 통과하지 못한 거고, 따라서 userDetailsImpl에도 정보가 담기지 못해 null값이 반환된 것이다. → 따라서 refreshToken에서 사용자 정보를 받는 것으로 수정하였다. 위에는 수정 전, 아래는 수정 후.
@PostMapping("/api/token")
public ResponseEntity<BaseDto<String>> reissueToken(@AuthenticationPrincipal UserDetailsImpl userDetails, HttpServletResponse res) throws UnsupportedEncodingException, UnsupportedEncodingException {
String message = memberService.reissueToken(userDetails, res);
return ResponseEntity.ok(new BaseDto<>(null, message, HttpStatus.OK));
}
@PostMapping("/api/token")
public ResponseEntity<BaseDto<String>> reissueToken(@CookieValue(name = "Refresh-token") String refreshToken, HttpServletResponse res) throws UnsupportedEncodingException, UnsupportedEncodingException {
String message = memberService.reissueToken(refreshToken, res);
return ResponseEntity.ok(new BaseDto<>(null, message, HttpStatus.OK));
}
7. 포스트맨 테스트를 모두 통과하였다! 이후 프론트와의 연결에도 문제가 없었다.