Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,29 @@ 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.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 +44,40 @@ 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",
)

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 +94,52 @@ val httpClientModule = module {
)
}

install(Auth) {
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
}
Comment thread
lluke0 marked this conversation as resolved.
Outdated

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
}
}

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

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 | 🟡 Minor

인증 제외 경로는 부분 문자열이 아니라 정확히 비교하세요.

contains()라서 users/signin-extraauth/refresh-preview 같은 경로도 제외 대상으로 처리됩니다. 상대 경로를 정규화한 뒤 AUTH_EXCLUDED_PATHS와 정확히 비교하는 편이 안전합니다.

🛠️ 제안
                     sendWithoutRequest { request ->
-                        val requestPath = request.url.pathSegments.joinToString("/")
-                        AUTH_EXCLUDED_PATHS.none { path ->
-                            requestPath.contains(path)
-                        }
+                        val requestPath = request.url.pathSegments
+                            .filter { it.isNotBlank() }
+                            .dropWhile { it == "api" }
+                            .joinToString("/")
+                        requestPath !in AUTH_EXCLUDED_PATHS
                     }
🤖 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 134 - 138, 현재 sendWithoutRequest 블록에서 requestPath를
AUTH_EXCLUDED_PATHS와 부분 문자열 비교(contains)하여 오탐이 발생합니다;
request.url.pathSegments.joinToString("/")로 만든 상대 경로를 정규화(불필요한 앞/뒤 슬래시 제거 및 URL
디코딩 등)한 뒤 AUTH_EXCLUDED_PATHS의 각 항목과 정확한 문자열 비교(==)로 검사하도록 바꾸세요; 대상 식별자는
sendWithoutRequest, requestPath, AUTH_EXCLUDED_PATHS 입니다.

}
}
}

install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Expand Down Expand Up @@ -83,7 +173,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