
전체 과정
클라이언트 요청 ↓ Access Token 만료 ↓ /auth/refresh 요청 (Refresh Token 전달) ↓ Refresh Token 검증 ├─ 서명/만료 검증 ├─ Redis 저장 여부 확인 ├─ 사용자 일치 여부 확인 ↓ 새 AT + (선택) 새 RefreshToken 발급 ↓ Redis RefreshToken 갱신
클라이언트가로그인시JWT Token을 발급하는 경우서버는Redis에Refresh Token를 저장
。Access Token 재발급 end point : /auth/refresh
서버에서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.redisson:redisson-spring-boot-starter:3.32.0' implementation 'org.springframework.boot:spring-boot-starter-data-redis'spring: data: redis: host: ${redis.host:localhost} port: ${redis.host:6379} datasource: hikari: maximum-pool-size: 100。
spring: datasource: hikari: maximum-pool-size는 최대DB Connection 수를 지정
▶ 약 100개의스레드로 통한DB 트랜잭션을 각각 수행하므로 100으로 지정
。spring: data: redis의 설정정보는RedisConfiguration에서RedisProperties를 통해의존성주입되어URI설정 시 활용됨
▶환경변수로 값 설정
Redis의Configuration file생성
。RedissonClient를 활용하기위해Spring Bean으로 등록
。RedissonClient에 설정될URI정보는RedisProperties 객체를 통해의존성 주입후application.yml내spring: data: redis에서 작성한host,port정보를 읽어와서URI로 작성 후 입력
▶redis://localhost:6379
▶RedisConnectionFactory도 동일@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 public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(redisProperties.getHost(),redisProperties.getPort()); } }▶
Redis의Java Client는Lettuce와Jedis가 존재하는데Lettuce를 사용.
Redis에발급한Refresh Token저장할Entity를 선언
。선언된Entity Class구조대로Redis에 저장됨.import org.springframework.data.redis.core.RedisHash; import org.springframework.data.annotation.Id; @RedisHash(value = "refreshToken", timeToLive = 43200) // 12시간 @NoArgsConstructor @Getter @ToString public class RefreshToken { @Id private String refreshToken; private UUID id; private String userId; public RefreshToken(String refreshToken, UUID id, String userId) { this.refreshToken = refreshToken; this.id = id; this.userId = userId; } }
@RedisHash
。Redis Lettuce를사용하여Redis에 저장할자료구조임을 지시하는어노테이션
。value = "키":Redis에서 해당자료의Key namespace가 된다.
▶Redis DB에 저장 시Key : {value값}:{@Id필드값}으로 지정됨.
@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 findByRefreshTokenByIdOrThrow(String refreshToken) { return findById(refreshToken).orElseThrow(()->new CustomException(ErrorCode.TOKEN_NOT_FOUND)); } }。
클라이언트에서 전송한RefreshToken을 기반으로Redis에서 저장된RefreshToken을 찾는메서드작성
▶RefreshToken 클래스의@Id 필드 = RefreshToken인 이유
JWT 발급은 위해 로그인을 하는 단계에서 다음RefreshToken Repository를 통해Redis에 저장하는코드추가private final RefreshTokenRepository refreshTokenRepository; @Override public Pair<String,String> LoginAccount(AccountRequest.Login request) { Account foundedAccount = accountRepository.findByEmailOrThrow(request.email()); // PreConditions.validate( // passwordEncoder.matches(request.password(),foundedAccount.getPassword()), ErrorCode.FAIL_LOGIN ); // PreConditions.validate( // foundedAccount.getStatus().equals(AccountStatus.ACTIVATED), ErrorCode.REMOVED_ACCOUNT ); // String accessToken = jwtService.issue( foundedAccount.getId(), // pojoJwtProperties.getJwt().accessTokenExpiration() ); // String refreshToken = jwtService.issue( foundedAccount.getId(), // pojoJwtProperties.getJwt().refreshTokenExpiration() ); // refreshTokenRepository.save( new RefreshToken( refreshToken, foundedAccount.getId(), foundedAccount.getEmail() ) ); return Pair.of(accessToken, refreshToken); }。이후
로그인하여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을 재발급 후컨트롤러로 반환@Override public String generateAccessToken(String refreshToken) { PreConditions.validate( Strings.isNotBlank(refreshToken), ErrorCode.REFRESH_TOKEN_NOT_FOUND ); // Client에서 전송한 RefreshToken으로 Redis DB에 저장된 RefreshToken 조회 RefreshToken foundedRefreshToken = refreshTokenRepository .findByRefreshTokenByIdOrThrow(refreshToken); // return jwtService.issue( foundedRefreshToken.getId(), pojoJwtProperties.getJwt().accessTokenExpiration() ); }
컨트롤러 레이어에서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 = resolveToken(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를 호출하여RefreshToken을 발급받을 수 있다.
- 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이 새로 생성됨을 확인 가능