Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.umcspot.spot.network

import androidx.datastore.core.DataStore
import com.umcspot.spot.datastore.token.SpotTokenData
import com.umcspot.spot.datastore.userId.SpotUserIdData
import com.umcspot.spot.network.service.TokenRefreshService
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject

class TokenAuthenticator @Inject constructor(
private val spotTokenDataStore: DataStore<SpotTokenData>,
private val spotUserIdDataStore: DataStore<SpotUserIdData>,
private val tokenRefreshService: TokenRefreshService
) : Authenticator {

private val refreshLock = Any()
private object RefreshAttempted

override fun authenticate(route: Route?, response: Response): Request? {
val refreshAttempted = response.request.tag(RefreshAttempted::class.java) != null
if (refreshAttempted) {
clearAuthData()
return null
}
if (responseCount(response) >= 2) return null
if (response.request.url.encodedPath.endsWith("/api/auth/reissue")) return null

val requestAccessToken = response.request.header("Authorization")
?.removePrefix("Bearer ")
.orEmpty()
val latestAccessToken = runBlocking {
spotTokenDataStore.data.first().accessToken
}

if (latestAccessToken.isNotBlank() && latestAccessToken != requestAccessToken) {
return response.request.newBuilder()
.header("Authorization", "Bearer $latestAccessToken")
.tag(RefreshAttempted::class.java, RefreshAttempted)
.build()
}

synchronized(refreshLock) {
val tokenData = runBlocking { spotTokenDataStore.data.first() }
if (tokenData.accessToken.isNotBlank() && tokenData.accessToken != requestAccessToken) {
return response.request.newBuilder()
.header("Authorization", "Bearer ${tokenData.accessToken}")
.tag(RefreshAttempted::class.java, RefreshAttempted)
.build()
}

val refreshToken = tokenData.refreshToken
if (refreshToken.isBlank()) {
clearAuthData()
return null
}

val refreshResponse = runBlocking {
try {
tokenRefreshService.refreshTokenData(refreshToken)
} catch (e: Exception) {
clearAuthData()
null
}
}
Comment on lines +62 to +69
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

예외가 삼켜지고 있어 디버깅이 어려움

토큰 갱신 실패 시 예외가 로깅 없이 삼켜지고 있습니다. 프로덕션 환경에서 토큰 갱신 문제를 진단하기 어려워질 수 있습니다.

🛠️ 로깅 추가 제안
             val refreshResponse = runBlocking {
                 try {
                     tokenRefreshService.refreshTokenData(refreshToken)
                 } catch (e: Exception) {
+                    android.util.Log.e("TokenAuthenticator", "Token refresh failed", e)
                     clearAuthData()
                     null
                 }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val refreshResponse = runBlocking {
try {
tokenRefreshService.refreshTokenData(refreshToken)
} catch (e: Exception) {
clearAuthData()
null
}
}
val refreshResponse = runBlocking {
try {
tokenRefreshService.refreshTokenData(refreshToken)
} catch (e: Exception) {
android.util.Log.e("TokenAuthenticator", "Token refresh failed", e)
clearAuthData()
null
}
}
🧰 Tools
🪛 detekt (1.23.8)

[warning] 65-65: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🤖 Prompt for AI Agents
In `@core/network/src/main/java/com/umcspot/spot/network/TokenAuthenticator.kt`
around lines 62 - 69, The current runBlocking block around
tokenRefreshService.refreshTokenData(refreshToken) swallows exceptions without
logging, hindering diagnostics; update the catch to log the caught exception
(including message and stack trace) before calling clearAuthData() and returning
null — reference the TokenAuthenticator class, the runBlocking block where
refreshResponse is assigned, tokenRefreshService.refreshTokenData(...), and
clearAuthData() so you add a logger call (e.g., logger.error or appropriate
logging utility used in this class) that includes the exception object.

if (refreshResponse == null) return null

if (!refreshResponse.isSuccess) {
clearAuthData()
return null
}

val result = refreshResponse.result
runBlocking {
spotTokenDataStore.updateData { current ->
current.copy(
accessToken = result.accessToken,
refreshToken = result.refreshToken
)
}
spotUserIdDataStore.updateData { current ->
current.copy(userId = result.userId)
}
}

return response.request.newBuilder()
.header("Authorization", "Bearer ${result.accessToken}")
.tag(RefreshAttempted::class.java, RefreshAttempted)
.build()
}
}

private fun clearAuthData() {
runBlocking {
spotTokenDataStore.updateData { current ->
current.copy(accessToken = "", refreshToken = "")
}
spotUserIdDataStore.updateData { current ->
current.copy(userId = "")
}
}
}

private fun responseCount(response: Response): Int {
var count = 1
var prior = response.priorResponse
while (prior != null) {
count++
prior = prior.priorResponse
}
return count
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact
import com.umcspot.spot.common.BuildConfigFieldProvider
import com.umcspot.spot.common.WeatherConfigFieldProvider
import com.umcspot.spot.network.AuthInterceptor
import com.umcspot.spot.network.TokenAuthenticator
import com.umcspot.spot.network.service.TokenRefreshService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
Expand Down Expand Up @@ -34,11 +36,23 @@ object NetworkModule {
@SpotApi
fun providesSpotOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
authInterceptor: AuthInterceptor
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator
): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator)
.build()

@Provides
@Singleton
@SpotRefreshApi
fun providesSpotRefreshOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()

// ---------- Weather API ----------
Expand Down Expand Up @@ -74,7 +88,23 @@ object NetworkModule {
.addConverterFactory(converterFactory)
.build()

@Provides @Singleton @WeatherApi
@Provides
@Singleton
@SpotRefreshApi
fun providesSpotRefreshRetrofit(
@SpotRefreshApi client: OkHttpClient,
converterFactory: Converter.Factory,
buildConfigProvider: BuildConfigFieldProvider
): Retrofit =
Retrofit.Builder()
.baseUrl(buildConfigProvider.get().baseUrl)
.client(client)
.addConverterFactory(converterFactory)
.build()

@Provides
@Singleton
@WeatherApi
fun providesWeatherRetrofit(
@WeatherApi weatherClient: OkHttpClient,
converterFactory: Converter.Factory,
Expand All @@ -85,4 +115,11 @@ object NetworkModule {
.client(weatherClient)
.addConverterFactory(converterFactory)
.build()
}

@Provides
@Singleton
fun providesTokenRefreshService(
@SpotRefreshApi retrofit: Retrofit
): TokenRefreshService =
retrofit.create(TokenRefreshService::class.java)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ annotation class SpotApi

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class WeatherApi
annotation class WeatherApi

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SpotRefreshApi
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.umcspot.spot.network.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class TokenRefreshResponse(
@SerialName("id")
val userId: String,
@SerialName("accessToken")
val accessToken: String,
@SerialName("refreshToken")
val refreshToken: String
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
package network.service
package com.umcspot.spot.network.service

class TokenRefreshService {

import com.umcspot.spot.network.model.BaseResponse
import com.umcspot.spot.network.model.TokenRefreshResponse
import retrofit2.http.Header
import retrofit2.http.POST

interface TokenRefreshService {

@POST("/api/auth/reissue")
suspend fun refreshTokenData(
@Header("refreshToken") refreshToken: String,
): BaseResponse<TokenRefreshResponse>
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.umcspot.spot.login.datasource

import com.umcspot.spot.login.dto.response.TokenResponseDto
import com.umcspot.spot.model.SocialLoginType
import com.umcspot.spot.network.model.BaseResponse
import com.umcspot.spot.network.model.NullResultResponse

interface LoginDataSource {
suspend fun finishSocialLogin(type : String, accessToken : String): BaseResponse<TokenResponseDto>
suspend fun getCallBackToken(type : String, accessToken : String): BaseResponse<TokenResponseDto>

suspend fun refreshTokenData(refreshToken : String) : BaseResponse<TokenResponseDto>

suspend fun spotLogout(): NullResultResponse
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package com.umcspot.spot.login.datasourceimpl

import androidx.datastore.core.DataStore
import com.umcspot.spot.login.datasource.LoginDataSource
import com.umcspot.spot.login.dto.response.TokenResponseDto
import com.umcspot.spot.login.service.LoginService
import com.umcspot.spot.network.model.BaseResponse
import com.umcspot.spot.network.model.NullResultResponse
import javax.inject.Inject

class LoginDataSourceImpl @Inject constructor(
private val loginService: LoginService
) : LoginDataSource {

override suspend fun finishSocialLogin(
override suspend fun getCallBackToken(
type: String,
accessToken: String
): BaseResponse<TokenResponseDto> =
loginService.getCallBackToken(type, accessToken)

override suspend fun refreshTokenData(refreshToken: String): BaseResponse<TokenResponseDto> =
loginService.refreshTokenData(refreshToken)

override suspend fun spotLogout() : NullResultResponse =
loginService.spotLogout()
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,26 @@ package com.umcspot.spot.login.repositoryimpl
import androidx.datastore.core.DataStore
import com.umcspot.spot.datastore.token.SpotTokenData
import com.umcspot.spot.datastore.userId.SpotUserIdData
import com.umcspot.spot.login.datasource.LoginDataSource
import com.umcspot.spot.login.mapper.toDomain
import com.umcspot.spot.login.service.LoginService
import com.umcspot.spot.model.SocialLoginType
import com.umcspot.spot.token.model.TokenResult
import com.umcspot.spot.token.repository.TokenRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject

class LoginRepositoryImpl @Inject constructor(
private val studyService: LoginService,
private val loginDataStore: LoginDataSource,
private val spotTokenDataStore: DataStore<SpotTokenData>,
private val spotUserIdDataStore: DataStore<SpotUserIdData>
) : TokenRepository {

override suspend fun finishSocialLogin(
type: SocialLoginType,
accessToken: String
): Result<TokenResult> =
): Result<Unit> =
runCatching {
val response = studyService.getCallBackToken(type.title, accessToken)
val response = loginDataStore.getCallBackToken(type.title, accessToken)
val tokenResult: TokenResult = response.result.toDomain()

spotTokenDataStore.updateData { current ->
Expand All @@ -36,7 +37,45 @@ class LoginRepositoryImpl @Inject constructor(
userId = tokenResult.userId
)
}
}

override suspend fun refreshTokenData(
): Result<Unit> =
runCatching {
val tokenData = spotTokenDataStore.data.first()
val refreshToken = tokenData.refreshToken


require(refreshToken.isNotBlank()) { "Refresh token is empty" }

val response = loginDataStore.refreshTokenData(refreshToken)
val tokenResult: TokenResult = response.result.toDomain()

spotTokenDataStore.updateData { current ->
current.copy(
accessToken = tokenResult.accessToken,
refreshToken = tokenResult.refreshToken
)
}

tokenResult
spotUserIdDataStore.updateData { current ->
current.copy(userId = tokenResult.userId)
}
}

override suspend fun spotLogout(): Result<Unit> =
runCatching {
loginDataStore.spotLogout().code

spotTokenDataStore.updateData { current ->
current.copy(
accessToken = "",
refreshToken = ""
)
}

spotUserIdDataStore.updateData { current ->
current.copy(userId = "")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package com.umcspot.spot.login.service

import com.umcspot.spot.login.dto.response.TokenResponseDto
import com.umcspot.spot.network.model.BaseResponse
import com.umcspot.spot.network.model.NullResultResponse
import retrofit2.http.GET
import retrofit2.http.HEAD
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query

Expand All @@ -14,4 +18,13 @@ interface LoginService {
@Query("accessToken") accessToken : String
) : BaseResponse<TokenResponseDto>

@POST("/api/auth/reissue")
suspend fun refreshTokenData(
@Header("refreshToken") refreshToken : String,
) : BaseResponse<TokenResponseDto>

@POST("/api/auth/logout")
suspend fun spotLogout(
) : NullResultResponse

}
Loading