diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 61a087483..65c11f819 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -58,8 +58,10 @@ kotlin { implementation(libs.glance.material3) implementation(libs.compose.runtime) implementation(libs.kakao.sdk.v2.user) + implementation(libs.ktor.client.okhttp) } iosMain.dependencies { + implementation(libs.ktor.client.darwin) } commonMain.dependencies { implementation(compose.runtime) @@ -92,6 +94,11 @@ kotlin { implementation(libs.kmp.firebase.analytics) implementation(libs.napier) implementation(libs.kinappbrowser) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.logging) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/HttpClientEngine.android.kt b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/HttpClientEngine.android.kt new file mode 100644 index 000000000..cc3640558 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/HttpClientEngine.android.kt @@ -0,0 +1,6 @@ +package com.chukchukhaksa.mobile.common.kmp + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.okhttp.OkHttp + +actual val httpClientEngineFactory: HttpClientEngineFactory<*> = OkHttp diff --git a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.android.kt b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.android.kt index fbdb15e7d..06197ee5e 100644 --- a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.android.kt +++ b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.android.kt @@ -17,6 +17,7 @@ actual class KakaoSignInClient { ?: throw IllegalStateException("Android Context is required for Kakao Login") val nonce = UUID.randomUUID().toString() + val hashedNonce = sha256Hex(nonce) return suspendCancellableCoroutine { continuation -> val callback: (OAuthToken?, Throwable?) -> Unit = callback@{ token, error -> @@ -37,13 +38,13 @@ actual class KakaoSignInClient { if (UserApiClient.instance.isKakaoTalkLoginAvailable(androidContext)) { UserApiClient.instance.loginWithKakaoTalk( context = androidContext, - nonce = nonce, + nonce = hashedNonce, ) { token, error -> if (error != null) { if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { UserApiClient.instance.loginWithKakaoAccount( context = androidContext, - nonce = nonce, + nonce = hashedNonce, callback = callback, ) return@loginWithKakaoTalk @@ -56,7 +57,7 @@ actual class KakaoSignInClient { } else { UserApiClient.instance.loginWithKakaoAccount( context = androidContext, - nonce = nonce, + nonce = hashedNonce, callback = callback, ) } diff --git a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256.android.kt b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256.android.kt new file mode 100644 index 000000000..a91309fce --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256.android.kt @@ -0,0 +1,8 @@ +package com.chukchukhaksa.mobile.common.kmp + +import java.security.MessageDigest + +actual fun sha256Hex(input: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/HttpClientEngine.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/HttpClientEngine.kt new file mode 100644 index 000000000..0584022c7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/HttpClientEngine.kt @@ -0,0 +1,5 @@ +package com.chukchukhaksa.mobile.common.kmp + +import io.ktor.client.engine.HttpClientEngineFactory + +expect val httpClientEngineFactory: HttpClientEngineFactory<*> diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256.kt new file mode 100644 index 000000000..7e45ff569 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256.kt @@ -0,0 +1,3 @@ +package com.chukchukhaksa.mobile.common.kmp + +expect fun sha256Hex(input: String): String diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/datasource/RemoteAuthDataSource.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/datasource/RemoteAuthDataSource.kt new file mode 100644 index 000000000..712ba0047 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/datasource/RemoteAuthDataSource.kt @@ -0,0 +1,7 @@ +package com.chukchukhaksa.mobile.data.auth.datasource + +import com.chukchukhaksa.mobile.domain.auth.model.SignInResult + +interface RemoteAuthDataSource { + suspend fun signIn(idToken: String, nonce: String): SignInResult +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/di/AuthRepositoryModule.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/di/AuthRepositoryModule.kt new file mode 100644 index 000000000..c347e0bdd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/di/AuthRepositoryModule.kt @@ -0,0 +1,12 @@ +package com.chukchukhaksa.mobile.data.auth.di + +import com.chukchukhaksa.mobile.data.auth.datasource.RemoteAuthDataSource +import com.chukchukhaksa.mobile.data.auth.repository.AuthRepositoryImpl +import com.chukchukhaksa.mobile.domain.auth.repository.AuthRepository +import com.chukchukhaksa.mobile.remote.auth.RemoteAuthDataSourceImpl +import org.koin.dsl.module + +val authRepositoryModule = module { + single { RemoteAuthDataSourceImpl(get()) } + single { AuthRepositoryImpl(get()) } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/repository/AuthRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/repository/AuthRepositoryImpl.kt new file mode 100644 index 000000000..b840f8fb6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/repository/AuthRepositoryImpl.kt @@ -0,0 +1,14 @@ +package com.chukchukhaksa.mobile.data.auth.repository + +import com.chukchukhaksa.mobile.data.auth.datasource.RemoteAuthDataSource +import com.chukchukhaksa.mobile.domain.auth.model.SignInResult +import com.chukchukhaksa.mobile.domain.auth.repository.AuthRepository + +class AuthRepositoryImpl( + private val remoteAuthDataSource: RemoteAuthDataSource, +) : AuthRepository { + + override suspend fun signIn(idToken: String, nonce: String): SignInResult { + return remoteAuthDataSource.signIn(idToken = idToken, nonce = nonce) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt index 2253a6dde..348c271e4 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt @@ -45,5 +45,5 @@ val domainModule = module { // Auth use cases factory { AppleLoginUseCase(get()) } - factory { KakaoLoginUseCase(get()) } + factory { KakaoLoginUseCase(get(), get()) } } diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.kt index e4054cce2..b6a79c11b 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.kt @@ -1,5 +1,6 @@ package com.chukchukhaksa.mobile.di +import com.chukchukhaksa.mobile.data.auth.di.authRepositoryModule import com.chukchukhaksa.mobile.data.config.di.appConfigRepositoryModule import com.chukchukhaksa.mobile.data.openlecture.di.openLectureRepositoryModule import com.chukchukhaksa.mobile.data.openmajor.di.openMajorRepositoryModule @@ -13,6 +14,7 @@ import com.chukchukhaksa.mobile.local.datasource.timetable.di.localTimetableData import com.chukchukhaksa.mobile.local.datastore.di.dataStoreModule import com.chukchukhaksa.mobile.remote.config.remoteAppConfigDataSourceModule import com.chukchukhaksa.mobile.remote.di.firebaseDatabaseModule +import com.chukchukhaksa.mobile.remote.di.httpClientModule import com.chukchukhaksa.mobile.remote.timetable.remoteOpenLectureDataSourceModule import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration @@ -36,8 +38,10 @@ fun initKoin(config: KoinAppDeclaration? = null) { openLectureRepositoryModule, presentationModule, firebaseDatabaseModule, + httpClientModule, remoteAppConfigDataSourceModule, appConfigRepositoryModule, + authRepositoryModule, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/model/SignInResult.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/model/SignInResult.kt new file mode 100644 index 000000000..eb19f7044 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/model/SignInResult.kt @@ -0,0 +1,7 @@ +package com.chukchukhaksa.mobile.domain.auth.model + +data class SignInResult( + val accessToken: String, + val refreshToken: String, + val isPortalLinked: Boolean, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/repository/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/repository/AuthRepository.kt new file mode 100644 index 000000000..bbab5c088 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/repository/AuthRepository.kt @@ -0,0 +1,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 +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/KakaoLoginUseCase.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/KakaoLoginUseCase.kt index 80542f1f0..296f5d5c1 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/KakaoLoginUseCase.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/KakaoLoginUseCase.kt @@ -1,14 +1,20 @@ package com.chukchukhaksa.mobile.domain.auth.usecase import com.chukchukhaksa.mobile.common.kmp.KakaoSignInClient -import com.chukchukhaksa.mobile.common.kmp.KakaoSignInResult +import com.chukchukhaksa.mobile.domain.auth.model.SignInResult +import com.chukchukhaksa.mobile.domain.auth.repository.AuthRepository import com.chukchukhaksa.mobile.domain.common.runCatchingIgnoreCancelled class KakaoLoginUseCase( private val kakaoSignInClient: KakaoSignInClient, + private val authRepository: AuthRepository, ) { - suspend operator fun invoke(context: Any? = null): Result = + suspend operator fun invoke(context: Any? = null): Result = runCatchingIgnoreCancelled { - kakaoSignInClient.signIn(context) + val kakaoResult = kakaoSignInClient.signIn(context) + authRepository.signIn( + idToken = kakaoResult.idToken, + nonce = kakaoResult.nonce, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/RemoteAuthDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/RemoteAuthDataSourceImpl.kt new file mode 100644 index 000000000..e90b26ba6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/RemoteAuthDataSourceImpl.kt @@ -0,0 +1,31 @@ +package com.chukchukhaksa.mobile.remote.auth + +import com.chukchukhaksa.mobile.data.auth.datasource.RemoteAuthDataSource +import com.chukchukhaksa.mobile.domain.auth.model.SignInResult +import com.chukchukhaksa.mobile.remote.auth.model.SignInRequest +import com.chukchukhaksa.mobile.remote.auth.model.SignInResponse +import com.chukchukhaksa.mobile.remote.common.ApiResponse +import com.chukchukhaksa.mobile.remote.common.getDataOrThrow +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody + +class RemoteAuthDataSourceImpl( + private val httpClient: HttpClient, +) : RemoteAuthDataSource { + + override suspend fun signIn(idToken: String, nonce: String): SignInResult { + val response = httpClient.post("users/signin") { + setBody(SignInRequest(idToken = idToken, nonce = nonce)) + }.body>() + + return response.getDataOrThrow().toSignInResult() + } +} + +private fun SignInResponse.toSignInResult() = SignInResult( + accessToken = accessToken, + refreshToken = refreshToken, + isPortalLinked = isPortalLinked, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/SignInRequest.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/SignInRequest.kt new file mode 100644 index 000000000..bfa605938 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/SignInRequest.kt @@ -0,0 +1,10 @@ +package com.chukchukhaksa.mobile.remote.auth.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SignInRequest( + @SerialName("id_token") val idToken: String, + val nonce: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/SignInResponse.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/SignInResponse.kt new file mode 100644 index 000000000..f42bbb009 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/SignInResponse.kt @@ -0,0 +1,10 @@ +package com.chukchukhaksa.mobile.remote.auth.model + +import kotlinx.serialization.Serializable + +@Serializable +data class SignInResponse( + val accessToken: String, + val refreshToken: String, + val isPortalLinked: Boolean, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/common/ApiException.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/common/ApiException.kt new file mode 100644 index 000000000..d70c69021 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/common/ApiException.kt @@ -0,0 +1,6 @@ +package com.chukchukhaksa.mobile.remote.common + +class ApiException( + val code: String, + override val message: String, +) : RuntimeException(message) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/common/ApiResponse.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/common/ApiResponse.kt new file mode 100644 index 000000000..a000f3fb2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/common/ApiResponse.kt @@ -0,0 +1,23 @@ +package com.chukchukhaksa.mobile.remote.common + +import kotlinx.serialization.Serializable + +@Serializable +data class ApiResponse( + val success: Boolean, + val data: T? = null, + val error: ApiError? = null, +) + +@Serializable +data class ApiError( + val code: String, + val message: String, +) + +fun ApiResponse.getDataOrThrow(): T { + return data ?: throw ApiException( + code = error?.code.orEmpty(), + message = error?.message ?: "알 수 없는 에러가 발생했어요.", + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt new file mode 100644 index 000000000..bf0ddc5fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt @@ -0,0 +1,88 @@ +package com.chukchukhaksa.mobile.remote.di + +import com.chukchukhaksa.mobile.common.kmp.httpClientEngineFactory +import com.chukchukhaksa.mobile.common.kmp.isDebug +import io.github.aakira.napier.Napier +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +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.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import org.koin.dsl.module + +private val prettyJson = Json { prettyPrint = true } + +private fun String.prettyPrintJson(): String = try { + val element = prettyJson.decodeFromString(this) + prettyJson.encodeToString(JsonElement.serializer(), element) +} catch (_: Exception) { + this +} + +val httpClientModule = module { + single { + HttpClient(httpClientEngineFactory) { + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 15_000 + } + + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + isLenient = true + }, + ) + } + + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + Napier.d(message, tag = "HttpClient") + } + } + level = if (isDebug) LogLevel.HEADERS else LogLevel.NONE + sanitizeHeader { header -> + header.equals(HttpHeaders.Authorization, ignoreCase = true) || + header.equals(HttpHeaders.Cookie, ignoreCase = true) || + header.equals(HttpHeaders.SetCookie, ignoreCase = true) + } + } + + if (isDebug) { + install(ResponseObserver) { + onResponse { response -> + val contentType = response.headers[HttpHeaders.ContentType] + if (contentType?.contains("application/json", ignoreCase = true) != true) return@onResponse + + val body = response.bodyAsText() + if (body.isNotBlank()) { + val preview = body.take(4096) + Napier.d( + "RESPONSE BODY:\n${preview.prettyPrintJson()}", + tag = "HttpClient", + ) + } + } + } + } + + defaultRequest { + url("https://api.cchaksa.com/api/") + contentType(ContentType.Application.Json) + } + } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256Test.kt b/composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256Test.kt new file mode 100644 index 000000000..8176e7db3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256Test.kt @@ -0,0 +1,88 @@ +package com.chukchukhaksa.mobile.common.kmp + +import kotlin.test.Test +import kotlin.test.assertEquals + +class Sha256Test { + + @Test + fun emptyString() { + assertEquals( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha256Hex(""), + ) + } + + @Test + fun shortString() { + // NIST example: SHA-256("abc") + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + sha256Hex("abc"), + ) + } + + @Test + fun testString() { + assertEquals( + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + sha256Hex("test"), + ) + } + + @Test + fun nistTwoBlockMessage() { + // NIST example: 448-bit message (exactly triggers two-block padding) + assertEquals( + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + sha256Hex("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + ) + } + + @Test + fun helloWorld() { + assertEquals( + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + sha256Hex("hello world"), + ) + } + + @Test + fun simpleString() { + // 간단한 문자열 해싱 테스트 + assertEquals( + "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", + sha256Hex("foo"), + ) + } + + @Test + fun paddingBoundary55Bytes() { + // 55 bytes: padding 후 정확히 64 bytes (한 블록) + val input = "a".repeat(55) + assertEquals( + "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318", + sha256Hex(input), + ) + } + + @Test + fun paddingBoundary56Bytes() { + // 56 bytes: padding 시 두 블록 필요 + val input = "a".repeat(56) + assertEquals( + "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a", + sha256Hex(input), + ) + } + + @Test + fun longString() { + // 1000 bytes + val input = "a".repeat(1000) + assertEquals( + "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3", + sha256Hex(input), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/domain/config/usecase/CheckNeedForceUpdateUseCaseTest.kt b/composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/domain/config/usecase/CheckNeedForceUpdateUseCaseTest.kt index a0ffb481f..c91f8f591 100644 --- a/composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/domain/config/usecase/CheckNeedForceUpdateUseCaseTest.kt +++ b/composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/domain/config/usecase/CheckNeedForceUpdateUseCaseTest.kt @@ -4,7 +4,9 @@ import com.chukchukhaksa.mobile.common.kmp.Platform import com.chukchukhaksa.mobile.domain.config.repository.AppConfigRepository import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class CheckNeedForceUpdateUseCaseTest { @@ -12,9 +14,13 @@ class CheckNeedForceUpdateUseCaseTest { private class MockAppConfigRepository : AppConfigRepository { var androidMinVersion: String = "1.0.0" var iosMinVersion: String = "1.0.0" + var appleStoreUrl: String = "https://apps.apple.com/app/id0000000000" + var googleStoreUrl: String = "https://play.google.com/store/apps/details?id=com.kunize.uswtimetable" override suspend fun getAndroidMinVersion(): String = androidMinVersion override suspend fun getIOSMinVersion(): String = iosMinVersion + override suspend fun getAppleStoreUrl(): String = appleStoreUrl + override suspend fun getGoogleStoreUrl(): String = googleStoreUrl } private val mockRepository = MockAppConfigRepository() @@ -26,7 +32,8 @@ class CheckNeedForceUpdateUseCaseTest { val result = useCase(Platform.Android, "1.5.0").getOrThrow() - assertTrue(result) + assertTrue(result.needForceUpdate) + assertEquals(mockRepository.googleStoreUrl, result.storeUrl) } @Test @@ -35,7 +42,8 @@ class CheckNeedForceUpdateUseCaseTest { val result = useCase(Platform.Android, "2.0.0").getOrThrow() - assertFalse(result) + assertFalse(result.needForceUpdate) + assertNull(result.storeUrl) } @Test @@ -44,7 +52,7 @@ class CheckNeedForceUpdateUseCaseTest { val result = useCase(Platform.Android, "1.5.0").getOrThrow() - assertFalse(result) + assertFalse(result.needForceUpdate) } @Test @@ -53,7 +61,8 @@ class CheckNeedForceUpdateUseCaseTest { val result = useCase(Platform.IOS, "2.0.0").getOrThrow() - assertTrue(result) + assertTrue(result.needForceUpdate) + assertEquals(mockRepository.appleStoreUrl, result.storeUrl) } @Test @@ -62,7 +71,8 @@ class CheckNeedForceUpdateUseCaseTest { val result = useCase(Platform.Android, "1.0.0").getOrThrow() - assertTrue(result) + assertTrue(result.needForceUpdate) + assertEquals(mockRepository.googleStoreUrl, result.storeUrl) } @Test @@ -71,7 +81,8 @@ class CheckNeedForceUpdateUseCaseTest { val result = useCase(Platform.Android, "1.0.0").getOrThrow() - assertTrue(result) + assertTrue(result.needForceUpdate) + assertEquals(mockRepository.googleStoreUrl, result.storeUrl) } @Test @@ -80,7 +91,7 @@ class CheckNeedForceUpdateUseCaseTest { val result = useCase(Platform.Android, "1.0.0").getOrThrow() - assertFalse(result) + assertFalse(result.needForceUpdate) } @Test @@ -98,7 +109,8 @@ class CheckNeedForceUpdateUseCaseTest { val result = useCase(Platform.Android, "1.9.9").getOrThrow() - assertTrue(result) + assertTrue(result.needForceUpdate) + assertEquals(mockRepository.googleStoreUrl, result.storeUrl) } @Test @@ -107,7 +119,8 @@ class CheckNeedForceUpdateUseCaseTest { val result = useCase(Platform.Android, "1.4.9").getOrThrow() - assertTrue(result) + assertTrue(result.needForceUpdate) + assertEquals(mockRepository.googleStoreUrl, result.storeUrl) } @Test diff --git a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/HttpClientEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/HttpClientEngine.ios.kt new file mode 100644 index 000000000..2681be58f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/HttpClientEngine.ios.kt @@ -0,0 +1,6 @@ +package com.chukchukhaksa.mobile.common.kmp + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.darwin.Darwin + +actual val httpClientEngineFactory: HttpClientEngineFactory<*> = Darwin diff --git a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.ios.kt b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.ios.kt index 503619fd3..0125eb92a 100644 --- a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.ios.kt @@ -12,25 +12,29 @@ actual class KakaoSignInClient( private val bridge: KakaoLoginBridge, ) { actual suspend fun signIn(context: Any?): KakaoSignInResult { - val nonce = NSUUID().UUIDString() + val rawNonce = NSUUID().UUIDString() + val hashedNonce = sha256Hex(rawNonce) return if (bridge.isKakaoTalkAvailable()) { try { - signInWithKakaoTalk(nonce) + signInWithKakaoTalk(rawNonce = rawNonce, hashedNonce = hashedNonce) } catch (e: KakaoTalkCancelledException) { - signInWithInAppBrowser(nonce) + signInWithInAppBrowser(rawNonce = rawNonce, hashedNonce = hashedNonce) } } else { - signInWithInAppBrowser(nonce) + signInWithInAppBrowser(rawNonce = rawNonce, hashedNonce = hashedNonce) } } - private suspend fun signInWithKakaoTalk(nonce: String): KakaoSignInResult { + private suspend fun signInWithKakaoTalk( + rawNonce: String, + hashedNonce: String, + ): KakaoSignInResult { return suspendCancellableCoroutine { continuation -> bridge.loginWithKakaoTalk( - nonce = nonce, + nonce = hashedNonce, onSuccess = { idToken -> - continuation.resume(KakaoSignInResult(idToken = idToken, nonce = nonce)) + continuation.resume(KakaoSignInResult(idToken = idToken, nonce = rawNonce)) }, onFailure = { error -> continuation.resumeWithException(error) @@ -39,8 +43,11 @@ actual class KakaoSignInClient( } } - private suspend fun signInWithInAppBrowser(nonce: String): KakaoSignInResult { - val oauthInfo = bridge.buildOAuthUrl(nonce) + private suspend fun signInWithInAppBrowser( + rawNonce: String, + hashedNonce: String, + ): KakaoSignInResult { + val oauthInfo = bridge.buildOAuthUrl(hashedNonce) val deferred = KakaoOAuthRedirectHandler.prepare() try { @@ -52,7 +59,7 @@ actual class KakaoSignInClient( KInAppBrowser.close() } - return exchangeCodeForToken(code, oauthInfo.codeVerifier, nonce) + return exchangeCodeForToken(code, oauthInfo.codeVerifier, rawNonce) } catch (e: Exception) { KakaoOAuthRedirectHandler.cancel() withContext(Dispatchers.Main) { @@ -65,14 +72,14 @@ actual class KakaoSignInClient( private suspend fun exchangeCodeForToken( code: String, codeVerifier: String, - nonce: String, + rawNonce: String, ): KakaoSignInResult { return suspendCancellableCoroutine { continuation -> bridge.exchangeCodeForToken( code = code, codeVerifier = codeVerifier, onSuccess = { idToken -> - continuation.resume(KakaoSignInResult(idToken = idToken, nonce = nonce)) + continuation.resume(KakaoSignInResult(idToken = idToken, nonce = rawNonce)) }, onFailure = { error -> continuation.resumeWithException(error) diff --git a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256.ios.kt b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256.ios.kt new file mode 100644 index 000000000..e3379b596 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/Sha256.ios.kt @@ -0,0 +1,97 @@ +package com.chukchukhaksa.mobile.common.kmp + +actual fun sha256Hex(input: String): String { + val bytes = input.encodeToByteArray() + return sha256(bytes).joinToString("") { + (it.toInt() and 0xFF).toString(16).padStart(2, '0') + } +} + +private val K = intArrayOf( + 0x428a2f98.toInt(), 0x71374491, 0xb5c0fbcf.toInt(), 0xe9b5dba5.toInt(), + 0x3956c25b, 0x59f111f1, 0x923f82a4.toInt(), 0xab1c5ed5.toInt(), + 0xd807aa98.toInt(), 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe.toInt(), 0x9bdc06a7.toInt(), 0xc19bf174.toInt(), + 0xe49b69c1.toInt(), 0xefbe4786.toInt(), 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152.toInt(), 0xa831c66d.toInt(), 0xb00327c8.toInt(), 0xbf597fc7.toInt(), + 0xc6e00bf3.toInt(), 0xd5a79147.toInt(), 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e.toInt(), 0x92722c85.toInt(), + 0xa2bfe8a1.toInt(), 0xa81a664b.toInt(), 0xc24b8b70.toInt(), 0xc76c51a3.toInt(), + 0xd192e819.toInt(), 0xd6990624.toInt(), 0xf40e3585.toInt(), 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814.toInt(), 0x8cc70208.toInt(), + 0x90befffa.toInt(), 0xa4506ceb.toInt(), 0xbef9a3f7.toInt(), 0xc67178f2.toInt(), +) + +private fun sha256(message: ByteArray): ByteArray { + var h0 = 0x6a09e667 + var h1 = 0xbb67ae85.toInt() + var h2 = 0x3c6ef372 + var h3 = 0xa54ff53a.toInt() + var h4 = 0x510e527f + var h5 = 0x9b05688c.toInt() + var h6 = 0x1f83d9ab + var h7 = 0x5be0cd19 + + val bitLen = message.size.toLong() * 8 + // Pre-processing: adding padding bits + // message + 0x80 + zeros + 8-byte big-endian length + val paddingSize = (56 - (message.size + 1).mod(64)).mod(64) + val padded = ByteArray(message.size + 1 + paddingSize + 8) + message.copyInto(padded) + padded[message.size] = 0x80.toByte() + for (i in 0 until 8) { + padded[padded.size - 1 - i] = (bitLen ushr (i * 8)).toByte() + } + + // Process each 512-bit (64-byte) block + val w = IntArray(64) + var offset = 0 + while (offset < padded.size) { + for (i in 0 until 16) { + w[i] = ((padded[offset + i * 4].toInt() and 0xFF) shl 24) or + ((padded[offset + i * 4 + 1].toInt() and 0xFF) shl 16) or + ((padded[offset + i * 4 + 2].toInt() and 0xFF) shl 8) or + (padded[offset + i * 4 + 3].toInt() and 0xFF) + } + for (i in 16 until 64) { + val s0 = w[i - 15].rotateRight(7) xor w[i - 15].rotateRight(18) xor (w[i - 15] ushr 3) + val s1 = w[i - 2].rotateRight(17) xor w[i - 2].rotateRight(19) xor (w[i - 2] ushr 10) + w[i] = w[i - 16] + s0 + w[i - 7] + s1 + } + + var a = h0; var b = h1; var c = h2; var d = h3 + var e = h4; var f = h5; var g = h6; var h = h7 + + for (i in 0 until 64) { + val s1 = e.rotateRight(6) xor e.rotateRight(11) xor e.rotateRight(25) + val ch = (e and f) xor (e.inv() and g) + val temp1 = h + s1 + ch + K[i] + w[i] + val s0 = a.rotateRight(2) xor a.rotateRight(13) xor a.rotateRight(22) + val maj = (a and b) xor (a and c) xor (b and c) + val temp2 = s0 + maj + + h = g; g = f; f = e; e = d + temp1 + d = c; c = b; b = a; a = temp1 + temp2 + } + + h0 += a; h1 += b; h2 += c; h3 += d + h4 += e; h5 += f; h6 += g; h7 += h + + offset += 64 + } + + val result = ByteArray(32) + intArrayOf(h0, h1, h2, h3, h4, h5, h6, h7).forEachIndexed { i, v -> + result[i * 4] = (v ushr 24).toByte() + result[i * 4 + 1] = (v ushr 16).toByte() + result[i * 4 + 2] = (v ushr 8).toByte() + result[i * 4 + 3] = v.toByte() + } + return result +} + +private fun Int.rotateRight(n: Int): Int = (this ushr n) or (this shl (32 - n)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index abed87307..c70db8165 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ napier = "2.7.1" glance = "1.1.1" composeRuntime = "1.10.0" +ktor = "3.3.1" kakao-sdk = "2.20.6" kinappbrowser = "1.0.0" @@ -87,6 +88,13 @@ napier = { group = "io.github.aakira", name = "napier", version.ref = "napier" } glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } + kakao-sdk-v2-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-sdk" } kinappbrowser = { group = "dev.yjyoon", name = "kinappbrowser", version.ref = "kinappbrowser" }