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..0a5e628c --- /dev/null +++ b/core/datastore/build.gradle.kts @@ -0,0 +1,28 @@ +@file:Suppress("INLINE_FROM_HIGHER_PLATFORM") + +plugins { + alias(libs.plugins.booket.android.library) + alias(libs.plugins.booket.android.hilt) +} + +android { + namespace = "com.ninecraft.booket.core.datastore" + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } +} + +dependencies { + implementations( + libs.androidx.datastore.preferences, + + libs.logger, + ) + + androidTestImplementations( + libs.androidx.test.ext.junit, + libs.androidx.test.runner, + libs.kotlinx.coroutines.test, + ) +} diff --git a/core/datastore/src/androidTest/kotlin/com/ninecraft/booket/core/datastore/TokenPreferenceDataSourceTest.kt b/core/datastore/src/androidTest/kotlin/com/ninecraft/booket/core/datastore/TokenPreferenceDataSourceTest.kt new file mode 100644 index 00000000..9c7152e7 --- /dev/null +++ b/core/datastore/src/androidTest/kotlin/com/ninecraft/booket/core/datastore/TokenPreferenceDataSourceTest.kt @@ -0,0 +1,80 @@ +package com.ninecraft.booket.core.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ninecraft.booket.core.datastore.datasource.DefaultTokenPreferencesDataSource +import com.ninecraft.booket.core.datastore.security.CryptoManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.io.path.createTempDirectory + +@RunWith(AndroidJUnit4::class) +class TokenPreferenceDataSourceTest { + private lateinit var dataStore: DataStore + private lateinit var dataSource: DefaultTokenPreferencesDataSource + private lateinit var cryptoManager: CryptoManager + private lateinit var tempFile: File + + @Before + fun setup() { + val tempFolder = createTempDirectory().toFile() + tempFile = File(tempFolder, "token_prefs.preferences_pb") + + dataStore = PreferenceDataStoreFactory.create( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { tempFile } + ) + + cryptoManager = CryptoManager() + dataSource = DefaultTokenPreferencesDataSource(dataStore, cryptoManager) + } + + @After + fun tearDown() { + tempFile.delete() + } + + @Test + fun tokenIsEncryptedWhenStored() = runTest { + // Given + val plainToken = "plain_access_token" + dataSource.setAccessToken(plainToken) + + // When + val storedToken = dataStore.data.first()[stringPreferencesKey("ACCESS_TOKEN")] + + // Then + assertNotNull(storedToken) + assertNotEquals(plainToken, storedToken) + assertTrue(storedToken!!.isNotEmpty()) + } + + + @Test + fun storedTokenIsDecryptedWhenRetrieved() = runTest { + // Given + val plainToken = "plain_access_token" + dataSource.setAccessToken(plainToken) + + // When + val restoredToken = dataSource.accessToken.first() + + // Then + assertEquals(plainToken, restoredToken) + } +} 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/kotlin/com/ninecraft/booket/core/datastore/datasource/DefaultTokenPreferencesDataSource.kt b/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/datasource/DefaultTokenPreferencesDataSource.kt new file mode 100644 index 00000000..53889f04 --- /dev/null +++ b/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/datasource/DefaultTokenPreferencesDataSource.kt @@ -0,0 +1,60 @@ +package com.ninecraft.booket.core.datastore.datasource + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.ninecraft.booket.core.datastore.security.CryptoManager +import com.ninecraft.booket.core.datastore.util.handleIOException +import com.orhanobut.logger.Logger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.security.GeneralSecurityException +import javax.inject.Inject + +class DefaultTokenPreferencesDataSource @Inject constructor( + private val dataStore: DataStore, + private val cryptoManager: CryptoManager, +) : TokenPreferencesDataSource { + override val accessToken: Flow = decryptStringFlow(ACCESS_TOKEN) + override val refreshToken: Flow = decryptStringFlow(REFRESH_TOKEN) + + override suspend fun setAccessToken(accessToken: String) { + dataStore.edit { prefs -> + prefs[ACCESS_TOKEN] = cryptoManager.encrypt(accessToken) + } + } + + override suspend fun setRefreshToken(refreshToken: String) { + dataStore.edit { prefs -> + prefs[REFRESH_TOKEN] = cryptoManager.encrypt(refreshToken) + } + } + + override suspend fun clearTokens() { + dataStore.edit { prefs -> + prefs.remove(ACCESS_TOKEN) + prefs.remove(REFRESH_TOKEN) + } + } + + private fun decryptStringFlow( + key: Preferences.Key, + ): Flow = dataStore.data + .handleIOException() + .map { prefs -> + prefs[key]?.let { + try { + cryptoManager.decrypt(it) + } catch (e: GeneralSecurityException) { + Logger.e(e, "Failed to decrypt token") + "" + } + }.orEmpty() + } + + companion object { + private val ACCESS_TOKEN = stringPreferencesKey("ACCESS_TOKEN") + private val REFRESH_TOKEN = stringPreferencesKey("REFRESH_TOKEN") + } +} diff --git a/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/datasource/TokenPreferencesDataSource.kt b/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/datasource/TokenPreferencesDataSource.kt new file mode 100644 index 00000000..b63a5d46 --- /dev/null +++ b/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/datasource/TokenPreferencesDataSource.kt @@ -0,0 +1,11 @@ +package com.ninecraft.booket.core.datastore.datasource + +import kotlinx.coroutines.flow.Flow + +interface TokenPreferencesDataSource { + val accessToken: Flow + val refreshToken: Flow + suspend fun setAccessToken(accessToken: String) + suspend fun setRefreshToken(refreshToken: String) + suspend fun clearTokens() +} diff --git a/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/di/DataStoreModule.kt new file mode 100644 index 00000000..6caa2453 --- /dev/null +++ b/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/di/DataStoreModule.kt @@ -0,0 +1,39 @@ +package com.ninecraft.booket.core.datastore.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.ninecraft.booket.core.datastore.datasource.DefaultTokenPreferencesDataSource +import com.ninecraft.booket.core.datastore.datasource.TokenPreferencesDataSource +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + private const val TOKEN_DATASTORE_NAME = "TOKENS_PREFERENCES" + private val Context.dataStore by preferencesDataStore(name = TOKEN_DATASTORE_NAME) + + @Provides + @Singleton + fun provideTokenDataStore( + @ApplicationContext context: Context, + ): DataStore = context.dataStore +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataStoreBindModule { + + @Binds + @Singleton + abstract fun bindTokenPreferencesDataSource( + tokenPreferencesDataSourceImpl: DefaultTokenPreferencesDataSource, + ): TokenPreferencesDataSource +} diff --git a/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/security/CryptoManager.kt b/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/security/CryptoManager.kt new file mode 100644 index 00000000..307a45e9 --- /dev/null +++ b/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/security/CryptoManager.kt @@ -0,0 +1,74 @@ +package com.ninecraft.booket.core.datastore.security + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CryptoManager @Inject constructor() { + private val cipher = Cipher.getInstance(TRANSFORMATION) + private val keyStore = KeyStore + .getInstance("AndroidKeyStore") + .apply { + load(null) + } + + private fun getKey(): SecretKey { + val existingKey = keyStore + .getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry + return existingKey?.secretKey ?: createKey() + } + + private fun createKey(): SecretKey { + return KeyGenerator + .getInstance(ALGORITHM) + .apply { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(PADDING) + .setRandomizedEncryptionRequired(true) + .setUserAuthenticationRequired(false) + .build(), + ) + } + .generateKey() + } + + fun encrypt(plainText: String): String { + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + val iv = cipher.iv + val encryptedBytes = cipher.doFinal(plainText.toByteArray()) + val combined = iv + encryptedBytes + return Base64.encodeToString(combined, Base64.NO_WRAP) + } + + fun decrypt(encodedText: String): String { + val combined = Base64.decode(encodedText, Base64.NO_WRAP) + val iv = combined.copyOfRange(0, IV_SIZE) + val encrypted = combined.copyOfRange(IV_SIZE, combined.size) + cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv)) + val decryptedString = String(cipher.doFinal(encrypted)) + return decryptedString + } + + companion object { + private const val KEY_ALIAS = "secret" + private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC + private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 + private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING" + private const val IV_SIZE = 16 // AES IV는 항상 16바이트 + } +} diff --git a/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/util/DataStoreUtil.kt b/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/util/DataStoreUtil.kt new file mode 100644 index 00000000..6f9f0fdf --- /dev/null +++ b/core/datastore/src/main/kotlin/com/ninecraft/booket/core/datastore/util/DataStoreUtil.kt @@ -0,0 +1,16 @@ +package com.ninecraft.booket.core.datastore.util + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import java.io.IOException + +fun Flow.handleIOException(): Flow = + catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d8f94665..960018f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,13 +57,14 @@ kotlin-ktlint-source = "0.50.0" ## Test junit = "4.13.2" -junit-version = "1.2.1" +androidx-test-ext-junit = "1.2.1" +androidx-test-runner = "1.6.2" espresso-core = "3.6.1" material = "1.12.0" [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "android-gradle-plugin" } -kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name="kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } compose-compiler-gradle-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } @@ -72,7 +73,7 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-splash = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidx-splash" } androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidx-startup" } -androidx-datastore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidx-datastore" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidx-datastore" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } @@ -94,6 +95,7 @@ retrofit-kotlinx-serialization-converter = { module = "com.squareup.retrofit2:co okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } @@ -111,7 +113,9 @@ detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-form kakao-auth = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-core" } junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit-version" } + +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } @@ -138,7 +142,7 @@ booket-android-application = { id = "booket.android.application", version = "uns booket-android-application-compose = { id = "booket.android.application.compose", version = "unspecified" } booket-android-library = { id = "booket.android.library", version = "unspecified" } booket-android-library-compose = { id = "booket.android.library.compose", version = "unspecified" } -booket-android-retrofit = { id = "booket.android.retrofit", version = "unspecified"} +booket-android-retrofit = { id = "booket.android.retrofit", version = "unspecified" } booket-android-feature = { id = "booket.android.feature", version = "unspecified" } booket-android-hilt = { id = "booket.android.hilt", version = "unspecified" } booket-jvm-library = { id = "booket.jvm.library", version = "unspecified" } diff --git a/settings.gradle.kts b/settings.gradle.kts index bdec7d1f..a4f069b7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ include( ":core:designsystem", ":core:network", + ":core:datastore", ":core:ui", ":feature:home",