Database/Redis

[Redis] RefreshToken을 Redis에 관리한다면?

kkang._.h00n 2024. 2. 13. 18:17

 

개요

최종 프로젝트를 시작하면서, 인증/인가 부분을 한 번 더 맡게 되었다. 이전에는 RefreshToken을 RDB에 관리하며, 'RefreshToken을 Redis에서 관리하는 것이 더 낫지 않을까?'란 느낌이 있었는데, 정확히 어떤 점이 좋은지는 머리 속으로 그려봐도 크게 그려지지는 않았다. 인증/인가 부분을 한 번 더 맡게 된 김에 이번에는 Redis를 이용하여 RefreshToken을 관리해보고, 이전에 잘못 이해했던 RefreshToken 사용 방식을 다시 공부하며 더불어 RDB로 관리할 때의 차이점을 비교해보려 한다.

 

 

 

요구사항

함께 살펴볼 부분은 다음과 같다.

  • 로그인 시 토큰들을 발급받으며 RefreshToken을 Redis에 저장한다.
  • AccessToken 만료 시 RefreshToken을 통해 AccessToken을 재발급 한다.

 

 

1. 로그인 시

로그인 시, RefreshToken 저장 흐름은 다음과 같다.

 

  1. OAuth 로그인
  2. AccessToken & RefreshToken 발급
  3. Redis에 RefreshToken 저장
  4. 로그인 응답으로 AccessToken & RefreshToken ID 전달

 

1. OAuth 로그인

OAuth2LoginSuccessHandler

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final TokenProvider tokenProvider;
    private final UserRepository userRepository;

    private String toJson(UserLoginResponse<?> userLoginResponse) throws JsonProcessingException {
        return objectMapper.writeValueAsString(userLoginResponse);
    }

    private void sendResponse(HttpServletResponse response, String userLoginResponse)
        throws IOException {
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType("application/json;charset=UTF-8");
        response.setContentLength(userLoginResponse.getBytes().length);
        response.getWriter().write(userLoginResponse);
    }

    private UserLoginResponse<?> checkJoined(OAuthUserInfo oAuthUserInfo) {
        UserLoginResponse<?> userLoginResponse;

        //회원가입 여부 확인
        Optional<User> optionalUser = userRepository.findByProviderId(
            oAuthUserInfo.providerId());

        //회원가입하지 않은 유저라면
        if (optionalUser.isEmpty()) {
            // todo : 회원가입 페이지 리다이렉트
        }
        //이미 회원가입한 유저라면
        else {
            log.info("oAuthUser's providerId : {}", oAuthUserInfo.providerId());

            //회원 정보를 통해 토큰 발급
            User findUser = optionalUser.get();
            Token token = tokenProvider.createToken(findUser.getId(), findUser.getRole());

            userLoginResponse = new UserLoginResponse<>(true, token);
        }

        return userLoginResponse;
    }

    @Override
    public void onAuthenticationSuccess(
        HttpServletRequest request,
        HttpServletResponse response,
        Authentication authentication
    ) throws IOException {

        log.info("OAuth Login Success!!");

        if (authentication instanceof OAuth2AuthenticationToken authenticationToken) {

            Map<String, Object> userAttributes = authenticationToken.getPrincipal().getAttributes();
            String provider = authenticationToken.getAuthorizedClientRegistrationId();

            //1. OAuth 로그인 유저 정보 매핑
            OAuthUserInfo oAuthAttribute = OAuthAttributeMapper.toOAuthUserInfo(userAttributes,
                provider);

            //2. 유저 정보 토큰을 담은 dto 생성
            UserLoginResponse<?> userLoginResponse = checkJoined(oAuthAttribute);

            //3. 응답
            sendResponse(response, toJson(userLoginResponse));
        }
    }
}

 

 

OAuth 로그인이 성공적으로 완료되면 호출되는 클래스로, OAuth 사용자에 대한 정보들을 매핑해주고, Token을 생성하여 응답해준다.

OAuth에 대한 자세한 설명은 생략하겠다!

 

 

2. AccessToken & RefreshToken 발급

JwtTokenProvider

@Component
@RequiredArgsConstructor
public class JwtTokenProvider implements TokenProvider {

    private static final String JWT_ROLE = "JWT_ROLE";
    private final JwtProperty jwtProperty;
    private final RefreshTokenRepository refreshTokenRepository;

    private String generateToken(
        Long id,
        Role role,
        long expireTime
    ) {
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + expireTime);
        byte[] keyBytes = Decoders.BASE64.decode(jwtProperty.getClientSecret());

        Claims claims = Jwts.claims().setSubject(String.valueOf(id));
        claims.put(JWT_ROLE, role);

        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(expireDate)
            .signWith(Keys.hmacShaKeyFor(keyBytes), SignatureAlgorithm.HS256)
            .compact();
    }

    //토큰 생성 메서드
    @Override
    public Token createToken(Long id, Role role) {

        //만료 시간 설정
        long accessExpiryMinute = jwtProperty.getAccessExpiryTime() * 1000 * 60;
        long refreshExpiryMinute = jwtProperty.getRefreshExpiryTime() * 1000 * 60;

        //AccessToken & RefreshToken 생성
        String accessToken = generateToken(id, role, accessExpiryMinute);
        String refreshToken = generateToken(id, role, refreshExpiryMinute);

        //로그인 할 때마다 RefreshToken 새로 저장
        String refreshTokenId = UUID.randomUUID().toString();
        refreshTokenRepository.save(refreshTokenId, refreshToken, jwtProperty.getRefreshExpiryTime());
		
        //토큰 전달
        return new Token(accessToken, refreshTokenId, role);
    }

    .
    .

}

 

Jwt 토큰을 전체적으로 관리하는 provider이다.

로그인 시 createToken()이 호출되며, AccessToken과 RefreshToken이 생성된다.

새로 생성된 RefreshToken은 Redis에 저장된다.

클라이언트에게는 (AccessToken, RefreshToken ID, 권한)이 전달된다.

이 때, RefreshToken ID(Redis key)는 아무 의미가 없는 UUID를 사용하였다.

 

 

3. Redis에 RefreshToken 저장

RefreshTokenRepository

@Component
@RequiredArgsConstructor
public class RefreshTokenRepository {

    private final StringRedisTemplate redisTemplate;

    // RefreshToken 저장
    public void save(String refreshTokenId, String refreshToken, Long refreshExpiryTime) {
        redisTemplate.opsForValue()
            .set(refreshTokenId, refreshToken, refreshExpiryTime, TimeUnit.MINUTES);
    }
    
    .
    .

}

 

RefreshToken을 관리하는 Redis 저장소이다. 

key UUID, value RefreshToken이다. 

TTL은 토큰 만료시간으로 설정하여, 만료가 된 RefreshToken은 Redis에서 자동 삭제된다.

 

 

 

2. AccessToken 만료 시

AccessToken 만료 시, RefreshToken 조회 흐름은 다음과 같다.

  1. 클라이언트에서 RefreshToken ID 과 함께 AccessToken 재발급 api 요청
  2. 서버에서 RefreshToken ID 값을 통해 RefreshToken 조회
  3. RefreshToken을 통해 회원 정보 조회 후, 새로운 AccessToken 발급

 

1. AccessToken 재발급  

AuthService

@Service
@RequiredArgsConstructor
public class AuthService {

    private final TokenProvider tokenProvider;

    // RefreshToken Id를 통해 AccessToken 재발급
    public String issueAccessToken(String refreshTokenId) {

        return tokenProvider.createAccessToken(refreshTokenId);
    }

}

 

클라이언트가 현재 갖고있는 AccessToken이 만료된다면, RefreshToken ID를 통해 AccessToken 재발급 요청을 보낼 것이다.

이 때 issueAccessToken()이 호출되며, RefreshToken을 발급하여 응답으로 보낼 것이다.

 

 

2. RefreshToken 조회 & 새로운 AccessToken 발급

JwtTokenProvider

@Component
@RequiredArgsConstructor
public class JwtTokenProvider implements TokenProvider {

    private static final String JWT_ROLE = "JWT_ROLE";
    private final JwtProperty jwtProperty;
    private final RefreshTokenRepository refreshTokenRepository;

    private String generateToken(
        Long id,
        Role role,
        long expireTime
    ) {
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + expireTime);
        byte[] keyBytes = Decoders.BASE64.decode(jwtProperty.getClientSecret());

        Claims claims = Jwts.claims().setSubject(String.valueOf(id));
        claims.put(JWT_ROLE, role);

        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(expireDate)
            .signWith(Keys.hmacShaKeyFor(keyBytes), SignatureAlgorithm.HS256)
            .compact();
    }

    private Jws<Claims> getClaims(String token) {
        byte[] keyBytes = Decoders.BASE64.decode(jwtProperty.getClientSecret());

        try {
            return Jwts.parserBuilder()
                .setSigningKey(Keys.hmacShaKeyFor(keyBytes))
                .build()
                .parseClaimsJws(token);
        } catch (ExpiredJwtException e) {
            throw new ValidationException(AuthErrorCode.AUTH_TOKEN_EXPIRED);
        } catch (
            SecurityException |
            MalformedJwtException |
            UnsupportedJwtException |
            IllegalArgumentException e
        ) {
            throw new ValidationException(AuthErrorCode.AUTH_TOKEN_INVALID);
        }
    }

    @Override
    public String createAccessToken(String refreshTokenId) {
        //id를 통해 redis에서 refreshToken 검색
        Optional<String> optional = refreshTokenRepository.findById(refreshTokenId);

        //redis에 refreshToken이 존재하지 않는다면 -> 새로 로그인하여 파기된 refreshToken
        if (optional.isEmpty()) {
            throw new ValidationException(AuthErrorCode.AUTH_TOKEN_EXPIRED);
        }

        String refreshToken = optional.get();
        TokenPayload payLoad = getPayLoad(refreshToken);

        //accessToken 재발급
        long accessExpiryMinute = jwtProperty.getAccessExpiryTime() * 1000 * 60;
        return generateToken(payLoad.userId(), payLoad.role(), accessExpiryMinute);
    }

    @Override
    public TokenPayload getPayLoad(String token) {
        Jws<Claims> claims = getClaims(token);
        Long userId = Long.parseLong(claims.getBody().getSubject());
        String name = claims.getBody().get(JWT_ROLE).toString();
        Role role = Role.valueOf(name);

        return new TokenPayload(userId, role);
    }

    .
    .

}

 

RefreshToken Id를 통해 AccessToken을 발급하는 createAccessToken() 메서드의 흐름은 다음과 같다.

  1. 우선 요청과 함께 들어온 RefreshToken ID를 통해 RefreshToken을 조회한다. (존재하지 않을 시, 토큰 만료 예외)
  2. RefreshToken을 통해 사용자 정보를 조회한다.
  3. 사용자 정보를 통해 AccessToken을 재발급한다.

 

 

RDB vs Redis

  • RDB 저장 시 PK를 자동으로 할당하지만, Redis는 UUID를 따로 생성하여 key로 사용하였다.
  • 로그아웃 하지 않고 RefreshToken이 만료된다면, RDB에 데이터는 그대로 남게 되지만, Redis는 TTL을 설정했기에 만료된 RefreshToken은 삭제된다.
  • RDB로 관리 시, 로그인 로그아웃마다 쿼리를 날린다.

 

 

정리

Redis를 통해 RefreshToken을 관리해보았다.

필자의 기술적 한계일 수도 있지만,  Redis로 관리하면 더 빠르고 편리할 줄 알았는데 크게 체감은 되지 않았고 나름의 트레이드오프도 있었던 것 같다.

그래도 Redis와 더불어 보안 쪽에 좀 더 생각해보고 공부한 시간이었던 것 같다.

인증/인가 이제 그만!!