JWT 인증 방식을 채용하여 Refresh Token 과 Access Token 의 개념을 도입하던 중 뭔가 이상한 점을 느꼈다.
생각했던 프로세스는
이렇게 코드를 구현하고, 문득 생각이 드는 것이 2번 과정부터 잘못된 게 아닌가라는 생각이 들었다.
JWT 는 stateless 한 서버를 구축하는데 장점이 있는 인증방식인데, Access Token 을 서버에 저장하고, 이를 클라이언트의 토큰과 비교하고 검증하는 게 세션 인증 방식과 크게 다를 게 없다는 생각이 든 것이다.
매번 인가 과정을 거칠 때마다 Access Token 끼리 비교 및 검증을 거치는 것은 아니지만, 뭔가 본래의 JWT 의의에서 벗어난 느낌이 든 것이다.
"굳이 Access Token 까지 서버에 저장할 필요가 있을까 ? " 라는 의문이 들었고, 결국 이를 개선하도록 하였다.
(물론 Refresh Token 도 서버에 저장하는 것도 온전히 stateless 한 방식은 아니란 생각이 든다.)
@Component
@RequiredArgsConstructor
public class RedisUtil {
private final RedisTemplate<String, Object> redisTemplate;
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);
}
public void deleteRefreshToken(String accessToken) {
redisTemplate.delete(accessToken);
}
public String getAccessToken(String loginId) {
Map<Object, Object> tokens = getTokens(loginId);
if (tokens != null && tokens.containsKey("accessToken")) {
return (String) tokens.get("accessToken");
}
return null;
}
}
수정 전에는 해시맵 구조로 데이터를 저장하기 위한 방식으로 코드를 작성했다.
@Component
@RequiredArgsConstructor
public class RedisUtil {
private final RedisTemplate<String, String> redisTemplate;
public void saveRefreshToken(String loginId, String refreshToken) {
redisTemplate.opsForValue().set(loginId, refreshToken);
}
public String getRefreshToken(String loginId) {
return redisTemplate.opsForValue().get(loginId);
}
public void deleteRefreshToken(String loginId) {
redisTemplate.delete(loginId);
}
}
이제는 Access Token 을 저장할 필요도 없고, 이에 해시맵으로 저장할 필요가 없으니 RedisTemplate 의 타입을 String 으로 변경하였다.
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(redisUtil.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 갱신
redisUtil.saveTokens(loginId, newAccessToken, refreshToken);
// 헤더를 통해 전달
res.addHeader(JwtUtil.AUTHORIZATION_HEADER, newAccessToken);
log.info("재발급 완료");
log.info("expiredAccessToken = " + expiredAccessToken);
log.info("newAccessToken = " + newAccessToken);
}
}
수정 전에는 클라이언트의 Access Token 과 Redis 에 저장된 Access Token 을 비교하여 검증 절차를 거치고 있다.
이제 해당 과정을 없앨 것이다.
public void refreshAccessToken(HttpServletRequest req, HttpServletResponse res) throws IOException {
// 사용자 유효성 검사
// 헤더에 담긴 Access Token
String expiredAccessToken = jwtUtil.getJwtFromHeader(req);
String loginId = jwtUtil.getUserInfoFromToken(expiredAccessToken).getSubject();
// 유저 정보 가져오기
User findUser = userInfoService.findUser(loginId);
// Refresh Token 추출
log.info("쿠키에서 리프레시 토큰 추출");
String refreshTokenFromCooikie = "";
Cookie[] cookies = req.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
refreshTokenFromCooikie = cookie.getValue();
break;
}
}
}
log.info("refreshToken = " + refreshTokenFromCooikie);
// Redis 에서 Refresh Token 추출
String refreshTokenFromRedis = redisUtil.getRefreshToken(findUser.getLoginId());
// Refresh Token 유효성 검증
if (!StringUtils.hasText(refreshTokenFromCooikie) || !jwtUtil.validateToken(refreshTokenFromCooikie) || !refreshTokenFromRedis.equals(refreshTokenFromCooikie)) {
log.info("Refresh Token 만료 또는 유효하지 않음");
redisUtil.deleteRefreshToken(findUser.getLoginId());
res.sendError(401, "리프레시 토큰이 존재하지 않거나 만료됐습니다.");
return;
}
// 새로운 AccessToken 발급
log.info("새로운 Access Token 발급");
String newAccessToken = jwtUtil.createAccessToken(findUser.getLoginId(), findUser.getRole());
// 헤더를 통해 전달
res.addHeader(JwtUtil.AUTHORIZATION_HEADER, newAccessToken);
log.info("재발급 완료");
log.info("newAccessToken = " + newAccessToken);
}
이렇게 함으로써 최종적으로 Access Token 을 서버에 저장하고, 이를 검증 과정에서 추출하고 비교하는 과정을 모두 제거하였다.
이번 과정을 통해 JWT의 stateless한 특성을 충실하게 구현할 수 있었다.
Access Token 을 서버에 저장하고 비교하는 과정을 제거함으로써, 서버의 상태 관리를 최소화 할 수 있었다.