Skip to content
Merged
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: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ detekt {
dependencies {

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Feelin">
android:theme="@style/Theme.Feelin.Starting">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

Expand All @@ -41,4 +41,4 @@
</activity>
</application>

</manifest>
</manifest>
2 changes: 2 additions & 0 deletions app/src/main/java/com/lyrics/feelin/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.lyrics.feelin.navigation.FeelinNavHost
import com.lyrics.feelin.presentation.designsystem.theme.FeelinTheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.lyrics.feelin.core.data.datasource.remote

import com.lyrics.feelin.core.data.datasource.remote.dto.RefreshTokenRequestDto
import com.lyrics.feelin.core.data.datasource.remote.dto.ServerStatusResponseDto
import com.lyrics.feelin.core.data.datasource.remote.dto.SignInRequestDto
import com.lyrics.feelin.core.data.util.safeApiCall
Expand Down Expand Up @@ -29,6 +30,18 @@ class AuthRemoteDataSource @Inject constructor(
return safeApiCall { authApiService.signOut() }
}

suspend fun validateToken(): Result<ServerStatusResponseDto> {
return safeApiCall { authApiService.validateToken() }
}

suspend fun reIssueToken(refreshToken: String): Result<AuthToken> {
return safeApiCall {
authApiService.reIssueToken(
refreshTokenDto = RefreshTokenRequestDto(refreshToken = refreshToken)
)
}
}

// TODO(@이대근): 온보딩 화면 제작하면서 연동 필요 2025.10.12.
suspend fun signUp(signUpData: SignUpData): Result<AuthToken> {
return safeApiCall { authApiService.signUp(body = signUpData) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ object NetworkModule {
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("http://api.feelinapp.com/") // TODO(@이대근): productFlavor 변수화 필요 2025.10.05.
.baseUrl("http://dev.feelinapp.com/") // TODO(@이대근): productFlavor 변수화 필요 2025.10.05.
Comment thread
gdaegeun539 marked this conversation as resolved.
Comment thread
gdaegeun539 marked this conversation as resolved.
Comment thread
gdaegeun539 marked this conversation as resolved.
.addConverterFactory(
Comment thread
gdaegeun539 marked this conversation as resolved.
Comment thread
gdaegeun539 marked this conversation as resolved.
Json.asConverterFactory(
"application/json; charset=UTF-8".toMediaType()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
package com.lyrics.feelin.core.data.interceptor

import android.util.Log
import com.lyrics.feelin.core.data.datasource.remote.AuthApiService
import com.lyrics.feelin.core.data.datasource.remote.dto.RefreshTokenRequestDto
import com.lyrics.feelin.core.data.manager.AuthManager
import com.lyrics.feelin.core.data.manager.AuthTokenRefresher
import dagger.Lazy
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Interceptor
import okhttp3.Response
import retrofit2.HttpException

/**
* 모든 API 요청에 Access Token을 자동으로 추가합니다.
Expand Down Expand Up @@ -57,53 +56,45 @@ class AuthInterceptor @Inject constructor(
* - 갱신 실패 시 null 반환 (로그아웃 처리는 상위 레이어에서)
*
* **주의:**
* - AuthApiService를 Lazy 주입 (순환 참조 방지)
* - AuthTokenRefresher를 Lazy 주입 (순환 참조 방지)
*
* **Detekt Suppression:**
* - `TooGenericExceptionCaught`: 토큰 갱신 실패 시 모든 예외를 동일하게 처리 (null 반환).
*/
@Singleton
class TokenAuthenticator @Inject constructor(
private val authManager: AuthManager,
private val authApiService: Lazy<AuthApiService>
private val authTokenRefresher: Lazy<AuthTokenRefresher>
) : Authenticator {

@Suppress("TooGenericExceptionCaught")
override fun authenticate(route: okhttp3.Route?, response: Response): okhttp3.Request? {
// 이미 재시도한 경우 중단 (무한 루프 방지)
if (response.priorResponse != null) {
if (response.priorResponse != null || response.request.url.encodedPath == TOKEN_REISSUE_PATH) {
return null
}

return runBlocking {
try {
authManager.initializationComplete.await()

// Refresh Token으로 갱신 시도
val refreshToken = authManager.refreshToken.value ?: return@runBlocking null
val dto = RefreshTokenRequestDto(refreshToken)

// 토큰 재발급 API 호출
val tokenResponse = authApiService.get().reIssueToken(dto).body()
?: return@runBlocking null

// 새 토큰 저장
authManager.updateAccessToken(
newAccessToken = tokenResponse.accessToken
)
authManager.updateRefreshToken(
newRefreshToken = tokenResponse.refreshToken
)
val staleAccessToken = response.request.header("Authorization")
?.removePrefix(BEARER_PREFIX)
val refreshResult = authTokenRefresher.get()
.refreshServerToken(staleAccessToken = staleAccessToken)
val tokenResponse = refreshResult.getOrNull()
?: run {
refreshResult.exceptionOrNull()?.let { error ->
Log.w(TAG, "Token refresh failed", error)
}
return@runBlocking null
}

// 재시도 요청 생성
response.request.newBuilder()
.header("Authorization", "Bearer ${tokenResponse.accessToken}")
.build()
} catch (e: HttpException) {
// HTTP 에러 (401, 403 등) - 토큰이 완전히 만료됨
// null 반환 → 원래 401이 상위로 전달 → 로그아웃 처리
Log.w(TAG, "Token refresh failed", e)
null
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
// 네트워크 오류 - 재시도하지 않고 실패 처리
Log.w(TAG, "Token refresh failed", e)
Expand All @@ -118,5 +109,7 @@ class TokenAuthenticator @Inject constructor(

companion object {
private const val TAG = "TokenAuthenticator"
private const val BEARER_PREFIX = "Bearer "
private const val TOKEN_REISSUE_PATH = "/api/v1/auth/token"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.lyrics.feelin.core.data.manager

import com.lyrics.feelin.core.data.datasource.remote.AuthRemoteDataSource
import com.lyrics.feelin.core.domain.model.AuthToken
import com.lyrics.feelin.util.toServerErrorDto
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import retrofit2.HttpException

class AuthTokenRefreshException(
val errorCode: String?,
cause: Throwable,
) : Exception(cause)

/**
* 서버 JWT 재발급 공통 경로입니다.
*
* 이 클래스는 런타임 401 재인증에서도 사용되므로
* 일시적인 네트워크/서버 실패만으로는 저장된 refresh token을 삭제하지 않습니다.
* 토큰 만료·무효처럼 서버가 세션 종료를 확정한 인증 에러 코드에만 토큰을 정리하고,
* 앱 시작 자동 로그인의 더 강한 실패 정책은 [AuthRepository.restoreSession]에서 조합합니다.
*/
@Singleton
class AuthTokenRefresher @Inject constructor(
private val authRemoteDataSource: AuthRemoteDataSource,
private val authManager: AuthManager
) {
private val refreshMutex = Mutex()

suspend fun refreshServerToken(staleAccessToken: String? = null): Result<AuthToken> {
return refreshMutex.withLock {
authManager.initializationComplete.await()

val cachedAccessToken = authManager.accessToken.value
val cachedRefreshToken = authManager.refreshToken.value
val cachedUserId = authManager.userId.value

val cachedAuthToken = createCachedAuthToken(
staleAccessToken = staleAccessToken,
cachedAccessToken = cachedAccessToken,
cachedRefreshToken = cachedRefreshToken,
cachedUserId = cachedUserId,
)
if (cachedAuthToken != null) {
return@withLock Result.success(
cachedAuthToken
)
}

val refreshToken = cachedRefreshToken
?: return@withLock clearTokensAndFail(IllegalStateException("Refresh token is null"))

authRemoteDataSource.reIssueToken(refreshToken = refreshToken)
.fold(
onSuccess = { authToken ->
authManager.saveServerToken(
accessToken = authToken.accessToken,
refreshToken = authToken.refreshToken,
userId = authToken.userId,
)
Result.success(authToken)
},
onFailure = { exception ->
if (exception is CancellationException) {
throw exception
}
handleRefreshFailure(exception)
},
)
}
}

private suspend fun handleRefreshFailure(exception: Throwable): Result<AuthToken> {
val refreshException = exception.toAuthTokenRefreshException()

// 401 재인증 경로에서는 일시 실패 후 다음 요청에서 다시 복구할 수 있도록 토큰을 보존합니다.
return if (refreshException.errorCode in TERMINAL_AUTH_ERROR_CODES) {
clearTokensAndFail(refreshException)
} else {
Result.failure(refreshException)
}
}

private suspend fun clearTokensAndFail(exception: Throwable): Result<AuthToken> {
authManager.clearTokens()
return Result.failure(exception)
}

private fun Throwable.toAuthTokenRefreshException(): AuthTokenRefreshException {
val errorCode = if (this is HttpException) {
toServerErrorDto().errorCode
} else {
null
}

return AuthTokenRefreshException(
errorCode = errorCode,
cause = this,
)
}

private fun createCachedAuthToken(
staleAccessToken: String?,
cachedAccessToken: String?,
cachedRefreshToken: String?,
cachedUserId: Long?
): AuthToken? {
val staleToken = staleAccessToken ?: return null
val accessToken = cachedAccessToken ?: return null
val refreshToken = cachedRefreshToken ?: return null
val userId = cachedUserId ?: return null

if (accessToken == staleToken) {
return null
}

return AuthToken(
accessToken = accessToken,
refreshToken = refreshToken,
userId = userId,
)
}

companion object {
private val TERMINAL_AUTH_ERROR_CODES = setOf(
"01001",
"01002",
"01004",
"01008",
)
}
}
Loading
Loading