Skip to content

Commit e86a388

Browse files
authored
Merge pull request #17 from YAPP-Github/BOOK-63-feature/#15
feat: DataStore 모듈 설계 및 암호화 기능 구현
2 parents 5d05068 + 22c4e8a commit e86a388

File tree

11 files changed

+323
-5
lines changed

11 files changed

+323
-5
lines changed

core/datastore/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build

core/datastore/build.gradle.kts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
@file:Suppress("INLINE_FROM_HIGHER_PLATFORM")
2+
3+
plugins {
4+
alias(libs.plugins.booket.android.library)
5+
alias(libs.plugins.booket.android.hilt)
6+
}
7+
8+
android {
9+
namespace = "com.ninecraft.booket.core.datastore"
10+
11+
defaultConfig {
12+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
13+
}
14+
}
15+
16+
dependencies {
17+
implementations(
18+
libs.androidx.datastore.preferences,
19+
20+
libs.logger,
21+
)
22+
23+
androidTestImplementations(
24+
libs.androidx.test.ext.junit,
25+
libs.androidx.test.runner,
26+
libs.kotlinx.coroutines.test,
27+
)
28+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.ninecraft.booket.core.datastore
2+
3+
import androidx.datastore.core.DataStore
4+
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
5+
import androidx.datastore.preferences.core.Preferences
6+
import androidx.datastore.preferences.core.stringPreferencesKey
7+
import androidx.test.ext.junit.runners.AndroidJUnit4
8+
import com.ninecraft.booket.core.datastore.datasource.DefaultTokenPreferencesDataSource
9+
import com.ninecraft.booket.core.datastore.security.CryptoManager
10+
import kotlinx.coroutines.CoroutineScope
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.SupervisorJob
13+
import kotlinx.coroutines.flow.first
14+
import kotlinx.coroutines.test.runTest
15+
import org.junit.After
16+
import org.junit.Assert.assertEquals
17+
import org.junit.Assert.assertNotEquals
18+
import org.junit.Assert.assertNotNull
19+
import org.junit.Assert.assertTrue
20+
import org.junit.Before
21+
import org.junit.Test
22+
import org.junit.runner.RunWith
23+
import java.io.File
24+
import kotlin.io.path.createTempDirectory
25+
26+
@RunWith(AndroidJUnit4::class)
27+
class TokenPreferenceDataSourceTest {
28+
private lateinit var dataStore: DataStore<Preferences>
29+
private lateinit var dataSource: DefaultTokenPreferencesDataSource
30+
private lateinit var cryptoManager: CryptoManager
31+
private lateinit var tempFile: File
32+
33+
@Before
34+
fun setup() {
35+
val tempFolder = createTempDirectory().toFile()
36+
tempFile = File(tempFolder, "token_prefs.preferences_pb")
37+
38+
dataStore = PreferenceDataStoreFactory.create(
39+
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
40+
produceFile = { tempFile }
41+
)
42+
43+
cryptoManager = CryptoManager()
44+
dataSource = DefaultTokenPreferencesDataSource(dataStore, cryptoManager)
45+
}
46+
47+
@After
48+
fun tearDown() {
49+
tempFile.delete()
50+
}
51+
52+
@Test
53+
fun tokenIsEncryptedWhenStored() = runTest {
54+
// Given
55+
val plainToken = "plain_access_token"
56+
dataSource.setAccessToken(plainToken)
57+
58+
// When
59+
val storedToken = dataStore.data.first()[stringPreferencesKey("ACCESS_TOKEN")]
60+
61+
// Then
62+
assertNotNull(storedToken)
63+
assertNotEquals(plainToken, storedToken)
64+
assertTrue(storedToken!!.isNotEmpty())
65+
}
66+
67+
68+
@Test
69+
fun storedTokenIsDecryptedWhenRetrieved() = runTest {
70+
// Given
71+
val plainToken = "plain_access_token"
72+
dataSource.setAccessToken(plainToken)
73+
74+
// When
75+
val restoredToken = dataSource.accessToken.first()
76+
77+
// Then
78+
assertEquals(plainToken, restoredToken)
79+
}
80+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+
</manifest>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.ninecraft.booket.core.datastore.datasource
2+
3+
import androidx.datastore.core.DataStore
4+
import androidx.datastore.preferences.core.Preferences
5+
import androidx.datastore.preferences.core.edit
6+
import androidx.datastore.preferences.core.stringPreferencesKey
7+
import com.ninecraft.booket.core.datastore.security.CryptoManager
8+
import com.ninecraft.booket.core.datastore.util.handleIOException
9+
import com.orhanobut.logger.Logger
10+
import kotlinx.coroutines.flow.Flow
11+
import kotlinx.coroutines.flow.map
12+
import java.security.GeneralSecurityException
13+
import javax.inject.Inject
14+
15+
class DefaultTokenPreferencesDataSource @Inject constructor(
16+
private val dataStore: DataStore<Preferences>,
17+
private val cryptoManager: CryptoManager,
18+
) : TokenPreferencesDataSource {
19+
override val accessToken: Flow<String> = decryptStringFlow(ACCESS_TOKEN)
20+
override val refreshToken: Flow<String> = decryptStringFlow(REFRESH_TOKEN)
21+
22+
override suspend fun setAccessToken(accessToken: String) {
23+
dataStore.edit { prefs ->
24+
prefs[ACCESS_TOKEN] = cryptoManager.encrypt(accessToken)
25+
}
26+
}
27+
28+
override suspend fun setRefreshToken(refreshToken: String) {
29+
dataStore.edit { prefs ->
30+
prefs[REFRESH_TOKEN] = cryptoManager.encrypt(refreshToken)
31+
}
32+
}
33+
34+
override suspend fun clearTokens() {
35+
dataStore.edit { prefs ->
36+
prefs.remove(ACCESS_TOKEN)
37+
prefs.remove(REFRESH_TOKEN)
38+
}
39+
}
40+
41+
private fun decryptStringFlow(
42+
key: Preferences.Key<String>,
43+
): Flow<String> = dataStore.data
44+
.handleIOException()
45+
.map { prefs ->
46+
prefs[key]?.let {
47+
try {
48+
cryptoManager.decrypt(it)
49+
} catch (e: GeneralSecurityException) {
50+
Logger.e(e, "Failed to decrypt token")
51+
""
52+
}
53+
}.orEmpty()
54+
}
55+
56+
companion object {
57+
private val ACCESS_TOKEN = stringPreferencesKey("ACCESS_TOKEN")
58+
private val REFRESH_TOKEN = stringPreferencesKey("REFRESH_TOKEN")
59+
}
60+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.ninecraft.booket.core.datastore.datasource
2+
3+
import kotlinx.coroutines.flow.Flow
4+
5+
interface TokenPreferencesDataSource {
6+
val accessToken: Flow<String>
7+
val refreshToken: Flow<String>
8+
suspend fun setAccessToken(accessToken: String)
9+
suspend fun setRefreshToken(refreshToken: String)
10+
suspend fun clearTokens()
11+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.ninecraft.booket.core.datastore.di
2+
3+
import android.content.Context
4+
import androidx.datastore.core.DataStore
5+
import androidx.datastore.preferences.core.Preferences
6+
import androidx.datastore.preferences.preferencesDataStore
7+
import com.ninecraft.booket.core.datastore.datasource.DefaultTokenPreferencesDataSource
8+
import com.ninecraft.booket.core.datastore.datasource.TokenPreferencesDataSource
9+
import dagger.Binds
10+
import dagger.Module
11+
import dagger.Provides
12+
import dagger.hilt.InstallIn
13+
import dagger.hilt.android.qualifiers.ApplicationContext
14+
import dagger.hilt.components.SingletonComponent
15+
import javax.inject.Singleton
16+
17+
@Module
18+
@InstallIn(SingletonComponent::class)
19+
object DataStoreModule {
20+
private const val TOKEN_DATASTORE_NAME = "TOKENS_PREFERENCES"
21+
private val Context.dataStore by preferencesDataStore(name = TOKEN_DATASTORE_NAME)
22+
23+
@Provides
24+
@Singleton
25+
fun provideTokenDataStore(
26+
@ApplicationContext context: Context,
27+
): DataStore<Preferences> = context.dataStore
28+
}
29+
30+
@Module
31+
@InstallIn(SingletonComponent::class)
32+
abstract class DataStoreBindModule {
33+
34+
@Binds
35+
@Singleton
36+
abstract fun bindTokenPreferencesDataSource(
37+
tokenPreferencesDataSourceImpl: DefaultTokenPreferencesDataSource,
38+
): TokenPreferencesDataSource
39+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.ninecraft.booket.core.datastore.security
2+
3+
import android.security.keystore.KeyGenParameterSpec
4+
import android.security.keystore.KeyProperties
5+
import android.util.Base64
6+
import java.security.KeyStore
7+
import javax.crypto.Cipher
8+
import javax.crypto.KeyGenerator
9+
import javax.crypto.SecretKey
10+
import javax.crypto.spec.IvParameterSpec
11+
import javax.inject.Inject
12+
import javax.inject.Singleton
13+
14+
@Singleton
15+
class CryptoManager @Inject constructor() {
16+
private val cipher = Cipher.getInstance(TRANSFORMATION)
17+
private val keyStore = KeyStore
18+
.getInstance("AndroidKeyStore")
19+
.apply {
20+
load(null)
21+
}
22+
23+
private fun getKey(): SecretKey {
24+
val existingKey = keyStore
25+
.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
26+
return existingKey?.secretKey ?: createKey()
27+
}
28+
29+
private fun createKey(): SecretKey {
30+
return KeyGenerator
31+
.getInstance(ALGORITHM)
32+
.apply {
33+
init(
34+
KeyGenParameterSpec.Builder(
35+
KEY_ALIAS,
36+
KeyProperties.PURPOSE_ENCRYPT or
37+
KeyProperties.PURPOSE_DECRYPT,
38+
)
39+
.setBlockModes(BLOCK_MODE)
40+
.setEncryptionPaddings(PADDING)
41+
.setRandomizedEncryptionRequired(true)
42+
.setUserAuthenticationRequired(false)
43+
.build(),
44+
)
45+
}
46+
.generateKey()
47+
}
48+
49+
fun encrypt(plainText: String): String {
50+
cipher.init(Cipher.ENCRYPT_MODE, getKey())
51+
val iv = cipher.iv
52+
val encryptedBytes = cipher.doFinal(plainText.toByteArray())
53+
val combined = iv + encryptedBytes
54+
return Base64.encodeToString(combined, Base64.NO_WRAP)
55+
}
56+
57+
fun decrypt(encodedText: String): String {
58+
val combined = Base64.decode(encodedText, Base64.NO_WRAP)
59+
val iv = combined.copyOfRange(0, IV_SIZE)
60+
val encrypted = combined.copyOfRange(IV_SIZE, combined.size)
61+
cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
62+
val decryptedString = String(cipher.doFinal(encrypted))
63+
return decryptedString
64+
}
65+
66+
companion object {
67+
private const val KEY_ALIAS = "secret"
68+
private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
69+
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
70+
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
71+
private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING"
72+
private const val IV_SIZE = 16 // AES IV는 항상 16바이트
73+
}
74+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.ninecraft.booket.core.datastore.util
2+
3+
import androidx.datastore.preferences.core.Preferences
4+
import androidx.datastore.preferences.core.emptyPreferences
5+
import kotlinx.coroutines.flow.Flow
6+
import kotlinx.coroutines.flow.catch
7+
import java.io.IOException
8+
9+
fun Flow<Preferences>.handleIOException(): Flow<Preferences> =
10+
catch { exception ->
11+
if (exception is IOException) {
12+
emit(emptyPreferences())
13+
} else {
14+
throw exception
15+
}
16+
}

gradle/libs.versions.toml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@ kotlin-ktlint-source = "0.50.0"
5757

5858
## Test
5959
junit = "4.13.2"
60-
junit-version = "1.2.1"
60+
androidx-test-ext-junit = "1.2.1"
61+
androidx-test-runner = "1.6.2"
6162
espresso-core = "3.6.1"
6263
material = "1.12.0"
6364

6465
[libraries]
6566
android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "android-gradle-plugin" }
66-
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name="kotlin-gradle-plugin", version.ref = "kotlin" }
67+
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
6768
compose-compiler-gradle-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" }
6869

6970
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
7273
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
7374
androidx-splash = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidx-splash" }
7475
androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidx-startup" }
75-
androidx-datastore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidx-datastore" }
76+
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidx-datastore" }
7677

7778
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" }
7879
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
@@ -94,6 +95,7 @@ retrofit-kotlinx-serialization-converter = { module = "com.squareup.retrofit2:co
9495
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
9596

9697
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
98+
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
9799
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
98100
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" }
99101
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
111113
kakao-auth = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-core" }
112114

113115
junit = { group = "junit", name = "junit", version.ref = "junit" }
114-
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit-version" }
116+
117+
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
118+
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" }
115119
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
116120
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
117121

@@ -138,7 +142,7 @@ booket-android-application = { id = "booket.android.application", version = "uns
138142
booket-android-application-compose = { id = "booket.android.application.compose", version = "unspecified" }
139143
booket-android-library = { id = "booket.android.library", version = "unspecified" }
140144
booket-android-library-compose = { id = "booket.android.library.compose", version = "unspecified" }
141-
booket-android-retrofit = { id = "booket.android.retrofit", version = "unspecified"}
145+
booket-android-retrofit = { id = "booket.android.retrofit", version = "unspecified" }
142146
booket-android-feature = { id = "booket.android.feature", version = "unspecified" }
143147
booket-android-hilt = { id = "booket.android.hilt", version = "unspecified" }
144148
booket-jvm-library = { id = "booket.jvm.library", version = "unspecified" }

0 commit comments

Comments
 (0)