Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/main/java/me/thinkcat/opic/practice/entity/RefreshToken.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,11 +17,22 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long

Optional<RefreshToken> findByToken(String token);

void deleteByToken(String token);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM RefreshToken r WHERE r.token = :token")
Optional<RefreshToken> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,32 @@ 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");
}

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);
Expand All @@ -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());
}
}
5 changes: 5 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}