Skip to content
Merged
8 changes: 7 additions & 1 deletion core/datastore/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,11 @@ android {
}

dependencies {
implementation(libs.androidx.datastore)
implementation(projects.core.security)
Comment thread
wjdrjs00 marked this conversation as resolved.

implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json)

Comment thread
wjdrjs00 marked this conversation as resolved.
testImplementation(libs.androidx.junit)
testImplementation(libs.kotlin.coroutines.test)
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.threegap.bitnagil.datastore.di

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import com.threegap.bitnagil.datastore.model.AuthToken
import com.threegap.bitnagil.datastore.serializer.AuthTokenSerializer
import com.threegap.bitnagil.datastore.serializer.TokenSerializer
import com.threegap.bitnagil.security.crypto.Crypto
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 {
@Provides
@Singleton
fun provideTokenSerializer(crypto: Crypto): TokenSerializer = AuthTokenSerializer(crypto)

@Provides
@Singleton
fun provideAuthTokenDataStore(
@ApplicationContext context: Context,
tokenSerializer: TokenSerializer,
): DataStore<AuthToken> =
DataStoreFactory.create(
serializer = tokenSerializer,
produceFile = { context.dataStoreFile("auth-token.enc") },
corruptionHandler = ReplaceFileCorruptionHandler { AuthToken() },
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.threegap.bitnagil.datastore.model

import kotlinx.serialization.Serializable

@Serializable
data class AuthToken(
val accessToken: String? = null,
val refreshToken: String? = null,
)
Comment thread
wjdrjs00 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.threegap.bitnagil.datastore.serializer

import com.threegap.bitnagil.datastore.model.AuthToken
import com.threegap.bitnagil.security.crypto.Crypto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream
import java.util.Base64
import javax.inject.Inject

internal class AuthTokenSerializer
@Inject
constructor(
private val crypto: Crypto,
) : TokenSerializer {
override val defaultValue: AuthToken
get() = AuthToken()

override suspend fun readFrom(input: InputStream): AuthToken {
return try {
val encryptedBytes =
withContext(Dispatchers.IO) {
input.use { it.readBytes() }
}
val decodedBytes = Base64.getDecoder().decode(encryptedBytes)
val decryptedBytes = crypto.decrypt(decodedBytes)
val decodedJsonString = decryptedBytes.decodeToString()
Json.decodeFromString(decodedJsonString)
} catch (e: Exception) {
AuthToken()
}
}

override suspend fun writeTo(
t: AuthToken,
output: OutputStream,
) {
val json = Json.encodeToString(t)
val bytes = json.toByteArray()
val encryptedBytes = crypto.encrypt(bytes)
val encryptedBytesBase64 = Base64.getEncoder().encode(encryptedBytes)
withContext(Dispatchers.IO) {
output.use {
it.write(encryptedBytesBase64)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.threegap.bitnagil.datastore.serializer

import androidx.datastore.core.Serializer
import com.threegap.bitnagil.datastore.model.AuthToken

interface TokenSerializer : Serializer<AuthToken>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.threegap.bitnagil.datastore.storage

import com.threegap.bitnagil.datastore.model.AuthToken
import kotlinx.coroutines.flow.Flow

interface AuthTokenDataStore {
val tokenFlow: Flow<AuthToken>

suspend fun updateAuthToken(authToken: AuthToken): AuthToken

suspend fun updateAccessToken(accessToken: String): AuthToken

suspend fun updateRefreshToken(refreshToken: String): AuthToken

suspend fun clearAuthToken(): AuthToken
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.threegap.bitnagil.datastore.storage

import android.util.Log
import androidx.datastore.core.DataStore
import com.threegap.bitnagil.datastore.model.AuthToken
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

internal class AuthTokenDataStoreImpl
@Inject
constructor(
private val dataStore: DataStore<AuthToken>,
) : AuthTokenDataStore {
override val tokenFlow: Flow<AuthToken> = dataStore.data

override suspend fun updateAuthToken(authToken: AuthToken): AuthToken =
runCatching {
dataStore.updateData { authToken }
}.fold(
onSuccess = { it },
onFailure = {
Log.e(TAG, "updateAuthToken failed:", it)
throw it
},
)

override suspend fun updateAccessToken(accessToken: String): AuthToken =
runCatching {
dataStore.updateData { authToken ->
authToken.copy(accessToken = accessToken)
}
}.fold(
onSuccess = { it },
onFailure = {
Log.e(TAG, "updateAccessToken failed:", it)
throw it
},
)

override suspend fun updateRefreshToken(refreshToken: String): AuthToken =
runCatching {
dataStore.updateData { authToken ->
authToken.copy(refreshToken = refreshToken)
}
}.fold(
onSuccess = { it },
onFailure = {
Log.e(TAG, "updateRefreshToken failed:", it)
throw it
},
)

override suspend fun clearAuthToken(): AuthToken =
runCatching {
dataStore.updateData { AuthToken() }
}.fold(
onSuccess = { it },
onFailure = {
Log.e(TAG, "clearAuthToken failed:", it)
throw it
},
)

companion object {
private const val TAG = "AuthTokenDataStore"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.threegap.bitnagil.datastore.serializer

import com.threegap.bitnagil.datastore.model.AuthToken
import com.threegap.bitnagil.security.crypto.Crypto
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.Base64

class AuthTokenSerializerTest {
private lateinit var serializer: AuthTokenSerializer
private lateinit var crypto: FakeCrypto
private lateinit var fakeToken: AuthToken
private lateinit var encrypted: ByteArray
private lateinit var json: String

private class FakeCrypto(
private val encryptResult: ByteArray,
private val decryptResult: ByteArray,
private val shouldFailDecrypt: Boolean = false,
) : Crypto {
override fun encrypt(bytes: ByteArray): ByteArray = encryptResult

override fun decrypt(bytes: ByteArray): ByteArray {
if (shouldFailDecrypt) throw RuntimeException("복호화 실패")
return decryptResult
}
}

@Before
fun setUp() {
fakeToken = AuthToken("access", "refresh")
json = Json.encodeToString(fakeToken)
encrypted = "암호화된값".toByteArray()

crypto =
FakeCrypto(
encryptResult = encrypted,
decryptResult = json.toByteArray(),
)

serializer = AuthTokenSerializer(crypto)
}

@Test
fun `writeTo는 AuthToken을 직렬화하여 기록한다`() =
runTest {
// given
val outputStream = ByteArrayOutputStream()

// when
serializer.writeTo(fakeToken, outputStream)

// then
val expected = Base64.getEncoder().encode(encrypted)
assertEquals(expected.toList(), outputStream.toByteArray().toList())
}

@Test
fun `readFrom은 InputStream을 역직렬화하여 AuthToken으로 복원한다`() =
runTest {
// given
val input = Base64.getEncoder().encode(encrypted)
val inputStream = ByteArrayInputStream(input)

// when
val result = serializer.readFrom(inputStream)

// then
assertEquals(fakeToken, result)
}

@Test
fun `readFrom에서 예외 발생시 기본값을 반환한다`() =
runTest {
// given
val brokenCrypto =
FakeCrypto(
encryptResult = byteArrayOf(),
decryptResult = byteArrayOf(),
shouldFailDecrypt = true,
)
val brokenSerializer = AuthTokenSerializer(brokenCrypto)
val inputStream = ByteArrayInputStream(Base64.getEncoder().encode(encrypted))

// when
val result = brokenSerializer.readFrom(inputStream)

// then
assertEquals(AuthToken(), result)
}
}
Loading