지난번에 작성했던, [생각정리] JWT의 Stateless 고찰에서 언급한 바와 같이, JWT의 Stateless한 특성에 집착하지 않기로 했다. 이 결정을 바탕으로, 프로젝트에 Redis를 활용한 Refresh Token Rotation 방식을 도입하였다. 또한 프로젝트 토큰 관리 전략도 개선 하였다. 이번 글에서 해당 내용들을 소개하도록 하겠다.
Refresh Token Rotation은 보안 강화를 위한 추가 조치로, 사용자가 인증을 갱신할 때마다 Refresh Token도 교체하는 방식이다. 이 방법은 Refresh Token의 수명을 최소화하고, 재사용을 방지하여 보안성을 높인다. 자세한 내용은 아래 게시글을 참고하는 것을 추천한다.
구글과 같은 서비스도 계속해서 사이트를 방문하면 일주일 ~ 한달 동안 로그인이 끊어지지 않지만, 이틀 정도 방문하지 않으면 세션이 만료되며 로그아웃이 된다. Rotation을 통한 지속적인 로그인을 구현했기 때문이다.
Refresh Token Rotation을 구현한 코드를 보며 설명하겠다. 적용한 프로젝트의 링크를 아래에 첨부하겠다.
@Transactional
public SignInResponse signIn(SignInRequest req) {
Member member = memberRepository.findWithRolesByEmail(req.getEmail()).orElseThrow(LoginFailureException::new);
validatePassword(req, member);
TokenHandler.PrivateClaims privateClaims = createPrivateClaims(member);
String accessToken = userAccessTokenHandler.createToken(privateClaims);
String refreshToken = userRefreshTokenHandler.createToken(privateClaims);
redisHandler.setValues(String.valueOf(member.getId()), refreshToken);
return new SignInResponse(accessToken, refreshToken);
}
사용자가 로그인을 요청할 때, 사용자 ID를 사용하여 Refresh Token을 Redis에 저장한다.
- 보안 강화: 서버 측에서 Refresh Token을 관리함으로써, 만약 토큰이 탈취되더라도 탈취된 토큰을 통한 액세스를 제한할 수 있다.
- 토큰 관리: 서버에서 Refresh Token의 유효성을 주기적으로 확인하고, 필요한 경우 갱신하거나 무효화할 수 있다.
@Transactional
public UserRefreshTokenResponse refreshToken(String userRefreshToken) {
TokenHandler.PrivateClaims userClaims = userRefreshTokenHandler.parse(userRefreshToken)
.orElseThrow(RefreshTokenFailureException::new);
String storedToken = Optional.ofNullable(redisHandler.getValues(userClaims.getId()))
.orElseThrow(RefreshTokenFailureException::new);
if (!storedToken.equals(userRefreshToken)) {
redisHandler.deleteValues(String.valueOf(userClaims.getId()));
throw new RefreshTokenFailureException();
}
String newUserAccessToken = userAccessTokenHandler.createToken(userClaims);
String newUserRefreshToken = userRefreshTokenHandler.createToken(userClaims);
redisHandler.setValues(String.valueOf(userClaims.getId()), newUserRefreshToken);
return new UserRefreshTokenResponse(newUserAccessToken, newUserRefreshToken);
}
Refresh Token 갱신 요청 시, 저장된 Refresh Token과 요청 받은 Token을 비교한다. 불일치하거나 없는 경우, 오류를 반환하고 저장된 Token을 삭제한다. 문제가 없는 경우에는 Access Token과 함께 Refresh Token도 갱신한다.
- 재사용 방지: 이미 사용되었거나 무효화된 Refresh Token의 재사용을 방지하여, Replay Attack과 같은 보안 위협으로부터 보호한다.
- 보안 위반 감지: 요청된 Refresh Token과 저장된 Token이 일치하지 않는 경우, 이는 보안 위반이 발생했을 가능성을 의미한다.
- 보안성 향상: 각 인증 갱신마다 새로운 Refresh Token을 발급함으로써, 토큰 탈취 시 공격자가 오래된 토큰을 사용하는 것을 방지한다.
- 토큰 수명 관리: Refresh Token의 수명을 짧게 유지함으로써, 잠재적 보안 위험을 최소화한다.
@Transactional
public void delete(Long id) {
Member member = memberRepository.findById(id).orElseThrow(MemberNotFoundException::new);
Boolean exists = redisHandler.exists(String.valueOf(id));
if (exists != null && exists) {
redisHandler.deleteValues(String.valueOf(id));
}
memberRepository.delete(member);
}
사용자가 계정을 삭제할 경우, 관련된 Refresh Token도 함께 삭제한다.
- 데이터 일관성: 사용자 데이터와 관련된 모든 정보를 삭제함으로써 데이터의 일관성을 유지한다.
- 보안 위험 제거: 탈퇴한 사용자의 토큰을 계속해서 저장하고 관리하는 것은 불필요한 보안 위험을 만든다.
프로젝트의 토큰 구조는 아래와 같다.