YS's develop story

Redis를 이용해 JWT 활용하기 (Refresh Token 저장 시 Redis활용) 본문

Spring

Redis를 이용해 JWT 활용하기 (Refresh Token 저장 시 Redis활용)

Yusang 2024. 5. 30. 14:51

 

 

 

test-redis:
  container_name: test-redis
  image: redis:6
  hostname: redis
  command: redis-server --port 6379
  ports:
    - "6379:6379"
  volumes:
    - ./redis/data:/data
  environment:
    - TZ=Asia/Seoul
  labels:
    - "name=redis"
    - "mode=standalone"
  restart: always

 

Docker 위에서 Redis를 올려놓고 사용할 것이기 때문에 docker-compose.yml 파일 수정

 

 

spring:
  config:
    activate:
      on-profile: dev

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3307/test?characterEncoding=UTF-8
    username: root
    password: root

  jpa:
    show-sql: true
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MySQLDialect

  data:
    redis:
      host: localhost
      port: 6379

 

application.yml 파일에 Redis 설정 추가

 

 

 

//redis config 설정 클래스들
@Configuration
public class RedisConfig {

    @Bean
    LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory());
        return template;
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(10L)); // 캐시의 TTL 설정 (10분)

        return RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(factory)
                .cacheDefaults(cacheConfig)
                .build();
    }

    //Redis는 LocalDateTime과 같은 일부 타입을 직렬화할 수 없기 때문에
    //Redis의 직렬화 도구인 GenericJackson2JsonRedisSerializer를 구성하여 Java 8의 시간 관련 타입을 처리할 수 있도록 함.
    @Bean
    public RedisSerializer<Object> redisSerializer() {
        return new GenericJackson2JsonRedisSerializer(new ObjectMapper().registerModule(new JavaTimeModule()));
    }
}

 

Redis config 설정

 

 

  1. 클라이언트가 로그인을 성공하면, 서버는 Access Token과 Refresh Token을 함께 제공합니다.
  2. 이후 클라이언트는 인가가 필요한 요청에 Access Token을 header에 담아서 요청하게 됩니다.
  3. 시간이 조금 흘러 Access Token이 만료되었다면, 클라이언트는 Refresh Token을 서버로 전달하여 새로운 Access Token을 발급받습니다.

 

여기서 Refresh Token 토큰을 DB에 저장하지 않고 Redis에 저장하는 방식을 구현해보고자 했습니다.

이렇게 했을 때의 장점은 Redis는 메모리 내 데이터 구조 저장소로서 매우 빠른 응답 시간을 제공하는데,

그렇기에 인증 서버에서 자주 액세스하는 데이터를 Redis에 저장하면 DB 액세스보다 더 빠른 응답 시간을 얻을 수 있습니다.

 

Refresh Token은 인증 서버에 자주 요청되는 데이터 중 하나이므로,

이를 Redis에 저장함으로써 전반적인 성능을 향상할 수 있습니다.

 

 

 

//redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

//Redis는 LocalDateTime과 같은 일부 타입을 직렬화할 수 없기 때문에
//Redis의 직렬화 도구인 GenericJackson2JsonRedisSerializer를 구성하여 Java 8의 시간 관련 타입을 처리할 수 있도록 함.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'

Redis 관련 gradle 추가

 

 

@Getter
@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 2)
//titmeToLive -> 만료시간 설정 초 단위
//timeToLive의 시간이 지나면 redis에 저장된 RefreshToken이 만료 됨

public class RefreshToken {

    //java.persistence.id가 아닌 opg.springframework.data.annotation.Id 를 import해야 함
    //Refresh Token은 Redis에 저장하기 때문에 JPA 의존성이 필요하지 않음. persistence로 하게되면 에러 발생
    @Id
    private String refreshToken;
    private Long userId;

    @Builder
    public RefreshToken(String refreshToken, Long userId) {
        this.refreshToken = refreshToken;
        this.userId = userId;
    }

    //역직렬화를 위한 기본 생성자
    public RefreshToken() {
    }
}

 

RefreshToken class 생성

 

이때 java.persistence.id가 아닌 opg.springframework.data.annotation.Id 를 import 해야 합니다.
여기서 Refresh Token은 DB에 저장하는 것이 아닌 Redis에 저장하기 때문에 JPA 의존성이 필요하지 않습니다. -> persistence로 하게 되면 에러가 발생합니다.

 

 

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {

    @Override
    Optional<RefreshToken> findById(String s);
}

 

RefreshToken CrudRepository 생성

 

 

public String createRefreshToken(User user, RefreshTokenRepository refreshTokenRepository) {
    String refreshToken = UUID.randomUUID().toString();
    RefreshToken redisRefreshToken = RefreshToken.builder().
            refreshToken(refreshToken).
            userId(user.getId()).
            build();

    refreshTokenRepository.save(redisRefreshToken);

    return refreshToken;
}

 

jwtProvider에 RefreshToken을 생성하는 함수 createRefreshToken을 생성

이때 Refresh Token은 UUID을 통해서 생성

 

 

@Override
protected void successfulAuthentication(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain,
        Authentication authResult
) throws IOException {
    User user = ((PrincipalDetails) authResult.getPrincipal()).getUser();
    String token = jwtProvider.createToken(user);
    String refreshToken = jwtProvider.createRefreshToken(user, refreshTokenRepository);

    LoginResponseDTO loginResponseDto = LoginResponseDTO.fromEntity(user, token, refreshToken);
    ResponseDTO<LoginResponseDTO> loginResponse = ResponseDTO.okWithData(loginResponseDto);

    sendJsonResponse(response, loginResponse, HttpStatus.OK);
}

 

로그인 성공 시 Access Token과 Refresh Token을 생성하여 보낼 수 있도록 코드 작성

 

 

 

이후 확인을 하면 로그인 시 Access Token과 Refresh Token을 모두 발급하고 successfulAuthentication에서 작성한 대로 Refresh Token은 Redis에 저장되고 있습니다.

 

Refresh Token의 만료시간은 Refresh Token 클래스의 TTL 시간이고 이 시간이 지나게 되면 redis에 저장된 값이 사라지기 때문에 Refresh Token이 만료되게 됩니다.

 

클라이언트는 로그인 성공 시 짧은 만료시간을 가진 Access Token과 약간 긴 만료시간을 가진 Refresh Token을 발급받게 되는데, 시간이 흘러  Access Token이 만료된다면 서버로 Refresh Token을 보내서 Access Token을 재발급받을 수 있습니다.

 

Access Token이 짧은 만료시간을 갖게 되는 건 Access Token이 탈취될 수 있기 때문에

토큰의 만료시간을 짧게 하는 보안성의 이점이 있다고 할 수 있습니다.

 

그렇다면 이제 redis에 Refresh Token이 존재한다면 서버에서 올바른 Refresh Token을 받았을 때,

해당 유저의 Access Token을 재 발급하는 코드를 작성해야 합니다.

 

 

 

@RequiredArgsConstructor
@RestController
public class TokenController {

    private final TokenService tokenService;

    @PostMapping("/access-token")
    public ResponseEntity<ResponseDTO<GetNewAccessTokenResponseDTO>>
    getNewAccessToken(@RequestBody RefreshToken refreshToken) {

        GetNewAccessTokenResponseDTO getNewAccessTokenResponseDTO =
                tokenService.generateAccessToken(refreshToken);

        ResponseDTO<GetNewAccessTokenResponseDTO> response
                = ResponseDTO.okWithData(getNewAccessTokenResponseDTO);

        return ResponseEntity
                .status(response.getCode())
                .body(response);
    }
}

 

Token controller

 

 


@RequiredArgsConstructor
@Transactional
@Service
public class TokenService {

    private final RefreshTokenRepository refreshTokenRepository;
    private final UserRepository userRepository;
    private final JwtProvider jwtProvider;

    public GetNewAccessTokenResponseDTO generateAccessToken(final RefreshToken refreshToken) {

        //redis의 refresh token의 유효기간이 지나지 않았는지
        RefreshToken refreshTokenCheck = refreshTokenRepository.findById(refreshToken.getRefreshToken())
                .orElseThrow(ExpiredRefreshTokenException::new);

        User user = userRepository.findById(refreshTokenCheck.getUserId())
                .orElseThrow(UserNotFoundException::new);

        return GetNewAccessTokenResponseDTO.fromEntity(jwtProvider.createToken(user));
    }
}

TokenService

 

Redis에 Refresh Token이 존재하는지 체크하고, 존재한다면 저장된 userId를 기반으로 jwt토큰을 재발급합니다.

 

public record GetNewAccessTokenResponseDTO(
        String token
) {

    public static GetNewAccessTokenResponseDTO fromEntity(String token) {
        return new GetNewAccessTokenResponseDTO(
                token
        );
    }
}

 

GetNewTokenResponseDTO

 

 

 

 

 

 

POST    /access-token

을 통해서 Access Token을 성공적으로 재발급받을 수 있습니다.

 

 

 

 

Comments