diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eea5c678..14c371b6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(projects.core.network) implementation(projects.core.navigation) implementation(projects.core.ui) + implementation(projects.core.datastore) implementation(projects.core.util) implementation(projects.data) implementation(projects.domain) diff --git a/app/src/main/java/com/yapp/twix/di/AppModule.kt b/app/src/main/java/com/yapp/twix/di/AppModule.kt new file mode 100644 index 00000000..7f4d6b22 --- /dev/null +++ b/app/src/main/java/com/yapp/twix/di/AppModule.kt @@ -0,0 +1,13 @@ +package com.yapp.twix.di + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.koin.dsl.module + +val appModule = + module { + single { + CoroutineScope(SupervisorJob() + Dispatchers.IO) + } + } diff --git a/app/src/main/java/com/yapp/twix/di/InitKoin.kt b/app/src/main/java/com/yapp/twix/di/InitKoin.kt index 5dfa81f2..54cea66b 100644 --- a/app/src/main/java/com/yapp/twix/di/InitKoin.kt +++ b/app/src/main/java/com/yapp/twix/di/InitKoin.kt @@ -2,6 +2,7 @@ package com.yapp.twix.di import android.content.Context import com.twix.data.di.dataModule +import com.twix.datastore.di.dataStoreModule import com.twix.network.di.networkModule import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -21,6 +22,8 @@ fun initKoin( addAll(networkModule) addAll(dataModule) add(uiModule) + add(dataStoreModule) + add(appModule) }, ) } diff --git a/core/datastore/.gitignore b/core/datastore/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/datastore/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts new file mode 100644 index 00000000..c76d6461 --- /dev/null +++ b/core/datastore/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.twix.android.library) + alias(libs.plugins.twix.koin) + alias(libs.plugins.serialization) +} + +android { + namespace = "com.twix.datastore" +} + +dependencies { + implementation(projects.core.token) + + implementation(libs.androidx.datastore) + implementation(libs.kotlinx.serialization.json) +} diff --git a/core/datastore/consumer-rules.pro b/core/datastore/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/datastore/proguard-rules.pro b/core/datastore/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/datastore/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/datastore/src/main/AndroidManifest.xml b/core/datastore/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8bdb7e14 --- /dev/null +++ b/core/datastore/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/datastore/src/main/java/com/twix/datastore/AuthConfigure.kt b/core/datastore/src/main/java/com/twix/datastore/AuthConfigure.kt new file mode 100644 index 00000000..74e94a90 --- /dev/null +++ b/core/datastore/src/main/java/com/twix/datastore/AuthConfigure.kt @@ -0,0 +1,60 @@ +package com.twix.datastore + +import androidx.datastore.core.Serializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import java.io.InputStream +import java.io.OutputStream + +@Serializable +internal data class AuthConfigure( + val accessToken: String = + "eyJhbGciOiJIUzM4NCJ9." + + "eyJzdWIiOiIxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc3MDI0NzM0NCwiZXhwIjoxNzcwODUyMTQ0fQ." + + "67rDscm8BeayYFA1gfcEMliEdEh8-HTUyE5TwmAT8Ef8ZvtaWczxpMNZqI5htiek", + val refreshToken: String = + "eyJhbGciOiJIUzM4NCJ9." + + "eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJpYXQiOjE3NzAyNDczNDQsImV4cCI6MTc3MDg1MjE0NH0." + + "zgUYdR6onyeY5EaH2_pWLs1rjNLf8m8ZeXsY7Cbk99a_2tzR0rDBZO_hdGTnorRL", +) + +internal object AuthConfigureSerializer : Serializer { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + override val defaultValue: AuthConfigure + get() = AuthConfigure() + + override suspend fun readFrom(input: InputStream): AuthConfigure = + try { + withContext(Dispatchers.IO) { + json.decodeFromString( + deserializer = AuthConfigure.serializer(), + string = input.readBytes().decodeToString(), + ) + } + } catch (e: SerializationException) { + defaultValue + } + + override suspend fun writeTo( + t: AuthConfigure, + output: OutputStream, + ) { + withContext(Dispatchers.IO) { + output.write( + json + .encodeToString( + serializer = AuthConfigure.serializer(), + value = t, + ).encodeToByteArray(), + ) + } + } +} diff --git a/core/datastore/src/main/java/com/twix/datastore/AuthTokenProvider.kt b/core/datastore/src/main/java/com/twix/datastore/AuthTokenProvider.kt new file mode 100644 index 00000000..a4f95b4b --- /dev/null +++ b/core/datastore/src/main/java/com/twix/datastore/AuthTokenProvider.kt @@ -0,0 +1,44 @@ +package com.twix.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import com.twix.token.TokenProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn + +class AuthTokenProvider( + context: Context, + scope: CoroutineScope, +) : TokenProvider { + private val dataStore: DataStore = context.authDataStore + + private val tokenState = + dataStore.data + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = AuthConfigure(), + ) + + override val accessToken: String + get() = tokenState.value.accessToken + + override val refreshToken: String + get() = tokenState.value.refreshToken + + override suspend fun saveToken( + accessToken: String, + refreshToken: String, + ) { + dataStore.updateData { + it.copy(accessToken = accessToken, refreshToken = refreshToken) + } + } + + override suspend fun clear() { + dataStore.updateData { + it.copy(accessToken = "", refreshToken = "") + } + } +} diff --git a/core/datastore/src/main/java/com/twix/datastore/DataStore.kt b/core/datastore/src/main/java/com/twix/datastore/DataStore.kt new file mode 100644 index 00000000..a98f6396 --- /dev/null +++ b/core/datastore/src/main/java/com/twix/datastore/DataStore.kt @@ -0,0 +1,10 @@ +package com.twix.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore + +internal val Context.authDataStore: DataStore by dataStore( + fileName = "auth-configure.json", + serializer = AuthConfigureSerializer, +) diff --git a/core/datastore/src/main/java/com/twix/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/twix/datastore/di/DataStoreModule.kt new file mode 100644 index 00000000..55049db8 --- /dev/null +++ b/core/datastore/src/main/java/com/twix/datastore/di/DataStoreModule.kt @@ -0,0 +1,12 @@ +package com.twix.datastore.di + +import android.content.Context +import com.twix.datastore.AuthTokenProvider +import com.twix.token.TokenProvider +import kotlinx.coroutines.CoroutineScope +import org.koin.dsl.module + +val dataStoreModule = + module { + single { AuthTokenProvider(get(), get()) } + } diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 3567c328..5a301264 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -40,6 +40,7 @@ android { } dependencies { + implementation(projects.core.token) implementation(projects.core.result) implementation(libs.bundles.ktor) diff --git a/core/network/src/main/java/com/twix/network/HttpClientProvider.kt b/core/network/src/main/java/com/twix/network/HttpClientProvider.kt index c468b311..4afa7901 100644 --- a/core/network/src/main/java/com/twix/network/HttpClientProvider.kt +++ b/core/network/src/main/java/com/twix/network/HttpClientProvider.kt @@ -1,14 +1,21 @@ package com.twix.network +import com.twix.network.model.request.RefreshRequest +import com.twix.network.service.AuthService +import com.twix.token.TokenProvider import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig 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.ANDROID 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.request.HttpRequestBuilder import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json @@ -16,6 +23,8 @@ import kotlinx.serialization.json.Json internal object HttpClientProvider { fun createHttpClient( + tokenProvider: TokenProvider, + authService: Lazy, baseUrl: String, isDebug: Boolean, ): HttpClient = @@ -26,18 +35,7 @@ internal object HttpClientProvider { configureLogging(isDebug) configureTimeout() configureDefaultRequest(baseUrl) - - // TODO : 토큰 관련 기능 구현 후 적용 -// install(Auth) { -// bearer { -// loadTokens { -// BearerTokens( -// accessToken = "", -// refreshToken = "", -// ) -// } -// } -// } + configureAuth(tokenProvider, authService, baseUrl) } private fun HttpClientConfig<*>.configureContentNegotiation(isDebug: Boolean) { @@ -81,6 +79,77 @@ internal object HttpClientProvider { } } + /** + * Ktor [Auth] Bearer 플러그인을 설정한다. + * + * ### 역할 + * - 모든 요청에 AccessToken 자동 첨부 + * - 401 발생 시 RefreshToken으로 토큰 재발급 + * - 재발급 성공 시 TokenProvider에 토큰 갱신 후 재요청 + * + * ### 왜 Lazy 인가? + * HttpClient ↔ AuthService 간 순환 의존성을 방지하기 위함. + * + * AuthService 내부에서 HttpClient를 사용하고, + * HttpClient 설정 시 AuthService가 필요하기 때문에 + * 즉시 생성하면 DI 순환참조가 발생한다. + * + * Lazy 로 지연 생성하여 실제 refresh 시점에만 인스턴스를 획득한다. + * + */ + private fun HttpClientConfig<*>.configureAuth( + tokenProvider: TokenProvider, + lazyAuthService: Lazy, + baseUrl: String, + ) { + install(Auth) { + bearer { + cacheTokens = false + + /** + * [loadTokens] + * - 현재 저장된 access/refresh token을 읽어 Authorization 헤더에 추가 + * */ + loadTokens { + val accessToken = tokenProvider.accessToken + val refreshToken = tokenProvider.refreshToken + BearerTokens(accessToken, refreshToken) + } + + /** + * [refreshTokens] + * - 401 Unauthorized 발생 시 호출 + * - AuthService.refresh() API 호출 + * - 새 토큰을 발급받아 TokenProvider에 저장 + * - BearerTokens 반환 → 원 요청 자동 재시도 + * **/ + refreshTokens { + val refreshToken = tokenProvider.refreshToken + val request = RefreshRequest(refreshToken) + val apiService = lazyAuthService.value + val (newAccessToken, newRefreshToken) = apiService.refresh(request) + + tokenProvider.saveToken(newAccessToken, newRefreshToken) + BearerTokens(newAccessToken, newRefreshToken) + } + + /** + * [sendWithoutRequest] + * - refresh API 호출 시에는 토큰을 붙이지 않음 + * - refresh 요청 자체가 다시 refresh 되는 무한 루프 방지 목적 + * **/ + sendWithoutRequest { request: HttpRequestBuilder -> + val requestUrl: String = request.url.toString() + + requestUrl.startsWith(baseUrl) && + !requestUrl.endsWith(AUTH_REFRESH_ENDPOINT) + } + } + } + } + private const val SANITIZE_HEADER = "Authorization" private const val TIMEOUT_MILLIS = 30_000L + + private const val AUTH_REFRESH_ENDPOINT = "/auth/refresh" } diff --git a/core/network/src/main/java/com/twix/network/di/ApiServiceModule.kt b/core/network/src/main/java/com/twix/network/di/ApiServiceModule.kt index 7116c8c0..f911f84f 100644 --- a/core/network/src/main/java/com/twix/network/di/ApiServiceModule.kt +++ b/core/network/src/main/java/com/twix/network/di/ApiServiceModule.kt @@ -1,6 +1,8 @@ package com.twix.network.di +import com.twix.network.service.AuthService import com.twix.network.service.OnboardingService +import com.twix.network.service.createAuthService import com.twix.network.service.createOnboardingService import de.jensklingenberg.ktorfit.Ktorfit import org.koin.dsl.module @@ -10,4 +12,7 @@ internal val apiServiceModule = single { get().createOnboardingService() } + single { + get().createAuthService() + } } diff --git a/core/network/src/main/java/com/twix/network/di/HttpClientModule.kt b/core/network/src/main/java/com/twix/network/di/HttpClientModule.kt index dfa3b254..ee8e7417 100644 --- a/core/network/src/main/java/com/twix/network/di/HttpClientModule.kt +++ b/core/network/src/main/java/com/twix/network/di/HttpClientModule.kt @@ -11,6 +11,8 @@ internal val httpClientModule = single { HttpClientProvider.createHttpClient( + tokenProvider = get(), + authService = lazy { get() }, baseUrl = BuildConfig.BASE_URL, isDebug = BuildConfig.DEBUG, ) diff --git a/core/network/src/main/java/com/twix/network/model/request/LoginRequest.kt b/core/network/src/main/java/com/twix/network/model/request/LoginRequest.kt new file mode 100644 index 00000000..588db4b0 --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/request/LoginRequest.kt @@ -0,0 +1,8 @@ +package com.twix.network.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val code: String, +) diff --git a/core/network/src/main/java/com/twix/network/model/request/RefreshRequest.kt b/core/network/src/main/java/com/twix/network/model/request/RefreshRequest.kt new file mode 100644 index 00000000..7398451b --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/request/RefreshRequest.kt @@ -0,0 +1,8 @@ +package com.twix.network.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class RefreshRequest( + val refreshToken: String, +) diff --git a/core/network/src/main/java/com/twix/network/model/response/RefreshResponse.kt b/core/network/src/main/java/com/twix/network/model/response/RefreshResponse.kt new file mode 100644 index 00000000..751f4e38 --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/response/RefreshResponse.kt @@ -0,0 +1,9 @@ +package com.twix.network.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class RefreshResponse( + val accessToken: String, + val refreshToken: String, +) diff --git a/core/network/src/main/java/com/twix/network/model/response/onboarding/LoginResponse.kt b/core/network/src/main/java/com/twix/network/model/response/onboarding/LoginResponse.kt new file mode 100644 index 00000000..69a12ec0 --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/response/onboarding/LoginResponse.kt @@ -0,0 +1,11 @@ +package com.twix.network.model.response.onboarding + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginResponse( + val userId: Long, + val accessToken: String, + val refreshToken: String, + val isNewUser: Boolean, +) diff --git a/core/network/src/main/java/com/twix/network/service/AuthService.kt b/core/network/src/main/java/com/twix/network/service/AuthService.kt new file mode 100644 index 00000000..b41d4bee --- /dev/null +++ b/core/network/src/main/java/com/twix/network/service/AuthService.kt @@ -0,0 +1,20 @@ +package com.twix.network.service + +import com.twix.network.model.request.LoginRequest +import com.twix.network.model.request.RefreshRequest +import com.twix.network.model.response.RefreshResponse +import com.twix.network.model.response.onboarding.LoginResponse +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.POST + +interface AuthService { + @POST("auth/google") + suspend fun googleLogin( + @Body request: LoginRequest, + ): LoginResponse + + @POST("auth/refresh") + suspend fun refresh( + @Body request: RefreshRequest, + ): RefreshResponse +} diff --git a/core/token/.gitignore b/core/token/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/token/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/token/build.gradle.kts b/core/token/build.gradle.kts new file mode 100644 index 00000000..ba7b83cc --- /dev/null +++ b/core/token/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + alias(libs.plugins.twix.java.library) +} diff --git a/core/token/consumer-rules.pro b/core/token/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/token/proguard-rules.pro b/core/token/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/token/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/token/src/main/java/com/twix/token/TokenProvider.kt b/core/token/src/main/java/com/twix/token/TokenProvider.kt new file mode 100644 index 00000000..23a4359c --- /dev/null +++ b/core/token/src/main/java/com/twix/token/TokenProvider.kt @@ -0,0 +1,14 @@ +package com.twix.token + +interface TokenProvider { + val accessToken: String + + val refreshToken: String + + suspend fun saveToken( + accessToken: String, + refreshToken: String, + ) + + suspend fun clear() +} diff --git a/data/build.gradle.kts b/data/build.gradle.kts index f61fa0e8..6e4a6774 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -5,3 +5,7 @@ plugins { android { namespace = "com.twix.data" } + +dependencies { + implementation(projects.core.datastore) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57f800f0..d257bbde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ kotlinx-serialization-json = "1.9.0" # AndroidX androidx-core-ktx = "1.17.0" androidx-lifecycle-runtime-ktx = "2.10.0" +androidx-datastore = "1.2.0" # Google material = "1.13.0" @@ -29,7 +30,7 @@ compose-coil = "3.3.0" compose-navigation = "2.9.4" # Ktor -ktor = "3.3.3" +ktor = "3.4.0" # Ktrofit ktorfit = "2.7.2" @@ -71,6 +72,7 @@ appcompat = "1.7.1" # AndroidX androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-ktx" } +androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" } # CameraX androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a1898890..acf759a1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,6 +29,8 @@ include(":app") include(":domain") include(":data") include(":feature:login") +include(":core:datastore") +include(":core:token") include(":core:util") include(":core:ui") include(":core:navigation")