From 0bbefe6c4f5acc01e8ebc519d81ff6615ba40eb0 Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Wed, 12 Aug 2026 17:57:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20redis=20token=20rotate=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20Testcontainers=EB=A1=9C=20RTR=EA=B3=BC=20TTL=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B2=80=EC=A6=9D=20-=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=ED=9A=8C=EC=A0=84=20=EC=8B=9C=20=EB=8B=A8=EC=9D=BC=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20=ED=86=A0=ED=81=B0=EB=A7=8C=20=EC=B1=84=ED=83=9D?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EC=9B=90=EC=9E=90=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20-=20=EC=9C=A0=EC=98=88=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EA=B0=90=EC=A7=80=EC=99=80=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auth/build.gradle | 3 + ...RefreshTokenStoreRedisIntegrationTest.java | 261 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreRedisIntegrationTest.java diff --git a/auth/build.gradle b/auth/build.gradle index b08238a..cef0b18 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -8,4 +8,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' + + testImplementation 'org.testcontainers:testcontainers:2.0.5' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter:2.0.5' } diff --git a/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreRedisIntegrationTest.java b/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreRedisIntegrationTest.java new file mode 100644 index 0000000..6b5df9b --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreRedisIntegrationTest.java @@ -0,0 +1,261 @@ +package com.nalssilog.auth.token; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import com.nalssilog.auth.token.RefreshTokenStore.RotationResult; +import com.nalssilog.auth.token.RefreshTokenStore.RotationStatus; +import com.nalssilog.auth.token.RefreshTokenStore.UsedToken; +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.IntStream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@SuppressWarnings("java:S5960") +@Testcontainers +class RefreshTokenStoreRedisIntegrationTest { + + private static final int REDIS_PORT = 6379; + private static final String REFRESH_PREFIX = "auth:refresh:"; + private static final String USED_PREFIX = "auth:refresh-used:"; + private static final String RETRY_PREFIX = "auth:refresh-retry:"; + private static final String MEMBER_SESSIONS_PREFIX = "auth:member-sessions:"; + + @Container + private static final GenericContainer REDIS = new GenericContainer<>( + DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(REDIS_PORT); + + private static LettuceConnectionFactory connectionFactory; + private static StringRedisTemplate redisTemplate; + + private RefreshTokenStore store; + + @BeforeAll + static void connectToRedis() { + RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration( + REDIS.getHost(), REDIS.getMappedPort(REDIS_PORT)); + connectionFactory = new LettuceConnectionFactory(configuration); + connectionFactory.afterPropertiesSet(); + + redisTemplate = new StringRedisTemplate(connectionFactory); + redisTemplate.afterPropertiesSet(); + } + + @AfterAll + static void disconnectFromRedis() { + connectionFactory.destroy(); + } + + @BeforeEach + void setUp() { + redisTemplate.execute((RedisCallback) connection -> { + connection.serverCommands().flushDb(); + + return null; + }); + store = new RefreshTokenStore(redisTemplate); + } + + @Test + void rotatesTokenAndPersistsReplacementTombstoneAndTtlsAtomically() { + Duration tokenTtl = Duration.ofMinutes(5); + Duration retryGrace = Duration.ofSeconds(5); + long memberId = 1L; + String sessionId = unique("session"); + SessionData current = session(unique("current"), sessionId, memberId); + SessionData replacement = session(unique("replacement"), sessionId, memberId); + String replacementToken = unique("replacement-token"); + store.save(current.tokenHash(), current, tokenTtl); + + RotationResult result = store.rotate( + current.tokenHash(), replacementToken, replacement, tokenTtl, retryGrace); + + assertThat(result.status()).isEqualTo(RotationStatus.ROTATED); + assertThat(result.replacementToken()).isEqualTo(replacementToken); + assertThat(result.replacementHash()).isEqualTo(replacement.tokenHash()); + assertThat(result.memberId()).isEqualTo(memberId); + assertThat(result.sessionId()).isEqualTo(sessionId); + assertThat(result.refreshTokenTtlMillis()).isEqualTo(tokenTtl.toMillis()); + + assertThat(store.findSession(current.tokenHash())).isEmpty(); + assertThat(store.findSession(replacement.tokenHash())).contains(replacement); + assertThat(store.findSessionsByMember(memberId)).containsExactly(replacement); + + UsedToken used = store.findUsedToken(current.tokenHash()).orElseThrow(); + assertThat(used.memberId()).isEqualTo(memberId); + assertThat(used.sessionId()).isEqualTo(sessionId); + assertThat(used.replacementHash()).isEqualTo(replacement.tokenHash()); + assertThat(redisTemplate.opsForValue().get(RETRY_PREFIX + current.tokenHash())) + .isEqualTo(replacementToken); + + assertTtlWithin(REFRESH_PREFIX + replacement.tokenHash(), tokenTtl); + assertTtlWithin(USED_PREFIX + current.tokenHash(), tokenTtl); + assertTtlWithin(RETRY_PREFIX + current.tokenHash(), retryGrace); + assertTtlWithin(MEMBER_SESSIONS_PREFIX + memberId, tokenTtl); + } + + @Test + void returnsOnlyOneReplacementWhenTheSameTokenIsRotatedConcurrently() throws Exception { + int requestCount = 16; + Duration tokenTtl = Duration.ofMinutes(5); + Duration retryGrace = Duration.ofSeconds(5); + long memberId = 2L; + String sessionId = unique("session"); + SessionData current = session(unique("current"), sessionId, memberId); + List candidates = IntStream.range(0, requestCount) + .mapToObj(index -> new RotationCandidate( + unique("replacement-token-" + index), + session(unique("replacement-" + index), sessionId, memberId))) + .toList(); + CountDownLatch ready = new CountDownLatch(requestCount); + CountDownLatch start = new CountDownLatch(1); + store.save(current.tokenHash(), current, tokenTtl); + + List results; + + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = candidates.stream() + .map(candidate -> executor.submit(() -> { + ready.countDown(); + + if (!start.await(5, SECONDS)) { + throw new IllegalStateException("concurrent rotation start timed out"); + } + + return store.rotate( + current.tokenHash(), + candidate.token(), + candidate.session(), + tokenTtl, + retryGrace); + })) + .toList(); + + boolean allRequestsReady = ready.await(5, SECONDS); + start.countDown(); + assertThat(allRequestsReady).isTrue(); + + results = futures.stream() + .map(RefreshTokenStoreRedisIntegrationTest::getResult) + .toList(); + } + + assertThat(results).filteredOn(result -> result.status() == RotationStatus.ROTATED).hasSize(1); + assertThat(results).filteredOn(result -> result.status() == RotationStatus.RETRIED) + .hasSize(requestCount - 1); + + RotationResult winner = results.stream() + .filter(result -> result.status() == RotationStatus.ROTATED) + .findFirst() + .orElseThrow(); + assertThat(results).extracting(RotationResult::replacementToken) + .containsOnly(winner.replacementToken()); + assertThat(results).extracting(RotationResult::replacementHash) + .containsOnly(winner.replacementHash()); + assertThat(store.findSession(winner.replacementHash())).isPresent(); + assertThat(store.findSessionsByMember(memberId)) + .extracting(SessionData::tokenHash) + .containsExactly(winner.replacementHash()); + assertThat(candidates) + .filteredOn(candidate -> !candidate.session().tokenHash().equals(winner.replacementHash())) + .allSatisfy(candidate -> assertThat(store.findSession(candidate.session().tokenHash())).isEmpty()); + } + + @Test + void detectsReuseAfterRetryGraceAndRevokesTheWholeSession() { + Duration tokenTtl = Duration.ofMinutes(5); + Duration retryGrace = Duration.ofMillis(250); + long memberId = 3L; + String sessionId = unique("session"); + SessionData current = session(unique("current"), sessionId, memberId); + SessionData replacement = session(unique("replacement"), sessionId, memberId); + String replacementToken = unique("replacement-token"); + store.save(current.tokenHash(), current, tokenTtl); + store.rotate(current.tokenHash(), replacementToken, replacement, tokenTtl, retryGrace); + + RotationResult immediateRetry = store.rotate( + current.tokenHash(), "", replacement, tokenTtl, retryGrace); + + assertThat(immediateRetry.status()).isEqualTo(RotationStatus.RETRIED); + assertThat(immediateRetry.replacementToken()).isEqualTo(replacementToken); + + await().atMost(Duration.ofSeconds(3)) + .pollInterval(Duration.ofMillis(25)) + .untilAsserted(() -> assertThat(redisTemplate.hasKey(RETRY_PREFIX + current.tokenHash())).isFalse()); + + RotationResult reuse = store.rotate( + current.tokenHash(), "", replacement, tokenTtl, retryGrace); + + assertThat(reuse.status()).isEqualTo(RotationStatus.REUSED); + assertThat(reuse.memberId()).isEqualTo(memberId); + assertThat(reuse.sessionId()).isEqualTo(sessionId); + assertThat(store.revokeSession(memberId, sessionId, tokenTtl)).isEqualTo(1); + assertThat(store.findSession(replacement.tokenHash())).isEmpty(); + assertThat(store.findSessionsByMember(memberId)).isEmpty(); + assertThat(store.isSessionRevoked(sessionId)).isTrue(); + + RotationResult afterRevocation = store.rotate( + current.tokenHash(), "", replacement, tokenTtl, retryGrace); + assertThat(afterRevocation.status()).isEqualTo(RotationStatus.REVOKED); + } + + private static RotationResult getResult(Future future) { + try { + return future.get(10, SECONDS); + } catch (Exception e) { + throw new IllegalStateException("concurrent rotation failed", e); + } + } + + private static SessionData session(String tokenHash, String sessionId, long memberId) { + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + + return new SessionData( + tokenHash, + sessionId, + memberId, + Provider.KAKAO, + "Chrome / Windows", + "127.0.0.1", + now, + now); + } + + private static String unique(String prefix) { + return prefix + '-' + UUID.randomUUID(); + } + + private static void assertTtlWithin(String key, Duration expectedMaximum) { + Long ttlMillis = redisTemplate.getExpire(key, MILLISECONDS); + + assertThat(ttlMillis) + .isNotNull() + .isPositive() + .isLessThanOrEqualTo(expectedMaximum.toMillis()); + } + + private record RotationCandidate(String token, SessionData session) { + } +} From 582c0b94e8f4853ba2d81abfdbb4d507d12a73fa Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Thu, 13 Aug 2026 22:26:55 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=B4=88=EA=B8=B0=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=98=ED=94=BD=EC=9D=98=20=EC=9D=B8=EA=B8=B0=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=EB=85=B8=EC=B6=9C=20=EB=B2=94=EC=9C=84=20=ED=99=95?= =?UTF-8?q?=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config | 2 +- .../nalssilog/location/config/PopularLocationProperties.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config b/config index 7a6434b..7b6d9e7 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 7a6434b8007cbef155e25ac78765f3d3361ff26c +Subproject commit 7b6d9e71547693b92e19111a52af8fb3f36dfef5 diff --git a/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java b/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java index ce4c6bc..3f73553 100644 --- a/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java +++ b/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java @@ -10,7 +10,7 @@ public record PopularLocationProperties( Duration snapshotInterval ) { - private static final Duration DEFAULT_WINDOW = Duration.ofDays(7); + private static final Duration DEFAULT_WINDOW = Duration.ofDays(30); private static final int DEFAULT_LIMIT = 20; private static final Duration DEFAULT_SNAPSHOT_INTERVAL = Duration.ofMinutes(10); private static final int MAX_LIMIT = 20; From 4b884643be361a6e7c9052fef76f393175be9db1 Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Thu, 13 Aug 2026 22:27:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=EB=B0=B0=ED=8F=AC=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=200.3.1=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ecdb09b..e6d088f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -appVersion=0.3.0 +appVersion=0.3.1