From d698b2f8f144304f30123dd2ed2fc1c026fce7c3 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 29 Oct 2026 20:45:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(auth):=20=ED=86=A0=ED=81=B0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=A4=91=EB=B3=B5=EA=B3=BC=20=EC=98=A4=EB=9E=98?= =?UTF-8?q?=EB=90=9C=20=EC=84=B8=EC=85=98=20=EB=8D=AE=EC=96=B4=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TEST_STRATEGY.md | 9 +- .../datastore/token/TokenRepositoryImpl.kt | 72 ++++++++-- .../token/TokenRepositoryImplTest.kt | 42 ++++++ .../com/ruleup/domain/token/TokenRefresher.kt | 2 +- .../ruleup/domain/token/TokenRepository.kt | 9 ++ .../ruleup/domain/test/FakeTokenRepository.kt | 16 +++ core/network/build.gradle.kts | 2 + .../ruleup/network/auth/TokenAuthenticator.kt | 16 ++- .../network/auth/TokenAuthenticatorTest.kt | 113 +++++++++++++++ onboarding/data/build.gradle.kts | 1 + .../auth/repository/TokenRefresherImpl.kt | 17 ++- .../auth/repository/TokenRefresherImplTest.kt | 129 ++++++++++++++++-- .../domain/auth/repository/AuthRepository.kt | 2 +- .../domain/auth/usecase/AutoLoginUseCase.kt | 15 +- .../auth/usecase/AutoLoginUseCaseTest.kt | 2 + .../ruleup/onboarding/domain/fake/Fakes.kt | 19 ++- .../domain/usecase/BindLocationUseCaseTest.kt | 8 ++ 17 files changed, 435 insertions(+), 39 deletions(-) create mode 100644 core/network/src/test/kotlin/com/ruleup/network/auth/TokenAuthenticatorTest.kt diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md index 6b88a8c79..c8542ce60 100644 --- a/TEST_STRATEGY.md +++ b/TEST_STRATEGY.md @@ -107,6 +107,13 @@ verification domain의 아래 테스트로 대체하고 생성·참여 연동 실제 OS·Health Connect 권한창과 서버 연결을 함께 거치는 실기기 검증은 아래 환경 항목에 남아 있다. +### 2026-10-29 토큰 갱신 경합 (#642) + +- `TokenAuthenticatorTest`: 동시에 만료된 요청이 하나의 갱신 결과로 재시도하는지 확인한다. +- `TokenRefresherImplTest`: 오래된 토큰 재사용과 대기자 취소 중 중복 갱신을 확인한다. +- `TokenRepositoryImplTest`: 갱신 중 로그인·로그아웃으로 세션이 바뀌면 이전 결과로 덮거나 지우지 않는지 확인한다. +- 실제 서버의 재사용 탐지 로그와 기기에서의 앱 시작·복귀 흐름은 별도 검증이 필요하다. + 품질 논의는 이 절에서 한다. 각 항목은 *무엇을 못 잡는가 · 왜 안 했나 · 풀리는 조건*을 적는다. ### 코드로 메울 수 있는 것 @@ -138,7 +145,7 @@ verification domain의 아래 테스트로 대체하고 생성·참여 연동 | 런타임 권한 다이얼로그·지오펜스 | Robolectric 이 못 흉내낸다 | 에뮬레이터 CI 워크플로 | | 계측 테스트 8건이 CI 밖 | `test.yml` 이 `./gradlew test` 만 돈다 | 위와 같은 워크플로 | | ~~인수 테스트 실행 확인~~ | — | **2026-09-07 해소.** 시크릿을 받아 스테이징에서 14건 전부 통과시켰다. 그 과정에서 하네스 버그 둘(봉투 미해제·`dev/tokens` 경로 이중 `/api`)과 `#417`(4xx 미변환)을 잡았다 | — | -| `TokenAuthenticator` 401 갱신 | 자동 로그아웃 분기가 어긋나면 전 사용자가 튕긴다 | `core:network` 에 테스트 소스셋이 **생겼다**(`ErrorBodyInterceptorTest`) — 이제 막는 건 시간뿐이다 | 이어서 진행 | +| 토큰 갱신 실서버 검증 | 서버의 refresh 회전·재사용 탐지와 앱 시작 경합을 함께 검증하지 못한다 | 로컬 테스트는 서버와 실제 기기를 사용하지 않는다 | 배포 후 SEC-05와 refresh_rotation_grace 로그 확인 | ### 2026-09-28 · 권한·공통 모델·화면 계측 diff --git a/core/datastore/src/main/kotlin/com/ruleup/datastore/token/TokenRepositoryImpl.kt b/core/datastore/src/main/kotlin/com/ruleup/datastore/token/TokenRepositoryImpl.kt index 0457528f5..99384c3a7 100644 --- a/core/datastore/src/main/kotlin/com/ruleup/datastore/token/TokenRepositoryImpl.kt +++ b/core/datastore/src/main/kotlin/com/ruleup/datastore/token/TokenRepositoryImpl.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.IOException import javax.inject.Inject @@ -32,6 +34,8 @@ class TokenRepositoryImpl @Volatile private var cachedAccess: String? = null + private val mutationLock = Mutex() + /** 읽기 경로의 단일 입구. */ private val preferences: Flow = dataStore.data.catch { cause -> @@ -50,7 +54,7 @@ class TokenRepositoryImpl override suspend fun saveSession( token: Token, userId: String, - ) { + ) = mutationLock.withLock { // 캐시를 먼저 채운다 cachedAccess = token.accessToken // 한 번의 edit 이라 원자적이다. @@ -65,7 +69,7 @@ class TokenRepositoryImpl override suspend fun saveTokens( token: Token, userId: String?, - ) { + ) = mutationLock.withLock { cachedAccess = token.accessToken write("saveTokens") { prefs -> prefs[KEY_ACCESS] = token.accessToken @@ -77,29 +81,67 @@ class TokenRepositoryImpl } } + override suspend fun saveTokensIfRefreshTokenMatches( + expectedRefreshToken: String, + token: Token, + userId: String?, + ): Boolean = + mutationLock.withLock { + var saved = false + dataStore.edit { prefs -> + if (prefs[KEY_REFRESH] == expectedRefreshToken) { + prefs[KEY_ACCESS] = token.accessToken + prefs[KEY_REFRESH] = token.refreshToken + userId?.let { prefs[KEY_USER_ID] = it } + prefs[KEY_EVER_LOGGED_IN] = true + saved = true + } + } + if (saved) cachedAccess = token.accessToken + saved + } + override suspend fun getAccessToken(): String? = - preferences - .map { it[KEY_ACCESS] } - .first() - .also { cachedAccess = it } + mutationLock.withLock { + preferences + .map { it[KEY_ACCESS] } + .first() + .also { cachedAccess = it } + } override fun cachedAccessToken(): String? = cachedAccess - override suspend fun getRefreshToken(): String? = preferences.map { it[KEY_REFRESH] }.first() + override suspend fun getRefreshToken(): String? = mutationLock.withLock { preferences.map { it[KEY_REFRESH] }.first() } override suspend fun getUserId(): String? = preferences.map { it[KEY_USER_ID] }.first() override suspend fun hasEverLoggedIn(): Boolean = preferences.map { it[KEY_EVER_LOGGED_IN] == true }.first() - override suspend fun clear() { - cachedAccess = null - write("clear") { prefs -> - // 로그인 이력만 남긴다. - val everLoggedIn = prefs[KEY_EVER_LOGGED_IN] - prefs.clear() - everLoggedIn?.let { prefs[KEY_EVER_LOGGED_IN] = it } + override suspend fun clear() = + mutationLock.withLock { + cachedAccess = null + write("clear") { prefs -> + // 로그인 이력만 남긴다. + val everLoggedIn = prefs[KEY_EVER_LOGGED_IN] + prefs.clear() + everLoggedIn?.let { prefs[KEY_EVER_LOGGED_IN] = it } + } + } + + override suspend fun clearIfRefreshTokenMatches(expectedRefreshToken: String): Boolean = + mutationLock.withLock { + var cleared = false + dataStore.edit { prefs -> + if (prefs[KEY_REFRESH] == expectedRefreshToken) { + val everLoggedIn = prefs[KEY_EVER_LOGGED_IN] + prefs.clear() + everLoggedIn?.let { prefs[KEY_EVER_LOGGED_IN] = it } + cleared = true + } + } + if (cleared) cachedAccess = null + cleared } - } /** 쓰기 경로의 단일 입구. */ private suspend fun write( diff --git a/core/datastore/src/test/kotlin/com/ruleup/datastore/token/TokenRepositoryImplTest.kt b/core/datastore/src/test/kotlin/com/ruleup/datastore/token/TokenRepositoryImplTest.kt index c69542817..05f20feba 100644 --- a/core/datastore/src/test/kotlin/com/ruleup/datastore/token/TokenRepositoryImplTest.kt +++ b/core/datastore/src/test/kotlin/com/ruleup/datastore/token/TokenRepositoryImplTest.kt @@ -104,6 +104,48 @@ class TokenRepositoryImplTest { assertEquals("u-1", repo.getUserId()) } + @Test + fun `갱신 중 세션이 바뀌면 이전 응답으로 새 세션을 덮지 않는다`() = + runTest { + val repo = repo(FakeDataStore()) + repo.saveSession(token(), userId = "old-user") + repo.saveSession(token(access = "new-access", refresh = "new-refresh"), userId = "new-user") + + val saved = repo.saveTokensIfRefreshTokenMatches("r1", token(access = "stale", refresh = "stale-refresh"), "old-user") + + assertFalse(saved) + assertEquals("new-refresh", repo.getRefreshToken()) + assertEquals("new-access", repo.cachedAccessToken()) + assertEquals("new-user", repo.getUserId()) + } + + @Test + fun `로그아웃 뒤 도착한 갱신 응답은 세션을 되살리지 않는다`() = + runTest { + val repo = repo(FakeDataStore()) + repo.saveSession(token(), userId = "u-1") + repo.clear() + + val saved = repo.saveTokensIfRefreshTokenMatches("r1", token(access = "a2", refresh = "r2")) + + assertFalse(saved) + assertNull(repo.getRefreshToken()) + assertNull(repo.cachedAccessToken()) + } + + @Test + fun `세션이 바뀌면 이전 토큰의 갱신 실패로 새 세션을 지우지 않는다`() = + runTest { + val repo = repo(FakeDataStore()) + repo.saveSession(token(), "old-user") + repo.saveSession(token(access = "new-access", refresh = "new-refresh"), "new-user") + + assertFalse(repo.clearIfRefreshTokenMatches("r1")) + assertEquals("new-refresh", repo.getRefreshToken()) + assertEquals("new-access", repo.cachedAccessToken()) + assertEquals("new-user", repo.getUserId()) + } + @Test fun `clear 는 저장값과 캐시를 함께 비운다`() = runTest { diff --git a/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRefresher.kt b/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRefresher.kt index 15ef77085..a320ac6c3 100644 --- a/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRefresher.kt +++ b/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRefresher.kt @@ -4,7 +4,7 @@ import com.ruleup.domain.entity.user.Token /** accessToken 만료(401) 시 refreshToken 으로 토큰을 재발급하는 포트. */ interface TokenRefresher { - /** [refreshToken] 으로 새 토큰(회전: access/refresh 둘 다 갱신)을 재발급한다. */ + /** [refreshToken] 으로 재발급한 토큰은 반환 전에 저장한다. 세션이 바뀌면 null 을 반환한다. */ suspend fun refresh(refreshToken: String): RefreshedSession? } diff --git a/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRepository.kt b/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRepository.kt index 7a2f7c2b6..7e2487f21 100644 --- a/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRepository.kt +++ b/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRepository.kt @@ -16,6 +16,13 @@ interface TokenRepository { userId: String? = null, ) + /** 갱신 중 세션이 바뀌었다면 이전 결과로 덮어쓰지 않는다. */ + suspend fun saveTokensIfRefreshTokenMatches( + expectedRefreshToken: String, + token: Token, + userId: String? = null, + ): Boolean + suspend fun getAccessToken(): String? /** 마지막으로 알려진 accessToken 의 인메모리 스냅샷(동기). */ @@ -31,6 +38,8 @@ interface TokenRepository { suspend fun clear() + suspend fun clearIfRefreshTokenMatches(expectedRefreshToken: String): Boolean + /** 이 기기에서 한 번이라도 로그인에 성공한 적이 있는지. */ suspend fun hasEverLoggedIn(): Boolean diff --git a/core/domain/src/testFixtures/kotlin/com/ruleup/domain/test/FakeTokenRepository.kt b/core/domain/src/testFixtures/kotlin/com/ruleup/domain/test/FakeTokenRepository.kt index 98b9e6e18..76468f582 100644 --- a/core/domain/src/testFixtures/kotlin/com/ruleup/domain/test/FakeTokenRepository.kt +++ b/core/domain/src/testFixtures/kotlin/com/ruleup/domain/test/FakeTokenRepository.kt @@ -49,6 +49,16 @@ class FakeTokenRepository( loggedIn.value = true } + override suspend fun saveTokensIfRefreshTokenMatches( + expectedRefreshToken: String, + token: Token, + userId: String?, + ): Boolean { + if (refreshToken != expectedRefreshToken) return false + saveTokens(token, userId) + return true + } + override suspend fun getAccessToken(): String? = savedToken?.accessToken override fun cachedAccessToken(): String? = savedToken?.accessToken @@ -57,6 +67,12 @@ class FakeTokenRepository( override suspend fun getUserId(): String? = storedUserId + override suspend fun clearIfRefreshTokenMatches(expectedRefreshToken: String): Boolean { + if (refreshToken != expectedRefreshToken) return false + clear() + return true + } + override suspend fun clear() { cleared = true refreshToken = null diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 1bccbcf1b..ee8b420a9 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -47,5 +47,7 @@ dependencies { // 에러 본문 인터셉터가 실제 OkHttp 응답을 다루므로 유닛 테스트에도 okhttp 가 필요하다. testImplementation(libs.junit) testImplementation(kotlin("test-junit")) + testImplementation(testFixtures(project(":core:domain"))) + testImplementation(testFixtures(project(":observability:domain"))) testImplementation(libs.okhttp) } diff --git a/core/network/src/main/kotlin/com/ruleup/network/auth/TokenAuthenticator.kt b/core/network/src/main/kotlin/com/ruleup/network/auth/TokenAuthenticator.kt index 4439f74c3..571cb1bee 100644 --- a/core/network/src/main/kotlin/com/ruleup/network/auth/TokenAuthenticator.kt +++ b/core/network/src/main/kotlin/com/ruleup/network/auth/TokenAuthenticator.kt @@ -39,6 +39,10 @@ class TokenAuthenticator } val refreshToken = runBlocking { tokenRepository.getRefreshToken() } ?: return null + val accessAfterRead = tokenRepository.cachedAccessToken() + if (!accessAfterRead.isNullOrBlank() && accessAfterRead != failedToken) { + return response.retryWith(accessAfterRead) + } val newToken = try { @@ -57,13 +61,17 @@ class TokenAuthenticator return response.retryWith(latestAccess) } // 정리하면 isLoggedIn 이 false 로 전이해 로그인 화면으로 라우팅된다. - observability.i(TAG) { "세션 만료 — 로컬 토큰 정리" } - runBlocking { tokenRepository.clear() } + val cleared = runBlocking { tokenRepository.clearIfRefreshTokenMatches(refreshToken) } + if (cleared) observability.i(TAG) { "세션 만료 — 로컬 토큰 정리" } + if (!cleared) { + val latest = tokenRepository.cachedAccessToken() + if (!latest.isNullOrBlank()) return response.retryWith(latest) + } return null } - runBlocking { tokenRepository.saveTokens(newToken.token, newToken.userId) } - return response.retryWith(newToken.token.accessToken) + val latestAccess = tokenRepository.cachedAccessToken() + return if (!latestAccess.isNullOrBlank()) response.retryWith(latestAccess) else null } } diff --git a/core/network/src/test/kotlin/com/ruleup/network/auth/TokenAuthenticatorTest.kt b/core/network/src/test/kotlin/com/ruleup/network/auth/TokenAuthenticatorTest.kt new file mode 100644 index 000000000..054066ce4 --- /dev/null +++ b/core/network/src/test/kotlin/com/ruleup/network/auth/TokenAuthenticatorTest.kt @@ -0,0 +1,113 @@ +package com.ruleup.network.auth + +import com.ruleup.domain.entity.user.Token +import com.ruleup.domain.test.FakeTokenRepository +import com.ruleup.domain.token.RefreshedSession +import com.ruleup.domain.token.TokenRefresher +import com.ruleup.domain.token.TokenRepository +import com.ruleup.observability.domain.test.testObservability +import dagger.Lazy +import kotlinx.coroutines.runBlocking +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.Test +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TokenAuthenticatorTest { + @Test + fun `리프레시 토큰 조회 중 다른 경로가 갱신하면 새 토큰으로 재시도한다`() = + runBlocking { + val stored = FakeTokenRepository() + stored.saveSession(Token("a1", "r1", "Bearer", 3600), "u1") + val tokens = + object : TokenRepository by stored { + override suspend fun getRefreshToken(): String? { + stored.saveTokens(Token("a2", "r2", "Bearer", 3600), "u1") + return stored.getRefreshToken() + } + } + val refresher = + object : TokenRefresher { + override suspend fun refresh(refreshToken: String): RefreshedSession? = error("중복 갱신") + } + val response = + Response + .Builder() + .request( + Request + .Builder() + .url("https://example.com/api/v1/profile") + .header("Authorization", "Bearer a1") + .build(), + ).protocol(Protocol.HTTP_1_1) + .code(401) + .message("Unauthorized") + .build() + + val retried = TokenAuthenticator(tokens, Lazy { refresher }, testObservability()).authenticate(null, response) + + assertEquals("Bearer a2", retried?.header("Authorization")) + } + + @Test + fun `동시에 만료된 요청들은 한 번만 갱신하고 같은 새 토큰을 사용한다`() = + runBlocking { + val tokens = FakeTokenRepository() + tokens.saveSession(Token("a1", "r1", "Bearer", 3600), "u1") + val calls = AtomicInteger() + val refresher = + object : TokenRefresher { + override suspend fun refresh(refreshToken: String): RefreshedSession { + calls.incrementAndGet() + val session = RefreshedSession(Token("a2", "r2", "Bearer", 3600), "u1") + tokens.saveTokensIfRefreshTokenMatches(refreshToken, session.token, session.userId) + return session + } + } + val authenticator = TokenAuthenticator(tokens, Lazy { refresher }, testObservability()) + val request = + Request + .Builder() + .url("https://example.com/api/v1/profile") + .header("Authorization", "Bearer a1") + .build() + val response = + Response + .Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(401) + .message("Unauthorized") + .build() + val executor = Executors.newFixedThreadPool(8) + val ready = CountDownLatch(8) + val start = CountDownLatch(1) + try { + val results = + List(8) { + executor.submit( + Callable { + ready.countDown() + check(start.await(10, TimeUnit.SECONDS)) + authenticator.authenticate(null, response)?.header("Authorization") + }, + ) + } + assertTrue(ready.await(10, TimeUnit.SECONDS)) + start.countDown() + assertEquals(List(8) { "Bearer a2" }, results.map { it.get(10, TimeUnit.SECONDS) }) + assertEquals(1, calls.get()) + assertEquals("r2", tokens.getRefreshToken()) + } finally { + start.countDown() + executor.shutdownNow() + } + } +} diff --git a/onboarding/data/build.gradle.kts b/onboarding/data/build.gradle.kts index 16b1be2cb..be20ec5ef 100644 --- a/onboarding/data/build.gradle.kts +++ b/onboarding/data/build.gradle.kts @@ -45,4 +45,5 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(kotlin("test-junit")) + testImplementation(testFixtures(project(":core:domain"))) } diff --git a/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImpl.kt b/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImpl.kt index 29548eed7..fdfac4479 100644 --- a/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImpl.kt +++ b/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImpl.kt @@ -2,6 +2,7 @@ package com.ruleup.onboarding.data.auth.repository import com.ruleup.domain.token.RefreshedSession import com.ruleup.domain.token.TokenRefresher +import com.ruleup.domain.token.TokenRepository import com.ruleup.network.dto.ApiException import com.ruleup.network.dto.getOrThrow import com.ruleup.onboarding.data.auth.api.AuthApi @@ -22,6 +23,7 @@ class TokenRefresherImpl @Inject constructor( private val api: AuthApi, + private val tokenRepository: TokenRepository, ) : TokenRefresher { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -33,14 +35,25 @@ class TokenRefresherImpl override suspend fun refresh(refreshToken: String): RefreshedSession? { val shared = lock.withLock { + if (tokenRepository.getRefreshToken() != refreshToken) return null latest?.takeIf { it.first == refreshToken }?.second - ?: scope.async { request(refreshToken) }.also { latest = refreshToken to it } + ?: scope + .async { + val session = request(refreshToken) ?: return@async null + if (tokenRepository.saveTokensIfRefreshTokenMatches(refreshToken, session.token, session.userId)) { + session + } else { + null + } + }.also { latest = refreshToken to it } } return try { shared.await() } catch (e: Exception) { // 일시적 실패를 붙잡아 두면 같은 토큰의 재시도가 영영 같은 실패를 받는다. - lock.withLock { if (latest?.second === shared) latest = null } + if (shared.isCompleted) { + lock.withLock { if (latest?.second === shared) latest = null } + } throw e } } diff --git a/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt b/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt index a50d51fd2..b7f3dd08c 100644 --- a/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt +++ b/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt @@ -1,5 +1,8 @@ package com.ruleup.onboarding.data.auth.repository +import com.ruleup.domain.entity.user.Token +import com.ruleup.domain.test.FakeTokenRepository +import com.ruleup.domain.token.TokenRepository import com.ruleup.network.dto.BaseResponse import com.ruleup.network.dto.EmptyData import com.ruleup.network.dto.ErrorBody @@ -10,8 +13,11 @@ import com.ruleup.onboarding.data.auth.dto.SocialLoginAuthRequest import com.ruleup.onboarding.data.auth.dto.TokenRefreshRequest import com.ruleup.onboarding.data.auth.dto.TokenRefreshResponse import com.ruleup.onboarding.data.auth.dto.WithdrawRequest +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import okhttp3.ResponseBody.Companion.toResponseBody @@ -20,6 +26,7 @@ import retrofit2.Response import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNull class TokenRefresherImplTest { @@ -41,7 +48,7 @@ class TokenRefresherImplTest { ) } - val refreshed = TokenRefresherImpl(api).refresh("old-refresh") + val refreshed = refresher(api, "old-refresh").refresh("old-refresh") assertEquals("new-access", refreshed?.token?.accessToken) assertEquals("new-refresh", refreshed?.token?.refreshToken) @@ -55,7 +62,7 @@ class TokenRefresherImplTest { runBlocking { val api = FakeAuthApi { throw httpException(401) } - assertNull(TokenRefresherImpl(api).refresh("expired-refresh")) + assertNull(refresher(api, "expired-refresh").refresh("expired-refresh")) } @Test @@ -70,7 +77,7 @@ class TokenRefresherImplTest { ) } - assertNull(TokenRefresherImpl(api).refresh("expired-refresh")) + assertNull(refresher(api, "expired-refresh").refresh("expired-refresh")) } @Test @@ -79,7 +86,7 @@ class TokenRefresherImplTest { val api = FakeAuthApi { throw httpException(500) } assertFailsWith { - TokenRefresherImpl(api).refresh("valid-refresh") + refresher(api, "valid-refresh").refresh("valid-refresh") } Unit } @@ -92,7 +99,7 @@ class TokenRefresherImplTest { delay(100) rotated() } - val refresher = TokenRefresherImpl(api) + val refresher = refresher(api) val results = listOf(async { refresher.refresh("r1") }, async { refresher.refresh("r1") }).awaitAll() @@ -100,17 +107,118 @@ class TokenRefresherImplTest { assertEquals(listOf("new-refresh", "new-refresh"), results.map { it?.token?.refreshToken }) } + @Test + fun `갱신 응답을 기다리는 동안 로그아웃하면 응답이 세션을 복구하지 않는다`() = + runBlocking { + val started = CompletableDeferred() + val release = CompletableDeferred() + val api = + FakeAuthApi { + started.complete(Unit) + release.await() + rotated() + } + val tokens = FakeTokenRepository(refreshToken = "r1") + val refresher = TokenRefresherImpl(api, tokens) + + val pending = async { refresher.refresh("r1") } + started.await() + tokens.clear() + release.complete(Unit) + + assertNull(pending.await()) + assertNull(tokens.getRefreshToken()) + } + + @Test + fun `새 토큰 저장이 끝나기 전에는 갱신 결과를 반환하지 않는다`() = + runBlocking { + val saving = CompletableDeferred() + val release = CompletableDeferred() + val delegate = FakeTokenRepository(refreshToken = "r1") + val tokens = + object : TokenRepository by delegate { + override suspend fun saveTokensIfRefreshTokenMatches( + expectedRefreshToken: String, + token: Token, + userId: String?, + ): Boolean { + saving.complete(Unit) + release.await() + return delegate.saveTokensIfRefreshTokenMatches(expectedRefreshToken, token, userId) + } + } + val api = FakeAuthApi { rotated() } + val refresher = TokenRefresherImpl(api, tokens) + + val first = async { refresher.refresh("r1") } + saving.await() + val second = async(start = CoroutineStart.UNDISPATCHED) { refresher.refresh("r1") } + assertFalse(first.isCompleted) + assertFalse(second.isCompleted) + release.complete(Unit) + + assertEquals("new-refresh", first.await()?.token?.refreshToken) + assertEquals("new-refresh", second.await()?.token?.refreshToken) + assertEquals("new-refresh", tokens.getRefreshToken()) + assertEquals(1, api.refreshCalls) + } + + @Test + fun `갱신을 기다리던 화면이 닫혀도 진행 중인 요청을 중복 전송하지 않는다`() = + runBlocking { + val started = CompletableDeferred() + val release = CompletableDeferred() + val api = + FakeAuthApi { + started.complete(Unit) + release.await() + rotated() + } + val refresher = refresher(api) + + val first = async { refresher.refresh("r1") } + started.await() + first.cancelAndJoin() + val second = async(start = CoroutineStart.UNDISPATCHED) { refresher.refresh("r1") } + release.complete(Unit) + + assertEquals("new-refresh", second.await()?.token?.refreshToken) + assertEquals(1, api.refreshCalls) + } + @Test fun `이미 끝난 갱신과 같은 토큰으로 늦게 와도 회전 전 토큰을 다시 보내지 않는다`() = runBlocking { val api = FakeAuthApi { rotated() } - val refresher = TokenRefresherImpl(api) + val refresher = refresher(api) refresher.refresh("r1") val late = refresher.refresh("r1") assertEquals(1, api.refreshCalls) - assertEquals("new-refresh", late?.token?.refreshToken) + assertNull(late) + } + + @Test + fun `두 번 회전한 뒤 지연된 이전 토큰으로 재발급하지 않는다`() = + runBlocking { + var rotation = 1 + val api = + FakeAuthApi { + rotation++ + rotated().let { it.copy(data = it.data?.copy(refreshToken = "r$rotation")) } + } + val tokens = FakeTokenRepository(refreshToken = "r1") + val refresher = TokenRefresherImpl(api, tokens) + + refresher.refresh("r1")!! + refresher.refresh("r2")!! + val late = refresher.refresh("r1") + + assertEquals(2, api.refreshCalls) + assertNull(late) + assertEquals("r3", tokens.getRefreshToken()) } @Test @@ -125,7 +233,7 @@ class TokenRefresherImplTest { } rotated() } - val refresher = TokenRefresherImpl(api) + val refresher = refresher(api) assertFailsWith { refresher.refresh("r1") } val retried = refresher.refresh("r1") @@ -134,6 +242,11 @@ class TokenRefresherImplTest { assertEquals("new-refresh", retried?.token?.refreshToken) } + private fun refresher( + api: AuthApi, + refreshToken: String = "r1", + ) = TokenRefresherImpl(api, FakeTokenRepository(refreshToken = refreshToken)) + private fun rotated() = BaseResponse( success = true, diff --git a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/repository/AuthRepository.kt b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/repository/AuthRepository.kt index ae21d18fe..4040b95cd 100644 --- a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/repository/AuthRepository.kt +++ b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/repository/AuthRepository.kt @@ -23,7 +23,7 @@ interface AuthRepository { device: DeviceIdentity, ): AuthSession - /** 앱 토큰 재발급(회전). */ + /** 앱 토큰을 재발급하고 저장한 뒤 반환한다. */ suspend fun refreshToken(refreshToken: String): RefreshedSession /** 현재 기기 refreshToken revoke. */ diff --git a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/AutoLoginUseCase.kt b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/AutoLoginUseCase.kt index 18741cfec..4b6d454c5 100644 --- a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/AutoLoginUseCase.kt +++ b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/AutoLoginUseCase.kt @@ -36,9 +36,7 @@ class AutoLoginUseCase return runCatching { authRepository.refreshToken(refreshToken) } .fold( onSuccess = { - // 갱신 응답이 userId 를 함께 줘서 프로필 조회 없이 세션이 완성된다. - tokenRepository.saveTokens(it.token, it.userId) - AutoLoginResult.Authenticated + if (tokenRepository.getRefreshToken() != null) AutoLoginResult.Authenticated else AutoLoginResult.NoSession }, onFailure = { // 동시 토큰 갱신 실패 시 최신 저장 토큰 확인. @@ -50,9 +48,14 @@ class AutoLoginUseCase AutoLoginResult.ConnectionFailed } else { // 세션이 실제로 끊긴 지점. - bizLogger.record(OnboardingEvents.sessionExpired(SessionExpiredTrigger.EXPIRED)) - tokenRepository.clear() - AutoLoginResult.SessionExpired + if (tokenRepository.clearIfRefreshTokenMatches(refreshToken)) { + bizLogger.record(OnboardingEvents.sessionExpired(SessionExpiredTrigger.EXPIRED)) + AutoLoginResult.SessionExpired + } else if (tokenRepository.getRefreshToken() != null) { + AutoLoginResult.Authenticated + } else { + AutoLoginResult.NoSession + } } }, ) diff --git a/onboarding/domain/src/test/kotlin/com/ruleup/onboarding/domain/auth/usecase/AutoLoginUseCaseTest.kt b/onboarding/domain/src/test/kotlin/com/ruleup/onboarding/domain/auth/usecase/AutoLoginUseCaseTest.kt index 54861bc4c..bb74b0496 100644 --- a/onboarding/domain/src/test/kotlin/com/ruleup/onboarding/domain/auth/usecase/AutoLoginUseCaseTest.kt +++ b/onboarding/domain/src/test/kotlin/com/ruleup/onboarding/domain/auth/usecase/AutoLoginUseCaseTest.kt @@ -34,6 +34,7 @@ class AutoLoginUseCaseTest { val newToken = Token("a", "r2", "Bearer", 3600) val auth = FakeAuthRepository().apply { refreshResult = RefreshedSession(newToken, userId = "u-1") } val tokens = FakeTokenRepository(refreshToken = "r1") + auth.onRefresh = { tokens.saveTokens(it.token, it.userId) } val result = AutoLoginUseCase(auth, tokens, RecordingBizLogger())() @@ -53,6 +54,7 @@ class AutoLoginUseCaseTest { refreshResult = RefreshedSession(Token("a", "r2", "Bearer", 3600), userId = null) } val tokens = FakeTokenRepository(refreshToken = "r1").apply { savedUserId = "u-old" } + auth.onRefresh = { tokens.saveTokens(it.token, it.userId) } AutoLoginUseCase(auth, tokens, RecordingBizLogger())() diff --git a/onboarding/domain/src/testFixtures/kotlin/com/ruleup/onboarding/domain/fake/Fakes.kt b/onboarding/domain/src/testFixtures/kotlin/com/ruleup/onboarding/domain/fake/Fakes.kt index e6ade276d..9dda1ff3e 100644 --- a/onboarding/domain/src/testFixtures/kotlin/com/ruleup/onboarding/domain/fake/Fakes.kt +++ b/onboarding/domain/src/testFixtures/kotlin/com/ruleup/onboarding/domain/fake/Fakes.kt @@ -61,6 +61,16 @@ class FakeTokenRepository( saveCount++ } + override suspend fun saveTokensIfRefreshTokenMatches( + expectedRefreshToken: String, + token: Token, + userId: String?, + ): Boolean { + if (refreshToken != expectedRefreshToken) return false + saveTokens(token, userId) + return true + } + override suspend fun getAccessToken(): String? = savedToken?.accessToken override fun cachedAccessToken(): String? = savedToken?.accessToken @@ -72,6 +82,12 @@ class FakeTokenRepository( // 실제 구현처럼 로그인 이력은 clear() 로 지워지지 않는다. override suspend fun hasEverLoggedIn(): Boolean = everLoggedIn + override suspend fun clearIfRefreshTokenMatches(expectedRefreshToken: String): Boolean { + if (refreshToken != expectedRefreshToken) return false + clear() + return true + } + override suspend fun clear() { cleared = true savedToken = null @@ -94,6 +110,7 @@ class FakeAuthRepository : AuthRepository { var refreshResult: RefreshedSession? = null var refreshError: Throwable? = null var refreshCalledWith: String? = null + var onRefresh: suspend (RefreshedSession) -> Unit = {} var signupResult: AuthSession? = null var signupError: Throwable? = null @@ -133,7 +150,7 @@ class FakeAuthRepository : AuthRepository { override suspend fun refreshToken(refreshToken: String): RefreshedSession { refreshCalledWith = refreshToken refreshError?.let { throw it } - return refreshResult!! + return refreshResult!!.also { onRefresh(it) } } override suspend fun logout(refreshToken: String) { diff --git a/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt b/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt index 9a1a326ed..f1a2b0870 100644 --- a/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt +++ b/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt @@ -85,6 +85,12 @@ class BindLocationUseCaseTest { userId: String?, ) = Unit + override suspend fun saveTokensIfRefreshTokenMatches( + expectedRefreshToken: String, + token: Token, + userId: String?, + ): Boolean = throw NotImplementedError() + override suspend fun getAccessToken(): String? = null override fun cachedAccessToken(): String? = null @@ -95,6 +101,8 @@ class BindLocationUseCaseTest { override suspend fun hasEverLoggedIn(): Boolean = storedUserId != null + override suspend fun clearIfRefreshTokenMatches(expectedRefreshToken: String): Boolean = throw NotImplementedError() + override suspend fun clear() = Unit override val isLoggedIn: Flow = flowOf(storedUserId != null) From 1bd9eb71ef944a248f1d6bc35b5d2fe51c5d9f97 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 29 Oct 2026 21:47:44 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(auth):=20=EC=9E=90=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=EA=B3=BC=20=EB=8F=99=EC=8B=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TEST_STRATEGY.md | 2 +- onboarding/data/build.gradle.kts | 1 + .../auth/repository/TokenRefresherImplTest.kt | 66 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md index c8542ce60..7b9918b96 100644 --- a/TEST_STRATEGY.md +++ b/TEST_STRATEGY.md @@ -110,7 +110,7 @@ verification domain의 아래 테스트로 대체하고 생성·참여 연동 ### 2026-10-29 토큰 갱신 경합 (#642) - `TokenAuthenticatorTest`: 동시에 만료된 요청이 하나의 갱신 결과로 재시도하는지 확인한다. -- `TokenRefresherImplTest`: 오래된 토큰 재사용과 대기자 취소 중 중복 갱신을 확인한다. +- `TokenRefresherImplTest`: 오래된 토큰 재사용과 대기자 취소 중 중복 갱신, 자동 로그인 갱신과 401 재시도가 겹칠 때 하나의 요청을 공유하는지 확인한다. - `TokenRepositoryImplTest`: 갱신 중 로그인·로그아웃으로 세션이 바뀌면 이전 결과로 덮거나 지우지 않는지 확인한다. - 실제 서버의 재사용 탐지 로그와 기기에서의 앱 시작·복귀 흐름은 별도 검증이 필요하다. diff --git a/onboarding/data/build.gradle.kts b/onboarding/data/build.gradle.kts index be20ec5ef..c8d50fd36 100644 --- a/onboarding/data/build.gradle.kts +++ b/onboarding/data/build.gradle.kts @@ -46,4 +46,5 @@ dependencies { testImplementation(kotlin("test-junit")) testImplementation(testFixtures(project(":core:domain"))) + testImplementation(testFixtures(project(":observability:domain"))) } diff --git a/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt b/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt index b7f3dd08c..2c6ca3978 100644 --- a/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt +++ b/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt @@ -2,10 +2,14 @@ package com.ruleup.onboarding.data.auth.repository import com.ruleup.domain.entity.user.Token import com.ruleup.domain.test.FakeTokenRepository +import com.ruleup.domain.token.RefreshedSession +import com.ruleup.domain.token.TokenRefresher import com.ruleup.domain.token.TokenRepository +import com.ruleup.network.auth.TokenAuthenticator import com.ruleup.network.dto.BaseResponse import com.ruleup.network.dto.EmptyData import com.ruleup.network.dto.ErrorBody +import com.ruleup.observability.domain.test.testObservability import com.ruleup.onboarding.data.auth.api.AuthApi import com.ruleup.onboarding.data.auth.dto.LogoutRequest import com.ruleup.onboarding.data.auth.dto.SignUpRequest @@ -13,6 +17,7 @@ import com.ruleup.onboarding.data.auth.dto.SocialLoginAuthRequest import com.ruleup.onboarding.data.auth.dto.TokenRefreshRequest import com.ruleup.onboarding.data.auth.dto.TokenRefreshResponse import com.ruleup.onboarding.data.auth.dto.WithdrawRequest +import dagger.Lazy import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async @@ -20,16 +25,77 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import okhttp3.Protocol +import okhttp3.Request import okhttp3.ResponseBody.Companion.toResponseBody import retrofit2.HttpException import retrofit2.Response +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class TokenRefresherImplTest { + @Test + fun `자동 로그인 갱신 중 동시 401 이 와도 이전 토큰을 다시 전송하지 않는다`() = + runBlocking { + val started = CompletableDeferred() + val release = CompletableDeferred() + val api = + FakeAuthApi { + started.complete(Unit) + release.await() + rotated() + } + val tokens = FakeTokenRepository() + tokens.saveSession(Token("old-access", "r1", "Bearer", 3600), "u-1") + val actualRefresher = TokenRefresherImpl(api, tokens) + val authenticationStarted = CountDownLatch(1) + val authenticatorRefresher = + object : TokenRefresher { + override suspend fun refresh(refreshToken: String): RefreshedSession? { + authenticationStarted.countDown() + return actualRefresher.refresh(refreshToken) + } + } + val authenticator = TokenAuthenticator(tokens, Lazy { authenticatorRefresher }, testObservability()) + val response = + okhttp3.Response + .Builder() + .request( + Request + .Builder() + .url("https://example.com/api/v1/profile") + .header("Authorization", "Bearer old-access") + .build(), + ).protocol(Protocol.HTTP_1_1) + .code(401) + .message("Unauthorized") + .build() + val executor = Executors.newSingleThreadExecutor() + try { + val autoLogin = async { actualRefresher.refresh("r1") } + started.await() + val retry = executor.submit(Callable { authenticator.authenticate(null, response)?.header("Authorization") }) + assertTrue(authenticationStarted.await(10, TimeUnit.SECONDS)) + release.complete(Unit) + + assertEquals("new-refresh", autoLogin.await()?.token?.refreshToken) + assertEquals("Bearer new-access", retry.get(10, TimeUnit.SECONDS)) + assertEquals(1, api.refreshCalls) + assertEquals("new-refresh", tokens.getRefreshToken()) + } finally { + release.complete(Unit) + executor.shutdownNow() + } + } + @Test fun `재발급에 성공하면 회전된 토큰을 반환한다`() = runBlocking {