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
2 changes: 2 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,14 @@ kotlin {
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth)

implementation(libs.ksafe)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class MainViewModel(
is NetworkException -> { mviStore.setState { copy(showNetworkErrorDialog = true) } }
// is ConnectException -> { mviStore.setState { copy(showNetworkErrorDialog = true) } }
else -> {
onShowToast(throwable.message ?: UnknownException().message)
onShowToast("네트워크 오류가 발생했습니다.")
throwable.record()
}
Comment thread
lluke0 marked this conversation as resolved.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ data class AppleSignInResult(
val userId: String,
val email: String?,
val fullName: String?,
val nonce: String,
)

expect class AppleSignInClient() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import com.chukchukhaksa.mobile.domain.auth.model.RefreshTokenResult
import com.chukchukhaksa.mobile.domain.auth.model.SignInResult

interface RemoteAuthDataSource {
suspend fun signIn(idToken: String, nonce: String): SignInResult
suspend fun signIn(provider: String, idToken: String, nonce: String): SignInResult
suspend fun refreshToken(refreshToken: String): RefreshTokenResult
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ class AuthRepositoryImpl(
private val localAuthDataSource: LocalAuthDataSource,
) : AuthRepository {

override suspend fun signIn(idToken: String, nonce: String): SignInResult {
return remoteAuthDataSource.signIn(idToken = idToken, nonce = nonce)
override suspend fun signIn(provider: String, idToken: String, nonce: String): SignInResult {
return remoteAuthDataSource.signIn(provider = provider, idToken = idToken, nonce = nonce)
}

override suspend fun refreshToken() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ val domainModule = module {
factory { CheckNeedForceUpdateUseCase(get())}

// Auth use cases
factory { AppleLoginUseCase(get()) }
factory { AppleLoginUseCase(get(), get()) }
factory { CheckAuthStateUseCase(get()) }
factory { KakaoLoginUseCase(get(), get()) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.chukchukhaksa.mobile.domain.auth.repository
import com.chukchukhaksa.mobile.domain.auth.model.SignInResult

interface AuthRepository {
suspend fun signIn(idToken: String, nonce: String): SignInResult
suspend fun signIn(provider: String, idToken: String, nonce: String): SignInResult
Comment thread
lluke0 marked this conversation as resolved.
suspend fun refreshToken()
suspend fun saveTokens(accessToken: String, refreshToken: String)
suspend fun getAccessToken(): String?
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
package com.chukchukhaksa.mobile.domain.auth.usecase

import com.chukchukhaksa.mobile.common.kmp.AppleSignInResult
import com.chukchukhaksa.mobile.common.kmp.AppleSignInClient
import com.chukchukhaksa.mobile.domain.auth.model.SignInResult
import com.chukchukhaksa.mobile.domain.auth.repository.AuthRepository
import com.chukchukhaksa.mobile.domain.common.runCatchingIgnoreCancelled

class AppleLoginUseCase(
private val appleSignInClient: AppleSignInClient,
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(): Result<AppleSignInResult> = runCatchingIgnoreCancelled {
appleSignInClient.signIn()
suspend operator fun invoke(): Result<SignInResult> = runCatchingIgnoreCancelled {
val appleResult = appleSignInClient.signIn()
val signInResult = authRepository.signIn(
provider = "APPLE",
Comment thread
lluke0 marked this conversation as resolved.
idToken = appleResult.identityToken,
nonce = appleResult.nonce,
)
authRepository.saveTokens(
accessToken = signInResult.accessToken,
refreshToken = signInResult.refreshToken,
)
signInResult
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class KakaoLoginUseCase(
runCatchingIgnoreCancelled {
val kakaoResult = kakaoSignInClient.signIn(context)
val signInResult = authRepository.signIn(
provider = "KAKAO",
idToken = kakaoResult.idToken,
nonce = kakaoResult.nonce,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ fun LandingScreen(
iconRes = Res.drawable.ic_apple_logo,
containerColor = Black100,
contentColor = White100,
enabled = true,
enabled = !uiState.isLoading,
onClick = onAppleLogin,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.chukchukhaksa.mobile.common.ui.MviStore
import com.chukchukhaksa.mobile.common.ui.mviStore
import com.chukchukhaksa.mobile.domain.auth.usecase.AppleLoginUseCase
import com.chukchukhaksa.mobile.domain.auth.usecase.KakaoLoginUseCase
import kotlinx.coroutines.launch

class LandingViewModel(
private val kakaoLoginUseCase: KakaoLoginUseCase,
private val appleLoginUseCase: AppleLoginUseCase,
) : ViewModel() {

val mviStore: MviStore<LandingState, LandingSideEffect> = mviStore(LandingState())
Expand All @@ -33,6 +35,21 @@ class LandingViewModel(
}

fun onAppleLogin() {
mviStore.postSideEffect(LandingSideEffect.ShowToast("준비 중입니다"))
if (mviStore.uiState.value.isLoading) return
mviStore.setState { copy(isLoading = true) }

viewModelScope.launch {
try {
appleLoginUseCase()
.onSuccess {
mviStore.postSideEffect(LandingSideEffect.NavigateHome)
}
.onFailure { throwable ->
mviStore.postSideEffect(LandingSideEffect.HandleException(throwable))
}
} finally {
mviStore.setState { copy(isLoading = false) }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.chukchukhaksa.mobile.remote.auth

import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow

class AuthEventBus {
private val _events = MutableSharedFlow<AuthEvent>(extraBufferCapacity = 1)
val events: SharedFlow<AuthEvent> = _events.asSharedFlow()

fun emit(event: AuthEvent = AuthEvent.TokenExpired) {
_events.tryEmit(event)
}
Comment thread
lluke0 marked this conversation as resolved.
}

sealed interface AuthEvent {
data object TokenExpired : AuthEvent
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ class RemoteAuthDataSourceImpl(
private val httpClient: HttpClient,
) : RemoteAuthDataSource {

override suspend fun signIn(idToken: String, nonce: String): SignInResult {
override suspend fun signIn(provider: String, idToken: String, nonce: String): SignInResult {
val response = httpClient.post("users/signin") {
setBody(SignInRequest(idToken = idToken, nonce = nonce))
setBody(SignInRequest(provider = provider, idToken = idToken, nonce = nonce))
}.body<ApiResponse<SignInResponse>>()

return response.getDataOrThrow().toSignInResult()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable

@Serializable
data class SignInRequest(
val provider: String,
@SerialName("id_token") val idToken: String,
val nonce: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,30 @@ package com.chukchukhaksa.mobile.remote.di

import com.chukchukhaksa.mobile.common.kmp.httpClientEngineFactory
import com.chukchukhaksa.mobile.common.kmp.isDebug
import com.chukchukhaksa.mobile.data.auth.datasource.LocalAuthDataSource
import com.chukchukhaksa.mobile.remote.auth.AuthEventBus
import com.chukchukhaksa.mobile.remote.auth.model.RefreshRequest
import com.chukchukhaksa.mobile.remote.auth.model.RefreshResponse
import com.chukchukhaksa.mobile.remote.common.ApiResponse
import com.chukchukhaksa.mobile.remote.common.getDataOrThrow
import io.github.aakira.napier.Napier
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.AuthConfig
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.observer.ResponseObserver
import io.ktor.client.plugins.plugin
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
Expand All @@ -32,8 +45,90 @@ private fun String.prettyPrintJson(): String = try {
this
}

private val BASE_URL = if (isDebug) "https://dev.api.cchaksa.com/api/" else "https://api.cchaksa.com/api/"

internal val AUTH_EXCLUDED_PATHS = listOf(
"auth/refresh",
"users/signin",
)

internal fun AuthConfig.configureBearerAuth(
localAuthDataSource: LocalAuthDataSource,
authEventBus: AuthEventBus,
refreshClient: HttpClient,
) {
bearer {
loadTokens {
val accessToken = localAuthDataSource.getAccessToken()
val refreshToken = localAuthDataSource.getRefreshToken()
if (accessToken != null && refreshToken != null) {
BearerTokens(accessToken, refreshToken)
} else {
null
}
}

refreshTokens {
val currentRefreshToken = localAuthDataSource.getRefreshToken()
if (currentRefreshToken == null) {
localAuthDataSource.clearTokens()
authEventBus.emit()
return@refreshTokens null
}

try {
val response = refreshClient.post("auth/refresh") {
setBody(RefreshRequest(refreshToken = currentRefreshToken))
}.body<ApiResponse<RefreshResponse>>()

val result = response.getDataOrThrow()
localAuthDataSource.saveAccessToken(result.accessToken)
localAuthDataSource.saveRefreshToken(result.refreshToken)
BearerTokens(result.accessToken, result.refreshToken)
} catch (e: Exception) {
Napier.e("Token refresh failed", e)
localAuthDataSource.clearTokens()
authEventBus.emit()
null
Comment on lines +88 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

일시적 refresh 실패까지 로그아웃 처리하면 세션이 불필요하게 끊깁니다.

여기서는 catch (Exception)이 타임아웃, 네트워크 단절, 5xx, 역직렬화 오류까지 모두 clearTokens()/emit()로 처리합니다. 이 경우 refresh token이 아직 유효해도 사용자를 강제로 로그아웃시킬 수 있으니, 토큰 삭제는 서버가 refresh token 무효를 확정한 응답에서만 수행하고 그 외 실패는 토큰을 보존한 채 요청만 실패시키는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`
around lines 88 - 92, The catch block in HttpClientModule.kt currently treats
any Exception as a guaranteed token-invalidation case and calls
localAuthDataSource.clearTokens() and authEventBus.emit(), which disconnects
users on transient failures; change the handler in the refresh flow (the catch
around the refresh call) to only clear tokens and emit logout when the exception
is a server response that definitively indicates an invalid/expired refresh
token (e.g., inspect ResponseException / HttpResponseException and check
response.status is the specific status the backend uses for invalid refresh
tokens like 401/403 or a dedicated error code), otherwise preserve tokens and
propagate or return a failed refresh result without calling
localAuthDataSource.clearTokens() or authEventBus.emit().

}
}

sendWithoutRequest { request ->
val requestPath = request.url.pathSegments.joinToString("/")
AUTH_EXCLUDED_PATHS.none { path ->
requestPath.contains(path)
}
}
}
}

val httpClientModule = module {
single { AuthEventBus() }

single {
val localAuthDataSource: LocalAuthDataSource = get()
val authEventBus: AuthEventBus = get()

val refreshClient = HttpClient(httpClientEngineFactory) {
install(HttpTimeout) {
requestTimeoutMillis = 10_000
connectTimeoutMillis = 5_000
socketTimeoutMillis = 10_000
}
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
},
)
}
defaultRequest {
url(BASE_URL)
contentType(ContentType.Application.Json)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

HttpClient(httpClientEngineFactory) {
install(HttpTimeout) {
requestTimeoutMillis = 15_000
Expand All @@ -50,6 +145,10 @@ val httpClientModule = module {
)
}

install(Auth) {
configureBearerAuth(localAuthDataSource, authEventBus, refreshClient)
}

install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Expand Down Expand Up @@ -83,7 +182,7 @@ val httpClientModule = module {
}

defaultRequest {
url("https://api.cchaksa.com/api/")
url(BASE_URL)
contentType(ContentType.Application.Json)
}
}.also { client ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.chukchukhaksa.mobile.remote.auth

import com.chukchukhaksa.mobile.data.auth.datasource.LocalAuthDataSource

class FakeLocalAuthDataSource(
initialAccessToken: String? = null,
initialRefreshToken: String? = null,
) : LocalAuthDataSource {
private var accessToken: String? = initialAccessToken
private var refreshToken: String? = initialRefreshToken

override suspend fun saveAccessToken(token: String) {
accessToken = token
}

override suspend fun getAccessToken(): String? = accessToken

override suspend fun saveRefreshToken(token: String) {
refreshToken = token
}

override suspend fun getRefreshToken(): String? = refreshToken

override suspend fun clearTokens() {
accessToken = null
refreshToken = null
}
}
Loading
Loading