redis 를 이용하여 Access Token 과 Refresh Token 구현 간 이상한 에러가 발생했다...!
거의 다 끝났다 싶을 때 발생한 것이다...
java.lang.ClassCastException: class java.util.HashMap cannot be cast to class java.lang.String (java.util.HashMap and java.lang.String are in module java.base of loader
...
'bootstrap')org.springframework.data.redis.serializer.SerializationException: Cannot deserialize
먼저 ClassCastException 를 알아보니 특정 클래스의 객체를 호환되지 않는 다른 클래스의 객체로 변환하려고 할 때 발생하는 런타임 에러라고 한다.
위 에러메시지를 살펴보니 HashMap 타입을 String 으로 캐스팅을 하려고 했나보다 싶었다.
이에 다음 SerializationException 는 직렬화 또는 역직렬화 과정에서 발생하는 에러라고 한다.
대충 직렬화는 객체를 데이터 스트림으로, 역직렬화는 그 반대라고 하는데,, 사실 좀 어려운 거 같다. 이는 추후 해당 주제를 토대로 공부를 해야겠다.
각설하고, 우선 문제 해결에 중점을 두자면 RedisTemplates 에서 뭔가 잘못 설정한 거 같다.
우선 코드를 살펴보니
public void saveTokens(String loginId, String accessToken, String refreshToken) {
String key = "authTokens:" + loginId;
Map<String, String> tokens = new HashMap<>();
tokens.put("accessToken", accessToken);
tokens.put("refreshToken", refreshToken);
redisTemplate.opsForValue().set(key, tokens);
}
public Map<Object, Object> getTokens(String loginId) {
String key = "authTokens:" + loginId;
return redisTemplate.opsForHash().entries(key);
}
saveTokens 메서드에서는 opsForValue().set를 사용하여 tokens 맵을 저장하고 있다.
그러나 getTokens 메서드에서는 opsForHash().entries를 사용하여 데이터를 조회하려고 하고 있는 것을 발견했다.
우선 저장방식과 조회방식 서로 일치하지 않는 문제가 있는 것을 찾았다.
나는 데이터를 해시맵으로 저장하는 게 목적이니, 이에 맞추어 수정하였다.
public void saveTokens(String loginId, String accessToken, String refreshToken) {
String key = "authTokens : " + loginId;
Map<String, String> tokens = new HashMap<>();
tokens.put("accessToken", accessToken);
tokens.put("refreshToken", refreshToken);
redisTemplate.opsForHash().putAll(key, tokens);
}
public Map<Object, Object> getTokens(String loginId) {
String key = "authTokens : " + loginId;
return redisTemplate.opsForHash().entries(key);
}
다음은 RedisTemplate 설정을 보기 위해 RedisConfig 를 찾아갔다.
RedisConfig
@Bean
public RedisTemplate<String, Object> redisTemplate() {
// redisTemplate를 받아와서 set, get, delete를 사용
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// setKeySerializer, setValueSerializer 설정
// redis-cli을 통해 직접 데이터를 조회 시 알아볼 수 없는 형태로 출력되는 것을 방지
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
1분 동안 살펴본 결과 알아챘다 !
딱보니까 redisTemplate.setValueSerializer(new StringRedisSerializer()); 이 녀석이 문제일 거 같았다.
해시맵 저장을 위해 RedisTemplate<String, Object> 와 같이 타입을 설정하였는데, value 값을 String 으로 직렬화한 것이 문제인 거 같았다.
이에 맵 객체는 어떻게 직렬화하는지 알아보았고 해당 코드를 붙여서 넣었다.
수정 후
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // 변경됨
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); // 해시 값 직렬화 방식도 설정
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
시도한 것처럼 코드를 수정하니 깔끔하게 해결할 수 있었다.
다음 문제는 만료된 AccessToken 을 재발급 받는 과정에서 발생하였다.
JwtAuthorizationFilter
public void refreshAccessToken(HttpServletRequest req, HttpServletResponse res) throws IOException {
log.info("쿠키에서 리프레시 토큰 추출");
String refreshToken = "";
Cookie[] cookies = req.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
refreshToken = cookie.getValue();
break;
}
}
}
log.info("refreshToken = " + refreshToken);
// 리프레시 토큰 유효성 검증
if (!StringUtils.hasText(refreshToken) || !jwtUtil.validateToken(refreshToken)) {
log.info("Refresh Token 만료 또는 유효하지 않음");
res.sendError(404, "리프레시 토큰이 존재하지 않거나 만료됐습니다.");
return;
}
// 사용자 유효성 검사
// Redis 에 저장된 AccessToken 과 요청헤더로 전달된 AccessToken 을 비교
// 헤더에 담긴 Access Token
String expiredAccessToken = jwtUtil.getJwtFromHeader(req);
String loginId = jwtUtil.getUserInfoFromToken(expiredAccessToken).getSubject();
// Redis 에 저장된 Access Token
String storedAccessToken = jwtUtil.subStringBearer(redisService.getAccessToken(loginId));
if (!expiredAccessToken.equals(storedAccessToken)) {
res.sendError(403, "잘못된 접근입니다.");
return;
}
// 권한 가지고 오기
User findUser = userInfoService.findUser(loginId);
// 새로운 AccessToken 발급
log.info("access Token 발급");
String newAccessToken = jwtUtil.createAccessToken(loginId, findUser.getRole());
// redis 갱신
redisService.saveTokens(loginId, newAccessToken, refreshToken);
// 헤더를 통해 전달
res.addHeader(JwtUtil.AUTHORIZATION_HEADER, newAccessToken);
log.info("재발급 완료");
log.info("expiredAccessToken = " + expiredAccessToken);
log.info("newAccessToken = " + newAccessToken);
}
우선 이와 같이 AccessToken 을 RefreshToken 을 이용하여 재발급 받는 로직을 작성하였다.
그러나 이상하게 로그가 // 리프레시 토큰 유효성 검증 으로 주석처리 한 부분 이후로 찍히질 않는 것이다...
2024-02-27T04:37:21.083+09:00 INFO 65521 --- [nio-8081-exec-2] JwtUtil : token = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0NiIsImF1dGgiOiJPV05FUiIsImlhdCI6MTcwODk3NTc5NCwiZXhwIjoxNzA4OTc1OTc0fQ._M53Sn-nbxZJk87Xggc3Cmc4Ma5bQqP_c_l4_nrr07Y
2024-02-27T04:37:21.083+09:00 INFO 65521 --- [nio-8081-exec-2] JWT 검증 및 인가 : AccessToken = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0NiIsImF1dGgiOiJPV05FUiIsImlhdCI6MTcwODk3NTc5NCwiZXhwIjoxNzA4OTc1OTc0fQ._M53Sn-nbxZJk87Xggc3Cmc4Ma5bQqP_c_l4_nrr07Y
2024-02-27T04:37:21.083+09:00 ERROR 65521 --- [nio-8081-exec-2] JwtUtil : Expired JWT token, 만료된 JWT token 입니다. Error: JWT expired at 2024-02-26T19:32:54Z. Current time: 2024-02-26T19:37:21Z, a difference of 267083 milliseconds. Allowed clock skew: 0 milliseconds.
2024-02-27T04:37:21.083+09:00 ERROR 65521 --- [nio-8081-exec-2] JWT 검증 및 인가 : Token Error
2024-02-27T04:37:21.083+09:00 INFO 65521 --- [nio-8081-exec-2] JWT 검증 및 인가 : 쿠키에서 리프레시 토큰 추출
2024-02-27T04:37:21.083+09:00 INFO 65521 --- [nio-8081-exec-2] JWT 검증 및 인가 : refreshToken = eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MDg5NzU3OTQsImV4cCI6MTcxMDE4NTM5NH0.83jz-sSWmuz4XvF8Nvk6lG9s3ozOcHkhQeqcSFpOcFQ
2024-02-27T04:37:21.084+09:00 INFO 65521 --- [nio-8081-exec-2] JwtUtil : token = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0NiIsImF1dGgiOiJPV05FUiIsImlhdCI6MTcwODk3NTc5NCwiZXhwIjoxNzA4OTc1OTc0fQ._M53Sn-nbxZJk87Xggc3Cmc4Ma5bQqP_c_l4_nrr07Y
2024-02-27T04:37:21.084+09:00 ERROR 65521 --- [nio-8081-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
이렇게 로그가 출력되는데, 우선 모두 예상에 맞는 로그가 잘 찍히는데 리프레시 토큰을 추출하고 이후에
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2024-02-26T19:32:54Z. Current time: 2024-02-26T19:37:21Z, a difference of 267084 milliseconds. Allowed clock skew: 0 milliseconds.
이렇게 토큰 만료시 발생하는 예외처리가 나타난 것이다.
토큰 만료에 대비하기 위해 Refresh 개념을 도입했는데,,,
머리가 띵했다
일단 refreshToken 이 잘못되었나 생각했다.
하지만 로그로 출력해봤지만 전혀 이상이 없었다.
로그에 찍힌 토큰과 레디스에 저장된 토큰, 포스트맨 쿠키에 존재하는 토큰 모두 이상이 없었다.
그래서 문제의 원인을 찾기 위해 디버깅을 시도하였다.
나는 Refesh 토큰의 유효성 검사가 문제인가 싶었는데,
디버깅을 시도한 결과
String loginId = jwtUtil.getUserInfoFromToken(expiredAccessToken).getSubject();
이 녀석에서 멈추는 것을 확인할 수 있었다.
해당 로직은 사용자가 (만료된)AccessToken 을 가지고 오면, 해당 토큰에서 사용자의 정보를 추출하여 변수에 저장하는 로직이다.
알아보니 만료된 토큰을 이용하여 정보를 추출하면 ExpiredJwtException 이 터진다고 한다.
사용자 정보는 반드시 필요했고, 이를 위해 RefreshToken 에서 정보를 추출하자니 애초에 RefreshToken 을 생성할 때 사용자 정보를 설정하지 않을 뿐더러, 이를 위해 사용자 정보를 구성하여 생성하고 싶진 않았다. (오로지 만료기한으로만 구성)
이에 한 가지 방법을 알아냈다.
만료된 토큰에서 사용자 정보를 추출해야 하는 경우, 예외 처리를 통해 이를 관리할 수 있는 것이다.
방법은 jwtUtil.getUserInfoFromToken 메서드 내에서 ExpiredJwtException을 캐치하고, 이 예외가 발생했을 때도 사용자 정보를 반환할 수 있도록 로직을 조정하는 것이다.
이를 위해 다음과 같이 jwtUtil.getUserInfoFromToken 메서드를 수정했다.
수정 전
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
수정 후
public Claims getUserInfoFromToken(String token) {
try {
return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
// 만료된 토큰에서도 Claims 반환
return e.getClaims();
}
}
이렇게 개선하니 만료된 토큰에서도 정보를 추출할 수 있었다.
그런데 아무래도 만료된 토큰에서 정보를 추출하는 것이다 보니 뭔가 보안상 문제가 있을 거 같고, 이에 대해 신중히 고려하고 대책을 세워야 할 거 같다는 생각이 들었다. (우선 이건 추후 고민,,,)
이렇게 함으로써 최종적으로 RefreshToken 을 이용하여 AccessToken 을 새롭게 발급하는 로직을 완성하였다.
어떤 객체에서 다른 객체로 캐스팅할 때 신중하게 해야한다는 것을 알았으며, 만료된 토큰에서 접근하면 반드시 ExpiredJwtException 예외가 발생한다는 것을 알았다.
이외 직렬화와 역직렬화는 추후 해당 주제만을 따로 다루어 공부를 하고 글을 남겨야겠다.