본문 바로가기

백엔드 개발일지

[Spring] Redis를 통한 Refresh Token 도입기 (2)

본 글에서는 기본적인 Redis 설정이 되어있다는 전제 하에 코드 위주로 설명합니다. 전체적인 리프레시 토큰 로직에 대해 참고하고 싶다면 이전 글을 확인해주세요. 

자세한 코드는 https://github.com/mocacong/Mocacong-Backend/pull/131 에서 확인할 수 있습니다.

 

1. Token 객체

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Token {

    @Id
    private Long id;

    private String refreshToken;

    private String accessToken;

    @TimeToLive(unit = TimeUnit.MILLISECONDS) // 디폴트는 TimeUnit.SECONDS
    private long expiration;

    public void setAccessToken(String newAccessToken) {
        this.accessToken = newAccessToken;
    }

    public static String createRefreshToken() {
        return UUID.randomUUID().toString();
    }
}

 

액세스 토큰을 재발급하기 위한 리프레시 토큰은 레디스에서 관리되어야 합니다. 레디스에서 관리하기 위한 Token 객체를 정의합니다.

 

리프레시 토큰의 유효기간은 Token 객체의 expiration 필드에서 관리됩니다.

@TimeToLive 어노테이션은 유효시간이 지나면 레디스에서 자동으로 삭제될 수 있게 합니다. @TimeToLive의 디폴트 값은 초 단위인데 모카콩에서는 액세스 토큰의 유효시간을 밀리세컨드 단위로 관리하고 있어 리프레시 토큰도 유효시간을 밀리세컨드 단위로 설정합니다.

 

Token 객체 안에서 리프레시 토큰을 생성하는 createRefreshToken() 메서드 또한 만들어줍니다.

jwt 방식으로 만든 액세스 토큰과 달리 리프레시 토큰은 랜덤한 UUID로 만든 문자열을 사용합니다.

만약 리프레시 토큰을 jwt 방식으로 생성했을 경우, 토큰이 탈취당했을 때 해당 토큰을 무효화할 방법이 제한적입니다. 이에 대비하여 랜덤한 UUID로 생성된 리프레시 토큰을 사용하고, 이를 사용자와 매핑하여 레디스에 안전하게 저장합니다.

 

 

 

 

 

2. RefreshToken Service

@Service
public class RefreshTokenService {

    private final long validityRefreshTokenInMilliseconds; // 리프레시 토큰 유효시간
    private final MemberRepository memberRepository;
    private final RedisTemplate<String, Object> redisTemplate;

    public RefreshTokenService(@Value("${security.jwt.token.refresh-key-expire-length}")
                            long validityRefreshTokenInMilliseconds,
                               MemberRepository memberRepository,
                               RedisTemplate<String, Object> redisTemplate) {
        this.validityRefreshTokenInMilliseconds = validityRefreshTokenInMilliseconds;
        this.memberRepository = memberRepository;
        this.redisTemplate = redisTemplate;
    }
	
    // 레디스에 저장할 토큰 객체
    public void saveTokenInfo(Long memberId, String refreshToken, String accessToken) {
        Token token = Token.builder()
                .id(memberId)
                .refreshToken(refreshToken) // 유저의 리프레시 토큰
                .accessToken(accessToken) // 유저의 액세스 토큰
                .expiration(validityRefreshTokenInMilliseconds) // 리프레시 토큰 유효기간
                .build();
		
        // 리프레시 토큰을 key, 나머지 정보들을 value로 저장
        redisTemplate.opsForValue().set(refreshToken, token, validityRefreshTokenInMilliseconds, TimeUnit.MILLISECONDS);
    }


    // 리프레시 토큰으로부터 회원정보(id)확인
    public Member getMemberFromRefreshToken(String refreshToken) {
        Token token = findTokenByRefreshToken(refreshToken);
        if (token.getExpiration() > 0) {
            Long memberId = token.getId();
            return memberRepository.findById(memberId)
                    .orElseThrow(NotFoundMemberException::new);
        }
        throw new InvalidRefreshTokenException();
    }

    public Token findTokenByRefreshToken(String refreshToken) {
        Token token = (Token) redisTemplate.opsForValue().get(refreshToken);
        if (token != null) {
            return token;
        }
        throw new InvalidRefreshTokenException();
    }

    public void updateToken(Token token) {
        redisTemplate.opsForValue().set(token.getRefreshToken(), token, token.getExpiration(), TimeUnit.MILLISECONDS);
    }
}

 

RefreshTokenService 클래스에서는 레디스에서 리프레시를 관리하는 로직을 구현했습니다.

각 주요 메서드가 하는 기능은 다음과 같습니다.

 

① saveTokenInfo(Long memberId, String refreshToken, String accessToken)

토큰 객체를 레디스에 저장하는 기능을 수행합니다. 이때 토큰에는 정당한 사용자가 요청을 보냈는지 확인하기 위한 정보로 memberId를 넣고 그 외에 리프레시 토큰, 액세스 토큰, 리프레시 토큰 유효시간 정보를 담습니다. 그리고 해당 토큰 객체는 RedisTemplate을 통해 레디스에 저장합니다.

 

 

② getMemberFromRefreshToken(String refreshToken)

리프레시 토큰을 사용하여 레디스에 저장된 토큰 객체를 찾습니다. 그 다음 유효한 회원이 보낸 것인지 확인하기 위해 검증하는 절차를 수행합니다.

 

 

③ updateToken(Token token)

리프레시 토큰을 통해 새로운 액세스 토큰을 발급했다면 레디스에 해당 변경사항을 저장해야 하는데, 이 역할을 수행하는 메서드입니다. 모카콩에서는 기존 토큰 객체에서 액세스 토큰 값만 새로운 액세스 토큰 값으로 대체하여 저장했습니다.

 

 

 

 

 

 

3. Auth

1) AuthController

@Operation(summary = "토큰 재발급")
@PostMapping("/reissue")
public ResponseEntity<ReissueTokenResponse> refreshAccessToken(@RequestBody @Valid RefreshTokenRequest request) {
    ReissueTokenResponse response = authService.reissueAccessToken(request);
    return ResponseEntity.ok(response);
}
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@ToString
public class RefreshTokenRequest {
    private String refreshToken;
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ReissueTokenResponse {
    private String accessToken;

    public static ReissueTokenResponse from(final String accessToken) {
        return new ReissueTokenResponse(accessToken);
    }
}

 

액세스 토큰 재발급을 위한 API를 새로 선언합니다. 해당 API에서는 클라이언트로부터 리프레시 토큰을 받았다면 유효성 검사을 수행 후, 액세스 토큰을 재발급하는 기능을 수행합니다.

그리고 기존에 존재하던 로그인 API 응답에는 AccessToken과 RefreshToken 모두 포함되도록 수정합니다.

 

 

2) AuthService

① 로그인 기능에서의 리프레시 토큰 로직

// 로그인
public TokenResponse login(AuthLoginRequest request) {
	Member findMember = memberRepository.findByEmailAndPlatform(request.getEmail(), Platform.MOCACONG)
			.orElseThrow(NotFoundMemberException::new);
	// ...
	String accessToken = issueAccessToken(findMember);
	String refreshToken = issueRefreshToken();

	// Redis에 refresh 토큰 저장 (사용자 기본키 Id, refresh 토큰, access 토큰)
	refreshTokenService.saveTokenInfo(findMember.getId(), refreshToken, accessToken);

	return TokenResponse.from(accessToken, refreshToken);
}
private String issueRefreshToken() {
	return Token.createRefreshToken();
}

 

기존에 로그인 기능을 수행하는 메서드에서 액세스 토큰과 리프레시 토큰을 함께 발행하도록 합니다. 저는 이전에 토큰 객체에서 미리 선언해둔 createRefreshToken() 메서드를 이용했습니다.

최종적으로 리프레시 토큰 정보를 포함한 토큰 객체를 레디스에 저장합니다.

 

 

② AccessToken 재발급 메서드

@Transactional
public ReissueTokenResponse reissueAccessToken(RefreshTokenRequest request) {
	String refreshToken = request.getRefreshToken();
	Member member = refreshTokenService.getMemberFromRefreshToken(refreshToken);
	Token token = refreshTokenService.findTokenByRefreshToken(refreshToken);
	String oldAccessToken = token.getAccessToken();

	// 이전에 발급된 액세스 토큰이 만료가 되어야 새로운 액세스 토큰 발급
	if (jwtTokenProvider.isExpiredAccessToken(oldAccessToken)) {
		String newAccessToken = issueAccessToken(member);
		token.setAccessToken(newAccessToken);
		refreshTokenService.updateToken(token); // 새로운 정보로 업데이트
		return ReissueTokenResponse.from(newAccessToken, member.getReportCount());
	}
	throw new NotExpiredAccessTokenException();
}

 

해당 메서드는 토큰 재발급 API를 호출 시, 액세스 토큰을 재발급하는 기능을 수행합니다.

RequestBody에 담긴 리프레시 토큰을 통해 레디스 서버에서 이전에 발급된 액세스 토큰을 찾습니다.

여기서 액세스 토큰이 만료되지 않았다면 예외를 발생시키고, 만료되었다면 새로운 액세스 토큰을 재발급합니다.

새로운 액세스 토큰을 재발급했다면 사용자의 리프레시 토큰을 키로 하는 기존 토큰 객체에서 만료된 액세스 토큰의 값을 새로운 액세스 토큰으로 교체하고, 이를 레디스에 저장합니다.

 

 

 

 

4. Redis-cli를 통한 동작 확인

해당 기능이 잘 수행되는지 확인하기 위해 Redis-cli를 통해 동작을 확인했습니다.

 

① 로그인 수행

 

위의 사진을 보면 로그인 수행 시, 액세스 토큰과 리프레시 토큰 둘다 재발급하고 있음을 알 수 있습니다.

오른쪽 터미널 창은 redis-cli를 실행한 화면입니다. 여기서 레디스에 저장된 키를 확인하면 응답으로 받은 리프레시 토큰의 값과 동일함을 알 수 있습니다.

 

 

토큰 객체에 저장된 값 살펴보기

 

`get [키 (리프레시 토큰)]` 명령어를 통해 해당 토큰 객체에 저장된 값을 살펴보았습니다.

RefreshTokenService에서 설정한 대로 회원 고유 id, 리프레시 토큰, 액세스 토큰, 만료기간 정보가 잘 저장되었음을 확인할 수 있습니다.

 

 

③ AccessToken 재발급 요청

 

 

`① 로그인 수행` 단계에서 발급된 액세스 토큰이 만료된 후, 리프레시 토큰을 사용하여 새로운 액세스 토큰을 재발급했습니다.

이때 redis-cli 창을 보면 기존 액세스 토큰 값(연두색 줄)이 새로 발급된 액세스 토큰 값(빨간색 줄)으로 변경되었음을 알 수 있습니다.