🔍 문제 상황
어제까지만 해도 잘 되던 my-page 기능이 제대로 동작하지 않는 문제가 있었다.
my-page의 프로세스는 다음과 같다.
- my-page에 접속한다.
- 토큰이 있는지 확인한다.
- 만약 토큰이 없으면 로그인 페이지로 리다이렉트 한다.
- 토큰이 있으면 백엔드 서버로 이를 전송한다.
- 백엔드 서버에서 응답을 받는다.
- 만약 토큰이 유효하지 않으면 로그인 페이지로 리다이렉트 한다. (문제 상황)
- 토큰이 유효하면 사용자 정보를 JSON 형태로 리턴 받는다.
위와 같이 현재 진행하고 있는 프로젝트 'VOCA'는 JWT토큰을 베이스로 구성되어 있다. 현재 문제는 3-1로 토큰이 만료되거나 유효하지 않았을 때 리다이렉트 기능이 제대로 동작하지 않는다는 것이다.
🔍 Debug
서버의 에러 로그를 확인해보니 예상과는 달리 InvalidHeaderException을 리턴하고 있었다.
[ERROR] [ERROR] 2023-05-25 21:00:44 [http-nio-8088-exec-8] [GeneralExceptionHandler:63] - InvalidHeaderException vanille.vocabe.global.exception.InvalidHeaderException: 잘못된 헤더 토큰입니다
서버의 코드는 다음과 같이 작성되어 있었다.
// 1. http 헤더로부터 토큰 정보를 추출한다.
final String token = request.getHeader(HttpHeaders.AUTHORIZATION);
// 1-1. 만약 헤더에 토큰 정보가 없으면 토큰이 없다는 예외를 던진다.
if(!StringUtils.hasText(token)) {
throw new NotFoundException(ErrorCode.NOT_FOUND_TOKEN);
}
User userDetails;
try {
// 2. 토큰에서 사용자 정보를 얻어온다.
String username = JwtTokenUtils.getUsername(token, secretKey);
userDetails = userService.findUserByUsername(username);
if (!JwtTokenUtils.validate(token, userDetails.getUsername(), secretKey)) {
log.error("Token is not valid {}", token);
throw new ExpiredTokenException(ErrorCode.EXPIRED_TOKEN);
}
} catch (Exception e) {
throw new InvalidHeaderException(ErrorCode.INVALID_TOKEN);
}
디버깅 모드를 통해 자세히 살펴보니 ExpiredToken 예외가 발생하여 getBody()를 얻지 못하여 catch문에서 예외로 걸러지고 있었다.
public class JwtTokenUtils {
public static Boolean validate(String token, String userName, String key) {
String usernameByToken = getUsername(token, key);
return usernameByToken.equals(userName) && !isTokenExpired(token, key);
}
public static String getUsername(String token, String key) {
return extractAllClaims(token, key).get("username", String.class);
}
public static Claims extractAllClaims(String token, String key) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey(key))
.build()
.parseClaimsJws(token)
.getBody();
}
}
validate() 코드를 보면 isTokenExpired() 메서드가 있는데 이 메서드 때문에 extractAllClaims() 메서드를 호출할 때는 토큰 만료와 관련된 에러는 안 던진다고 생각한 것이다.
코드는 다음과 같이 수정했다. ExpiredJwtException이 발생하면 토큰 에러를 던지고 나머지 예외는 Exception으로 잡고 에러 로그를 남기는 식으로 코드를 변경했다.
try {
String username = JwtTokenUtils.getUsername(token, secretKey);
userDetails = userService.findUserByUsername(username);
JwtTokenUtils.validate(token, userDetails.getUsername(), secretKey);
} catch (ExpiredJwtException e) {
throw new ExpiredTokenException(ErrorCode.EXPIRED_TOKEN);
} catch (Exception e) {
log.error("Error : " + e.getMessage() + ", Cause : " + e.getCause());
throw new InvalidHeaderException(ErrorCode.INVALID_TOKEN);
}
테스트 코드도 추가하였다.
@DisplayName("[실패] 토큰이 만료되었을 때")
@Test
void authInterceptorFailWithExpiredToken() throws Exception {
User user = UserFixture.getVerifiedUser("kim");
userRepository.save(user);
final String token;
try {
token = JwtTokenUtils.generateAccessToken("kim", 1000L, key);
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
mockMvc.perform(post("/api/v1/words")
.header(HttpHeaders.AUTHORIZATION, token)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(WordFixture.get(user)))
)
.andExpect(jsonPath("statusCode").value(ErrorCode.EXPIRED_TOKEN.getHttpStatus().toString()))
.andDo(print());
}
@DisplayName("[실패] 토큰 유효하지 않을 때")
@Test
void authInterceptorFailWithInvalidToken() throws Exception {
User user = UserFixture.getVerifiedUser("kim");
userRepository.save(user);
mockMvc.perform(post("/api/v1/words")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + "wrongtoken.dwfwdf.wdfxcv")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(WordFixture.get(user)))
)
.andExpect(jsonPath("statusCode").value(ErrorCode.INVALID_TOKEN.getHttpStatus().toString()))
.andDo(print());
}
이펙티브 자바에서 예외는 가장 상세한 예외를 던지는 것이 좋다 하여 catch(DecodeException e)를 추가할려고 하였는데 추가 로직이 필요없고, 로그 에러를 남기기만해도 충분할 것 같다는 생각이 들어 생략하였다.