Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[fix] 토큰 재발급 동시성 문제 해결 #237

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
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
1 change: 0 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ android {

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
buildConfigField "String", "GP_BASE_URL", properties["GP_BASE_URL"]
buildConfigField "String", "ACCESS_TOKEN", properties["ACCESS_TOKEN"]
buildConfigField "String", "KAKAO_APP_KEY", properties["KAKAO_APP_KEY"]

manifestPlaceholders = [KAKAO_APP_KEY: properties["KAKAO_APP_KEY"]]
Expand Down
184 changes: 145 additions & 39 deletions app/src/main/java/com/sopt/geonppang/data/interceptor/AuthInterceptor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import com.sopt.geonppang.presentation.auth.SignActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import javax.inject.Inject

Expand All @@ -17,64 +21,166 @@ class AuthInterceptor @Inject constructor(
private val context: Application,
) : Interceptor {

// TODO dana 경우에 따른 분기 처리 필요
private val mutex = Mutex()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요런 녀석이 있었군요

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

멋져요

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니달라
coroutine 동시 접근 문제는 보통 Mutex를 사용하는거 같더라고용


override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val authRequest =
originalRequest.newBuilder().addHeader("Authorization", gpDataSource.accessToken)
.build()
val response = chain.proceed(
val requestAccessToken = gpDataSource.accessToken // 요청한 엑세스 토큰

var response = attemptRequest(chain, originalRequest, requestAccessToken)

when (response.code) {
401 -> {
response.close()
response = handleTokenExpiration(chain, originalRequest, requestAccessToken)
}
}

return response
}

/**
* 최초 요청을 시도하는 함수 (인터셉트를 위한)
*/
private fun attemptRequest(chain: Interceptor.Chain, originalRequest: Request, accessToken: String): Response {
val authRequest = originalRequest.newBuilder().addHeader(ACCESS_TOKEN, accessToken).build()
return chain.proceed(
if (gpDataSource.accessToken.isNotBlank()) {
authRequest
} else {
originalRequest
}
)
}

when (response.code) {
401 -> {
response.close()
val refreshTokenRequest =
originalRequest.newBuilder().get().url("${BuildConfig.GP_BASE_URL}auth/refresh")
.addHeader(ACCESS_TOKEN, gpDataSource.accessToken)
.addHeader(REFRESH_TOKEN, gpDataSource.refreshToken).build()
val refreshTokenResponse = chain.proceed(refreshTokenRequest)

if (refreshTokenResponse.isSuccessful) {
val responseHeader = refreshTokenResponse.headers
val responseAccessToken = responseHeader[ACCESS_TOKEN]
val responseRefreshToken = responseHeader[REFRESH_TOKEN]

with(gpDataSource) {
accessToken = BEARER_PREFIX + responseAccessToken.toString()
refreshToken = BEARER_PREFIX + responseRefreshToken.toString()
/**
* 토큰 만료 처리
* @param requestAccessToken 최초 요청한 엑세스 토큰, 유효하지 않은 토큰
* @member nowAccessToken 현재 시점에서의 토큰
*
*/
private fun handleTokenExpiration(chain: Interceptor.Chain, originalRequest: Request, requestAccessToken: String): Response =
runBlocking {
mutex.withLock {
val nowAccessToken = gpDataSource.accessToken
val nowRefreshToken = gpDataSource.refreshToken

when (isTokenValid(requestAccessToken = requestAccessToken, nowAccessToken = nowAccessToken)) {
true -> {
reattemptRequest(chain, originalRequest, nowAccessToken)
}

refreshTokenResponse.close()
val newRequest = originalRequest.newBuilder()
.addHeader(ACCESS_TOKEN, gpDataSource.accessToken).build()
return chain.proceed(newRequest)
} else {
with(context) {
CoroutineScope(Dispatchers.Main).launch {
startActivity(
Intent(
this@with,
SignActivity::class.java
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
false -> {
handleTokenRefresh(chain, originalRequest, nowAccessToken, nowRefreshToken)
}
gpDataSource.clear()
}
}
}
return response

/**
* 재발급을 요청하는 엑세스 토큰이 유효한 토큰인지 확인
* 유효하다면 재발급하지 않는다.
* 요청한 토큰과 현재 재발급 시점에서의 토큰이 다르다면, 이전에 이미 토큰이 재발급 된 것이라고 판단
*/
private fun isTokenValid(requestAccessToken: String, nowAccessToken: String): Boolean =
requestAccessToken != nowAccessToken && nowAccessToken.isNotBlank()

/**
* 토큰 갱신 및 처리
*/
private fun handleTokenRefresh(
chain: Interceptor.Chain,
originalRequest: Request,
accessToken: String,
refreshToken: String
): Response {
val refreshTokenResponse = refreshToken(chain, originalRequest, accessToken, refreshToken)

when (refreshTokenResponse.isSuccessful) {
true -> {
return handleSuccessfulTokenRefresh(chain, originalRequest, refreshTokenResponse)
}

false -> {
return handleFailedTokenRefresh(refreshTokenResponse)
}
}
}

/**
* 토큰 재발급 성공 처리
*/
private fun handleSuccessfulTokenRefresh(
chain: Interceptor.Chain,
originalRequest: Request,
refreshTokenResponse: Response,
): Response {
val responseHeader = refreshTokenResponse.headers
val responseAccessToken = responseHeader[ACCESS_TOKEN]
val responseRefreshToken = responseHeader[REFRESH_TOKEN]

with(gpDataSource) {
accessToken = BEARER_PREFIX + responseAccessToken.toString()
refreshToken = BEARER_PREFIX + responseRefreshToken.toString()
}

refreshTokenResponse.close()

// 새 토큰으로 원래 요청을 다시 시도
return reattemptRequest(chain, originalRequest, GPDataSource.ACCESS_TOKEN)
}

/**
* 토큰 재발급 실패 처리
*/
private fun handleFailedTokenRefresh(refreshTokenResponse: Response): Response {
refreshTokenResponse.close()

// 로그아웃 처리 및 로그인 화면으로 이동
CoroutineScope(Dispatchers.Main).launch {
context.startActivity(
Intent(context, SignActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
gpDataSource.clear()

return refreshTokenResponse
}

/**
* 토큰 재발급 api 호출
*/
private fun refreshToken(
chain: Interceptor.Chain,
originalRequest: Request,
accessToken: String,
refreshToken: String,
): Response {
val refreshTokenRequest = originalRequest.newBuilder().get()
.url("${BuildConfig.GP_BASE_URL}auth/refresh")
.addHeader(ACCESS_TOKEN, accessToken)
.addHeader(REFRESH_TOKEN, refreshToken)
.build()
val refreshTokenResponse = chain.proceed(refreshTokenRequest)
return refreshTokenResponse
}

/**
* 재요청
*/
private fun reattemptRequest(
chain: Interceptor.Chain,
originalRequest: Request,
accessToken: String
): Response {
val newRequest = originalRequest.newBuilder().addHeader(ACCESS_TOKEN, accessToken).build()
return chain.proceed(newRequest)
}

companion object {
const val ACCESS_TOKEN = "Authorization"
const val REFRESH_TOKEN = "Authorization-refresh"
const val BEARER_PREFIX = "Bearer "
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ class HomeViewModel @Inject constructor(
fetchBestList()
}

// 하나의 스코프를 만들어서 각 함수들이 동기적으로 처리되도록 수정
fun fetchBestList() {
private fun fetchBestList() {
viewModelScope.launch {
homeRepository.fetchBestBakery()
.onSuccess { bestBakeryList ->
Expand All @@ -53,7 +52,9 @@ class HomeViewModel @Inject constructor(
.onFailure { throwable ->
_bestBakeryListState.value = UiState.Error(throwable.message)
}
}

viewModelScope.launch {
homeRepository.fetchBestReview()
.onSuccess { bestReviewList ->
_bestReviewListState.value = UiState.Success(bestReviewList)
Expand Down
Loading