Skip to content

Commit d3d6187

Browse files
committed
feat: 리프레시 토큰 이슈 해결
1 parent 134119a commit d3d6187

File tree

3 files changed

+84
-37
lines changed

3 files changed

+84
-37
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ android {
4747
minSdk = 26
4848
targetSdk = 35
4949
versionCode = 11015
50-
versionName = "1.4.0"
50+
versionName = "1.4.1"
5151

5252
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
5353
vectorDrawables {

app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt

Lines changed: 75 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import okhttp3.OkHttpClient
2424
import okhttp3.Request
2525
import okhttp3.RequestBody.Companion.toRequestBody
2626
import okio.Timeout
27+
import java.io.IOException
28+
import java.util.concurrent.locks.ReentrantLock
29+
import kotlin.concurrent.withLock
2730
import retrofit2.Retrofit
2831
import retrofit2.converter.jackson.JacksonConverterFactory
2932
import timber.log.Timber
@@ -84,45 +87,82 @@ object NetworkModule {
8487
sessionModule: SessionModule,
8588
): Authenticator {
8689
val authenticatorClient = createOkHttpClient(null, null)
90+
val refreshLock = ReentrantLock()
91+
var lastRefreshedAccessToken: String? = null
92+
8793
return Authenticator { _, response ->
8894
if (response.code == 401) {
89-
Timber.d("[NetworkModule] Refresh tokens with Authenticator")
90-
val currentSession = sessionModule.sessionState.value
91-
if (!currentSession.isLoggedIn()) return@Authenticator null
92-
93-
val previousApiToken = currentSession.apiToken
94-
val headers = Headers.headersOf(
95-
"Accept", "application/json",
96-
"X-APP-KEY", BuildConfig.appKey,
97-
"X-APP-VERSION", BuildConfig.VERSION_NAME,
98-
"X-USER-PLATFORM", "AOS",
99-
"X-USER-ID", currentSession.memberId,
100-
)
101-
val refreshRequest = Request.Builder()
102-
.url(BuildConfig.apiBaseUrl + "v1/auth/refresh")
103-
.headers(headers)
104-
.post(
105-
"{\"refreshToken\": \"${previousApiToken.refreshToken}\"}"
106-
.toRequestBody("application/json".toMediaType())
95+
Timber.d("[NetworkModule] 401 received, attempting token refresh")
96+
97+
refreshLock.withLock {
98+
val currentSession = sessionModule.sessionState.value
99+
if (!currentSession.isLoggedIn()) return@Authenticator null
100+
101+
val failedRequestToken = response.request.header("X-AUTH-TOKEN")
102+
val currentAccessToken = currentSession.apiToken.accessToken
103+
104+
// 다른 스레드가 이미 refresh에 성공한 경우, 새 토큰으로 재시도
105+
if (failedRequestToken != null
106+
&& failedRequestToken != currentAccessToken
107+
&& currentAccessToken == lastRefreshedAccessToken
108+
) {
109+
Timber.d("[NetworkModule] Token already refreshed by another thread, retrying")
110+
return@Authenticator response.request
111+
.newBuilder()
112+
.removeHeader("X-AUTH-TOKEN")
113+
.addHeader("X-AUTH-TOKEN", currentAccessToken)
114+
.build()
115+
}
116+
117+
val previousApiToken = currentSession.apiToken
118+
val headers = Headers.headersOf(
119+
"Accept", "application/json",
120+
"X-APP-KEY", BuildConfig.appKey,
121+
"X-APP-VERSION", BuildConfig.VERSION_NAME,
122+
"X-USER-PLATFORM", "AOS",
123+
"X-USER-ID", currentSession.memberId,
107124
)
108-
.build()
109-
kotlin.runCatching {
110-
authenticatorClient.newCall(refreshRequest).execute().use { refreshResponse ->
111-
if (refreshResponse.isSuccessful) {
112-
val newToken = refreshResponse.body!!.string()
113-
val newTokenObject = Gson().fromJson(newToken, AuthResult::class.java)
114-
115-
sessionModule.onRefreshToken(newTokenObject)
116-
return@Authenticator response.request
117-
.newBuilder()
118-
.removeHeader("X-AUTH-TOKEN")
119-
.addHeader("X-AUTH-TOKEN", newTokenObject.accessToken)
120-
.build()
121-
} else throw RuntimeException()
125+
val refreshRequest = Request.Builder()
126+
.url(BuildConfig.apiBaseUrl + "v1/auth/refresh")
127+
.headers(headers)
128+
.post(
129+
"{\"refreshToken\": \"${previousApiToken.refreshToken}\"}"
130+
.toRequestBody("application/json".toMediaType())
131+
)
132+
.build()
133+
134+
try {
135+
authenticatorClient.newCall(refreshRequest).execute()
136+
.use { refreshResponse ->
137+
if (refreshResponse.isSuccessful) {
138+
val newToken = refreshResponse.body!!.string()
139+
val newTokenObject =
140+
Gson().fromJson(newToken, AuthResult::class.java)
141+
142+
sessionModule.onRefreshToken(newTokenObject)
143+
lastRefreshedAccessToken = newTokenObject.accessToken
144+
Timber.d("[NetworkModule] Token refresh succeeded")
145+
146+
return@Authenticator response.request
147+
.newBuilder()
148+
.removeHeader("X-AUTH-TOKEN")
149+
.addHeader("X-AUTH-TOKEN", newTokenObject.accessToken)
150+
.build()
151+
} else {
152+
// 서버가 refresh token을 명시적으로 거부 (만료/무효)
153+
Timber.w(
154+
"[NetworkModule] Refresh failed with HTTP %d - invalidating session",
155+
refreshResponse.code
156+
)
157+
requireTokenInvalidRestart.value = true
158+
sessionModule.invalidateSession()
159+
}
160+
}
161+
} catch (e: IOException) {
162+
// 네트워크 오류 (타임아웃, 연결 끊김 등)
163+
// 세션을 삭제하지 않음 — 디스크에 유효한 토큰이 남아있음
164+
Timber.w(e, "[NetworkModule] Network error during token refresh - NOT invalidating session")
122165
}
123-
}.onFailure {
124-
requireTokenInvalidRestart.value = true
125-
sessionModule.invalidateSession()
126166
}
127167
}
128168
null

app/src/main/java/com/no5ing/bbibbi/di/SessionModule.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.no5ing.bbibbi.di
22

33
import android.content.Context
4+
import com.no5ing.bbibbi.data.datasource.local.LocalDataStorage
45
import com.no5ing.bbibbi.data.model.auth.AuthResult
56
import com.no5ing.bbibbi.data.model.member.Member
67
import com.no5ing.bbibbi.presentation.feature.uistate.common.SessionState
@@ -11,7 +12,10 @@ import javax.inject.Inject
1112
import javax.inject.Singleton
1213

1314
@Singleton
14-
class SessionModule @Inject constructor(val context: Context) {
15+
class SessionModule @Inject constructor(
16+
val context: Context,
17+
private val localDataStorage: LocalDataStorage,
18+
) {
1519
private val _sessionState = MutableStateFlow(SessionState(isLoggedIn = false))
1620
val sessionState: StateFlow<SessionState> = _sessionState
1721

@@ -20,6 +24,9 @@ class SessionModule @Inject constructor(val context: Context) {
2024
}
2125

2226
fun onRefreshToken(newTokenPair: AuthResult) {
27+
localDataStorage.setAuthTokens(newTokenPair)
28+
Timber.d("[SessionModule] Token persisted to disk after refresh")
29+
2330
_sessionState.value = _sessionState.value.copy(
2431
isLoggedIn = true,
2532
_apiToken = newTokenPair,

0 commit comments

Comments
 (0)