
전체 과정
클라이언트 요청 ↓ Access Token 만료 ↓ /auth/refresh 요청 (Refresh Token 전달) ↓ Refresh Token 검증 ├─ 서명/만료 검증 ├─ Redis 저장 여부 확인 ├─ 사용자 일치 여부 확인 ↓ 새 AT + (선택) 새 RefreshToken 발급 ↓ Redis RefreshToken 갱신
클라이언트가로그인시JWT Token을 발급하는 경우서버는Redis에Refresh Token를 저장
。Access Token 재발급 end point : /auth/refresh
。Access Token은프론트엔드 단에서Axios API를 통해인증 헤더의만료된 토큰을 교환하도록로직을 작성.
서버에서Refresh Token을Redis에 저장하는 이유
。NoSQL의Key - Value 자료구조로서인메모리 DB 방식으로 빠르게 접근
。인메모리 DB 서버이므로브라우저에 비해서버 사이드에 있으므로 탈취가능성이 낮음
。Refresh Token은비밀번호처럼 영구히 남는 데이터가 아니므로인메모리 DB를 통해 설사데이터가만료되더라도 큰 문제가 발생하지 않음.
사전 설정
redis 설치docker run --name redis-container -d -p 6379:6379 redis
Spring설정implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.redisson:redisson-spring-boot-starter:4.4.0'custom: redis: host: ${redis.host:localhost} port: ${redis.port:6379} datasource: hikari: maximum-pool-size: 100。
spring: datasource: hikari: maximum-pool-size는Auto Configuration를 통해 생성 및Spring Bean으로 등록될DataSource의Connection Pool내 최대준비될DB Connection 수를 지정
▶ 약 100개의스레드로 통한DB 트랜잭션을 각각 수행하므로 100으로 지정
。spring: data: redis의DB Connection 정보는RedisConfiguration에서RedisProperties를 통해의존성주입되어URI설정 시 활용됨
▶환경변수로 값 설정@Getter @Setter @ConfigurationProperties(prefix = "custom.redis") public class RedisProperties { private String host; private String port; }
Redis의Configuration file생성
。RedissonClient를 활용하기위해Spring Bean으로 등록
。RedissonClient에 설정될URI정보는RedisProperties 객체를 통해의존성 주입후application.yml내spring: data: redis에서 작성한host,port정보를 읽어와서URI로 작성 후 입력
▶redis://localhost:6379
▶RedisConnectionFactory도 동일
。각각 다른Redis DB를 사용하도록 설정@Configuration @RequiredArgsConstructor public class RedisConfiguration { // application.yml 에서 redis 관련 설정정보 가져오는 객체 private final RedisProperties redisProperties; // RedissonClient 를 Spring Bean으로 등록 // 이후 분산락에서 사용 @Bean public RedissonClient redisClient() { Config config = new Config(); // URI 설정 String uri = String.format("redis://%s:%s", redisProperties.getHost(), redisProperties.getPort()); config.useSingleServer().setAddress(uri); return Redisson.create(config); } // RedisConnectionFactory 를 return @Bean @Primary public RedisConnectionFactory authRedisConnectionFactory() { RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort()); config.setDatabase(1); // 인증 토큰용 return new LettuceConnectionFactory(config); } @Bean public RedisConnectionFactory cacheRedisConnectionFactory() { RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort()); config.setDatabase(2); // 캐시용 return new LettuceConnectionFactory(config); } @Bean @Qualifier("cache") public RedisTemplate<String,String> cacheRedisTemplate(){ RedisTemplate<String, String> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(cacheRedisConnectionFactory()); return redisTemplate; } }。
인증 토큰 용 Redis는1번 DB,캐싱 용 Redis는2번 DB를 사용하도록 설정
。Redis 캐싱을 사용하는 경우Redis에서 각각 다른DB(0 ~ 15)를 사용하도록 구성
▶@RedisHash는@Primary가 선언된RedisConnectionFactory 객체를 우선적으로주입되어 해당RedisConnectionFactory에 설정된DB에서 수행
Redis에발급한Refresh Token저장할Entity를 선언
。선언된Entity Class구조대로Redis에 저장됨.import org.springframework.data.redis.core.RedisHash; import org.springframework.data.annotation.Id; @RedisHash(value = "refreshToken", timeToLive = 604800) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @ToString @Builder public class RefreshToken { @Id private String refreshToken; private String email; public RefreshToken(String refreshToken, String email){ this.refreshToken = refreshToken; this.email = email; } }
@RedisHash(value = "KeyNamespace명", timeToLive = TTL(Sec))
。Redis Lettuce를사용하여Redis에 저장할Entity임을 지시하는어노테이션
。value = "KeyNamespace명" 속성:Redis에서Key namespace가 설정
Redis에서 저장되는 경우
。value = "resfreshToken",@Id 필드:abc123,email 필드:wjdtn747@naver.com인 경우// key refreshToken:abc123 : // value { "@Id필드명":"abc123", "email":"wjdtn747@naver.com" }▶ 다음 형식으로 저장됨.
。Refresh Token저장 시key:{키명}:{RefreshToken}/value:{ "refreshToken" : "토큰값" }형식으로 저장
▶ 이후RefreshToken를 기반으로refreshTokenRepository.findById(refreshToken)를 통해 해당Redis DB의Key Namespace내부에value : { refreshToken:{토큰값} , ... }을 가진Key가 있는지 확인 후 화이트리스트 방식을 수행
。@RedisHash(timeToLive = 604800)를 지정하여Refresh Token의TTL을 설정
▶ 해당refreshToken의Key명으로Entity를 통해 저장 시7일이수명으로 설정
@Id
。선언된필드가Redis Key 값으로 설정됨
▶null인 경우랜덤값으로 설정
。@Id 필드는RefreshToken로 설정
▶ 이후 재발급 시클라이언트에서 전송한쿠키에 포함된RefreshToken를 통해 해당RefreshToken을 찾아야하므로.
。JPA의@Id가 아닌import org.springframework.data.annotation.Id;의@Id를 선언해야한다.
。@RedisHash(value = "keynamespace")와@Id Long id의필드값이"id"인 경우
▶Redis 자료구조에서는keynamespace:id로키값으로 저장
RefreshToken Repository생성
。CrudRepository<>를 확장하여 생성
▶JpaRepository<>를 선언 시JPA와Redis가 동시에 해당Entity를 참조하려 하면서 오류 발생하므로@Repository public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> { default RefreshToken findByIdOrThrow(String refreshToken){ return findById(refreshToken).orElseThrow( ()-> new BusinessException(ErrorCode.REFRESH_TOKEN_EXPIRED) ); } }。
클라이언트에서 전송한RefreshToken을 통해@ID 필드를 기반으로Redis에서 저장된RefreshToken을 찾는메서드작성
▶RefreshToken 클래스의@Id 필드 = RefreshToken인 이유
JWT 발급을 하는 단계에서 다음RefreshToken Repository를 통해Redis에 저장하는코드추가private String issueRefreshToken( String email ){ String issuedRefreshToken = Jwts.builder() .subject(jwtProperties.getPayload().getSubjectRefreshToken()) .claim("email", email) .issuedAt(new Date()) .expiration(new Date(new Date().getTime() + jwtProperties.getValidations().getRefresh())) .signWith(getSecretKey()) .compact(); refreshTokenRepository.save( RefreshToken.builder() .refreshToken(issuedRefreshToken) .email(email) .build() ); return issuedRefreshToken; }。이후
로그인하여JWT 토큰생성 및Redis에서 조회 시 다음처럼 저장됨을 확인가능.
。refreshToken:wjdtn747@naver.com:
▶refreshToken:@RedisHash(value="refreshToken")의접두사
▶wjdtn747@naver.com:RefreshToken 클래스에서@Id 필드에 해당
。Refresh Token은String이 아니므로 출력되지 않음.
▶HGETALL 키명입력 시 출력
ACCESS TOKEN재발급
。클라이언트가만료된Access Token을 전송 시401 UnAthorize를 반환하며, 이에 대응하여클라이언트는/api/auth/refresh를 호출
。 JWT 로그인 로직 작성에서 구현한로직에 따라Cookie에 포함된Refresh Token을 가져오는로직사용
서비스 레이어에서Refresh Token을 추출하는메서드작성
。Cookie에서 찾은RefreshToken를 기반으로Redis DB에 저장된RefreshToken을 검색 후 존재 시Access Token을 재발급 후컨트롤러로 반환
▶ 화이트리스트 방식public KeyPair refreshToken(Cookie cookie){ String receivedRT = cookie.getAttribute(REFRESH_TOKEN_NAME); // NPE 검증 PreConditions.validate( Strings.isNotBlank(receivedRT), ErrorCode.TOKEN_NOT_FOUND ); // 토큰 유효성 검증 tokenProvider.validate(receivedRT); // 토큰 화이트리스트 검증 RefreshToken foundedRT = refreshTokenRepository.findById(receivedRT) .orElseThrow( () -> new BusinessException(ErrorCode.REFRESH_TOKEN_EXPIRED) ); // 토큰 재발급 refreshTokenRepository.delete(foundedRT); User foundedUser = userRepository.findByEmailOrThrow(foundedRT.getEmail()); return tokenProvider.issueKeyPair( foundedUser.getEmail(), foundedUser.getRole() ); }
컨트롤러 레이어에서HttpRequest를 받아서서비스레이어에서 생성한Access Token을 반환하는로직작성
。HttpRequest에서Cookie를 추출하는메서드작성
▶ JWT 로그인 로직에서 설정한Cookie명을 활용하여 해당Refresh Token을 찾는다.private static final String cookieName = "RT"; @PostMapping("/refresh") public ResponseEntity<ApiResultResponse<AuthResponse.AccessToken>> refresh( HttpServletRequest request ){ String refreshToken = resolveRefreshToken(request); String accessToken = authService.generateAccessToken(refreshToken); return ApiResultResponse.data( SuccessCode.ACCESS_TOKEN_REFRESH_SUCCESS, new AuthResponse.AccessToken(accessToken) ); } public String resolveRefreshToken(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if( cookies != null ){ for(Cookie cookie : cookies){ if(cookieName.equals(cookie.getName())){ return cookie.getValue(); } } } return null; }▶
클라이언트가Access Token이 만료되더라도브라우저에서RefreshToken을 가진 유효한Cookie를 포함하는 경우, 해당API를 호출하여AccessToken을 발급받을 수 있다.
- JWT 필터에서 해당
/api/auth/refresh를 거치지않도록 작성하기@Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain chain ) throws ServletException, IOException{ String uri = request.getRequestURI(); // if (uri.startsWith("/api/auth/")) { chain.doFilter(request, response); return; }
▶ 이후RefreshToken을 포함한쿠키를 기반으로Access Token이 새로 생성됨을 확인 가능