diff --git a/src/main/java/me/thinkcat/opic/practice/entity/RefreshToken.java b/src/main/java/me/thinkcat/opic/practice/entity/RefreshToken.java index 0684241..1b72a01 100644 --- a/src/main/java/me/thinkcat/opic/practice/entity/RefreshToken.java +++ b/src/main/java/me/thinkcat/opic/practice/entity/RefreshToken.java @@ -27,6 +27,13 @@ public class RefreshToken { @Column(nullable = false) private LocalDateTime expiresAt; + @Builder.Default + @Column(nullable = false) + private boolean revoked = false; + + @Column + private LocalDateTime revokedAt; + @Column(nullable = false, updatable = false) private LocalDateTime createdAt; @@ -38,4 +45,9 @@ protected void onCreate() { public boolean isExpired() { return LocalDateTime.now().isAfter(expiresAt); } + + public void revoke() { + this.revoked = true; + this.revokedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/me/thinkcat/opic/practice/repository/RefreshTokenRepository.java b/src/main/java/me/thinkcat/opic/practice/repository/RefreshTokenRepository.java index 1c784e4..5a79bb8 100644 --- a/src/main/java/me/thinkcat/opic/practice/repository/RefreshTokenRepository.java +++ b/src/main/java/me/thinkcat/opic/practice/repository/RefreshTokenRepository.java @@ -1,7 +1,9 @@ package me.thinkcat.opic.practice.repository; +import jakarta.persistence.LockModeType; import me.thinkcat.opic.practice.entity.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -15,11 +17,22 @@ public interface RefreshTokenRepository extends JpaRepository findByToken(String token); - void deleteByToken(String token); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM RefreshToken r WHERE r.token = :token") + Optional findByTokenWithLock(@Param("token") String token); void deleteByUserId(Long userId); @Modifying - @Query("DELETE FROM RefreshToken r WHERE r.expiresAt < :now") - void deleteAllExpired(@Param("now") LocalDateTime now); + @Query("UPDATE RefreshToken r SET r.revoked = true, r.revokedAt = :now WHERE r.user.id = :userId") + void revokeAllByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now); + + @Modifying + @Query(""" + DELETE FROM RefreshToken r WHERE + (r.revoked = true AND r.revokedAt < :cutoff) + OR + (r.revoked = false AND r.expiresAt < :cutoff) + """) + void deleteOlderThan(@Param("cutoff") LocalDateTime cutoff); } diff --git a/src/main/java/me/thinkcat/opic/practice/scheduler/UserCleanupScheduler.java b/src/main/java/me/thinkcat/opic/practice/scheduler/UserCleanupScheduler.java index da129ea..7403530 100644 --- a/src/main/java/me/thinkcat/opic/practice/scheduler/UserCleanupScheduler.java +++ b/src/main/java/me/thinkcat/opic/practice/scheduler/UserCleanupScheduler.java @@ -5,6 +5,7 @@ import me.thinkcat.opic.practice.entity.User; import me.thinkcat.opic.practice.repository.RefreshTokenRepository; import me.thinkcat.opic.practice.repository.UserRepository; +import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +25,9 @@ public class UserCleanupScheduler { private final UserRepository userRepository; private final RefreshTokenRepository refreshTokenRepository; + @Value("${scheduler.refresh-token-cleanup.retention-days}") + private int refreshTokenRetentionDays; + @Scheduled(cron = "0 0 4 * * *") @Transactional public void hardDeleteWithdrawnUsers() { @@ -44,10 +48,11 @@ public void hardDeleteWithdrawnUsers() { log.info("[UserCleanup] Hard deleted {} withdrawn users.", targets.size()); } - @Scheduled(cron = "0 0 4 * * *") + @Scheduled(cron = "${scheduler.refresh-token-cleanup.cron}") @Transactional - public void cleanupExpiredRefreshTokens() { - refreshTokenRepository.deleteAllExpired(LocalDateTime.now()); - log.info("[UserCleanup] Expired refresh tokens deleted."); + public void cleanupOldRefreshTokens() { + LocalDateTime cutoff = LocalDateTime.now().minusDays(refreshTokenRetentionDays); + refreshTokenRepository.deleteOlderThan(cutoff); + log.info("[UserCleanup] Old refresh tokens deleted (cutoff={})", cutoff); } } diff --git a/src/main/java/me/thinkcat/opic/practice/service/RefreshTokenService.java b/src/main/java/me/thinkcat/opic/practice/service/RefreshTokenService.java index 1a459be..773d0be 100644 --- a/src/main/java/me/thinkcat/opic/practice/service/RefreshTokenService.java +++ b/src/main/java/me/thinkcat/opic/practice/service/RefreshTokenService.java @@ -38,14 +38,22 @@ public RefreshToken createRefreshToken(User user) { @Transactional public TokenResponse refreshTokens(String refreshTokenValue) { - RefreshToken refreshToken = refreshTokenRepository.findByToken(refreshTokenValue) + RefreshToken refreshToken = refreshTokenRepository.findByTokenWithLock(refreshTokenValue) .orElseThrow(() -> { log.warn("event=token_rotation_fail | reason=invalid_token"); return new UnauthorizedException("Invalid refresh token"); }); + if (refreshToken.isRevoked()) { + log.warn("event=token_reuse_detected | who={} | action=revoke_all", + refreshToken.getUser().getUsername()); + revokeAllByUser(refreshToken.getUser()); + throw new UnauthorizedException("Token reuse detected"); + } + if (refreshToken.isExpired()) { - refreshTokenRepository.delete(refreshToken); + refreshToken.revoke(); + refreshTokenRepository.save(refreshToken); log.warn("event=token_rotation_fail | who={} | reason=expired", refreshToken.getUser().getUsername()); throw new TokenExpiredException("Refresh token expired"); @@ -53,8 +61,9 @@ public TokenResponse refreshTokens(String refreshTokenValue) { User user = refreshToken.getUser(); - // Rotation: 기존 삭제 → 새로 발급 - refreshTokenRepository.delete(refreshToken); + // Rotation: soft delete 후 신규 발급 + refreshToken.revoke(); + refreshTokenRepository.save(refreshToken); String newAccessToken = jwtTokenProvider.generateAccessToken(user.getUsername(), user.getId(), user.getUserRole()); RefreshToken newRefreshToken = createRefreshToken(user); @@ -72,14 +81,15 @@ public TokenResponse refreshTokens(String refreshTokenValue) { public void revokeRefreshToken(String refreshTokenValue) { refreshTokenRepository.findByToken(refreshTokenValue) .ifPresent(token -> { + token.revoke(); + refreshTokenRepository.save(token); log.info("event=token_revoke | who={}", token.getUser().getUsername()); - refreshTokenRepository.delete(token); }); } @Transactional public void revokeAllByUser(User user) { log.info("event=token_revoke_all | who={}", user.getUsername()); - refreshTokenRepository.deleteByUserId(user.getId()); + refreshTokenRepository.revokeAllByUserId(user.getId(), LocalDateTime.now()); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4a7f8aa..80fb613 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,6 +14,11 @@ aws: internal: api-key: ${INTERNAL_API_KEY} +scheduler: + refresh-token-cleanup: + cron: "0 0 4 * * *" + retention-days: 90 + file: allowed-types: - audio/mpeg diff --git a/src/test/java/me/thinkcat/opic/practice/service/RefreshTokenServiceTest.java b/src/test/java/me/thinkcat/opic/practice/service/RefreshTokenServiceTest.java new file mode 100644 index 0000000..88646f1 --- /dev/null +++ b/src/test/java/me/thinkcat/opic/practice/service/RefreshTokenServiceTest.java @@ -0,0 +1,149 @@ +package me.thinkcat.opic.practice.service; + +import me.thinkcat.opic.practice.config.security.JwtTokenProvider; +import me.thinkcat.opic.practice.dto.response.TokenResponse; +import me.thinkcat.opic.practice.entity.RefreshToken; +import me.thinkcat.opic.practice.entity.User; +import me.thinkcat.opic.practice.exception.TokenExpiredException; +import me.thinkcat.opic.practice.exception.UnauthorizedException; +import me.thinkcat.opic.practice.repository.RefreshTokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class RefreshTokenServiceTest { + + @Mock private RefreshTokenRepository refreshTokenRepository; + @Mock private JwtTokenProvider jwtTokenProvider; + @InjectMocks private RefreshTokenService refreshTokenService; + + private User user; + + @BeforeEach + void setUp() { + user = User.builder().id(1L).username("testuser").build(); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private RefreshToken validToken() { + return RefreshToken.builder() + .id(1L).user(user).token("valid-token") + .expiresAt(LocalDateTime.now().plusDays(7)) + .build(); + } + + private RefreshToken expiredToken() { + return RefreshToken.builder() + .id(2L).user(user).token("expired-token") + .expiresAt(LocalDateTime.now().minusSeconds(1)) + .build(); + } + + private RefreshToken revokedToken() { + RefreshToken token = RefreshToken.builder() + .id(3L).user(user).token("revoked-token") + .expiresAt(LocalDateTime.now().plusDays(7)) + .build(); + token.revoke(); + return token; + } + + // ── refreshTokens ───────────────────────────────────────────────────────── + + @Test + void 정상_토큰으로_rotation시_기존_토큰_revoke_후_새_토큰_반환() { + RefreshToken existing = validToken(); + given(refreshTokenRepository.findByTokenWithLock("valid-token")).willReturn(Optional.of(existing)); + given(refreshTokenRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(jwtTokenProvider.generateAccessToken(any(), any(), any())).willReturn("new-access-token"); + given(jwtTokenProvider.generateRefreshToken(any(), any())).willReturn("new-refresh-token"); + given(jwtTokenProvider.getRefreshTokenValidityInMillis()).willReturn(604800000L); + given(jwtTokenProvider.getAccessTokenValidityInSeconds()).willReturn(3600L); + + TokenResponse response = refreshTokenService.refreshTokens("valid-token"); + + assertThat(existing.isRevoked()).isTrue(); + assertThat(existing.getRevokedAt()).isNotNull(); + assertThat(response.getAccessToken()).isEqualTo("new-access-token"); + assertThat(response.getRefreshToken()).isEqualTo("new-refresh-token"); + } + + @Test + void 존재하지_않는_토큰으로_rotation시_UnauthorizedException() { + given(refreshTokenRepository.findByTokenWithLock("unknown")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> refreshTokenService.refreshTokens("unknown")) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + void 이미_revoked된_토큰으로_rotation시_전체_무효화_후_UnauthorizedException() { + given(refreshTokenRepository.findByTokenWithLock("revoked-token")).willReturn(Optional.of(revokedToken())); + + assertThatThrownBy(() -> refreshTokenService.refreshTokens("revoked-token")) + .isInstanceOf(UnauthorizedException.class); + + then(refreshTokenRepository).should().revokeAllByUserId(eq(user.getId()), any(LocalDateTime.class)); + } + + @Test + void 만료된_토큰으로_rotation시_revoke_후_TokenExpiredException() { + RefreshToken expired = expiredToken(); + given(refreshTokenRepository.findByTokenWithLock("expired-token")).willReturn(Optional.of(expired)); + given(refreshTokenRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + assertThatThrownBy(() -> refreshTokenService.refreshTokens("expired-token")) + .isInstanceOf(TokenExpiredException.class); + + assertThat(expired.isRevoked()).isTrue(); + assertThat(expired.getRevokedAt()).isNotNull(); + } + + // ── revokeRefreshToken ─────────────────────────────────────────────────── + + @Test + void 토큰_revoke시_soft_delete_처리() { + RefreshToken token = validToken(); + given(refreshTokenRepository.findByToken("valid-token")).willReturn(Optional.of(token)); + given(refreshTokenRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + refreshTokenService.revokeRefreshToken("valid-token"); + + assertThat(token.isRevoked()).isTrue(); + assertThat(token.getRevokedAt()).isNotNull(); + } + + @Test + void 존재하지_않는_토큰_revoke시_아무_작업_없음() { + given(refreshTokenRepository.findByToken("unknown")).willReturn(Optional.empty()); + + refreshTokenService.revokeRefreshToken("unknown"); + + then(refreshTokenRepository).should(never()).save(any()); + } + + // ── revokeAllByUser ────────────────────────────────────────────────────── + + @Test + void 유저_전체_토큰_revoke시_revokeAllByUserId_호출() { + refreshTokenService.revokeAllByUser(user); + + then(refreshTokenRepository).should().revokeAllByUserId(eq(user.getId()), any(LocalDateTime.class)); + } +}