[ T I L ] 2024.02.27

오세창·2024년 2월 27일

TIL

목록 보기
3/18

문제

JWT 인증 방식을 채용하여 Refresh Token 과 Access Token 의 개념을 도입하던 중 뭔가 이상한 점을 느꼈다.

생각했던 프로세스는

  1. 인증이 성공하면, Access Token 과 Refresh Token 을 생성 및 발급
  2. 서버측에서는 loginId 를 key 로 가지고 각 토큰을 value 로 가진 HashMap 구조로 Redis 에 저장을 한다
  3. 이후 클라이언트의 Access Token 이 만료되면 위 코드를 수행한다.
  4. 클라이언트가 전달한 만료된 Access Token 과 Redis 에 저장된 Access Token 을 비교 및 검증을 거친다.
  5. 동일하게 Refresh Token 도 비교 및 검증을 거친다.
  6. 검증 절차를 통과하면 새로운 Access Token 발급

이렇게 코드를 구현하고, 문득 생각이 드는 것이 2번 과정부터 잘못된 게 아닌가라는 생각이 들었다.

JWT 는 stateless 한 서버를 구축하는데 장점이 있는 인증방식인데, Access Token 을 서버에 저장하고, 이를 클라이언트의 토큰과 비교하고 검증하는 게 세션 인증 방식과 크게 다를 게 없다는 생각이 든 것이다.

매번 인가 과정을 거칠 때마다 Access Token 끼리 비교 및 검증을 거치는 것은 아니지만, 뭔가 본래의 JWT 의의에서 벗어난 느낌이 든 것이다.

"굳이 Access Token 까지 서버에 저장할 필요가 있을까 ? " 라는 의문이 들었고, 결국 이를 개선하도록 하였다.

(물론 Refresh Token 도 서버에 저장하는 것도 온전히 stateless 한 방식은 아니란 생각이 든다.)

시도

수정 전 RedisUtil

@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;
    }
}

수정 전에는 해시맵 구조로 데이터를 저장하기 위한 방식으로 코드를 작성했다.

수정 후 RedisUtil

@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 으로 변경하였다.

수정 전 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(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 을 비교하여 검증 절차를 거치고 있다.

이제 해당 과정을 없앨 것이다.

수정 후 JwtAuthorizationFilter

   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 을 서버에 저장하고 비교하는 과정을 제거함으로써, 서버의 상태 관리를 최소화 할 수 있었다.

0개의 댓글