feat: add provider login flow

This commit is contained in:
2026-08-23 21:58:04 +08:00
parent 6f7d3248b0
commit cc8eeebdc4
43 changed files with 2277 additions and 91 deletions
+34
View File
@@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.flagship.abox.manager.session"
compileSdk = 34
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
api(project(":domain"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.datastore.preferences)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
}
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.session.credential
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AndroidKeystoreCredentialCipherTest {
@Test
fun encryptsWithRandomIvAndProfileBoundAad() {
val cipher = AndroidKeystoreCredentialCipher(
keyAlias = "abox_manager_test_${System.nanoTime()}",
)
val profile = ProviderProfileId("profile")
val plaintext = "secret".toByteArray()
val first = cipher.encrypt(profile, plaintext)
val second = cipher.encrypt(profile, plaintext)
assertFalse(first.contentEquals(second))
assertArrayEquals(plaintext, cipher.decrypt(profile, first))
assertArrayEquals(plaintext, cipher.decrypt(profile, second))
}
}
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,68 @@
package com.flagship.abox.manager.session.credential
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import com.flagship.abox.manager.domain.ProviderProfileId
import java.nio.ByteBuffer
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class AndroidKeystoreCredentialCipher(
private val keyAlias: String = KEY_ALIAS,
) : CredentialCipher {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8))
val ciphertext = cipher.doFinal(plaintext)
return ByteBuffer.allocate(2 + cipher.iv.size + ciphertext.size)
.put(VERSION)
.put(cipher.iv.size.toByte())
.put(cipher.iv)
.put(ciphertext)
.array()
}
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
val buffer = ByteBuffer.wrap(encrypted)
require(buffer.get() == VERSION) { "Unsupported credential format" }
val ivLength = buffer.get().toInt() and 0xFF
require(ivLength in 12..16 && buffer.remaining() > ivLength) { "Invalid credential payload" }
val iv = ByteArray(ivLength).also(buffer::get)
val ciphertext = ByteArray(buffer.remaining()).also(buffer::get)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8))
return cipher.doFinal(ciphertext)
}
private fun secretKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE).run {
init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setKeySize(KEY_SIZE_BITS)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
generateKey()
}
}
companion object {
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val KEY_ALIAS = "abox_manager_credentials_v1"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val KEY_SIZE_BITS = 256
private const val GCM_TAG_BITS = 128
private const val VERSION: Byte = 1
}
}
@@ -0,0 +1,9 @@
package com.flagship.abox.manager.session.credential
import com.flagship.abox.manager.domain.ProviderProfileId
interface CredentialCipher {
fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray
fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray
}
@@ -0,0 +1,102 @@
package com.flagship.abox.manager.session.credential
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.flagship.abox.manager.domain.CredentialStore
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Username
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.security.InvalidKeyException
import java.security.KeyStoreException
import java.security.UnrecoverableKeyException
import java.util.Base64
import javax.crypto.AEADBadTagException
import kotlinx.coroutines.flow.first
class KeystoreCredentialStore(
private val dataStore: DataStore<Preferences>,
private val cipher: CredentialCipher,
) : CredentialStore {
override suspend fun saveCredentials(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Unit> = operation {
val encrypted = cipher.encrypt(profileId, encode(credentials))
dataStore.edit { it[credentialKey(profileId)] = Base64.getEncoder().encodeToString(encrypted) }
}
override suspend fun loadCredentials(
profileId: ProviderProfileId,
): DomainResult<LoginCredentials?> {
return try {
val value = dataStore.data.first()[credentialKey(profileId)]
?: return DomainResult.Success(null)
val encrypted = Base64.getDecoder().decode(value)
DomainResult.Success(decode(cipher.decrypt(profileId, encrypted)))
} catch (error: Exception) {
if (error.isPermanentCredentialFailure()) {
dataStore.edit { it.remove(credentialKey(profileId)) }
DomainResult.Success(null)
} else {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
}
}
override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit> = operation {
dataStore.edit { it.remove(credentialKey(profileId)) }
}
private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output ->
DataOutputStream(output).use { data ->
data.writeUTF(credentials.username.value)
data.writeUTF(credentials.password.value)
}
output.toByteArray()
}
private fun decode(value: ByteArray): LoginCredentials = DataInputStream(
ByteArrayInputStream(value),
).use { data ->
val username = data.readUTF()
val password = data.readUTF()
require(data.available() == 0) { "Unexpected credential data" }
LoginCredentials(Username(username), Password(password))
}
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
block()
DomainResult.Success(Unit)
} catch (error: Exception) {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
companion object {
private fun credentialKey(profileId: ProviderProfileId) =
stringPreferencesKey("profile_${profileId.value}_credential")
}
}
private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
is AEADBadTagException,
is InvalidKeyException,
is KeyStoreException,
is UnrecoverableKeyException,
is IllegalArgumentException,
-> true
else -> cause?.isPermanentCredentialFailure() == true
}
@@ -0,0 +1,38 @@
package com.flagship.abox.manager.session.gateway
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LocalSessionGateway
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionValidity
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class LocalSessionGatewayImpl : LocalSessionGateway {
private val sessions = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<Session?>>()
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = flow(profileId)
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> = DomainResult.Success(
Session(profileId, credentials.username).also { flow(profileId).value = it },
)
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = DomainResult.Success(
if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID,
)
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
flow(profileId).value = null
return DomainResult.Success(Unit)
}
private fun flow(profileId: ProviderProfileId): MutableStateFlow<Session?> =
sessions.getOrPut(profileId) { MutableStateFlow(null) }
}
@@ -0,0 +1,31 @@
package com.flagship.abox.manager.session.gateway
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SessionValidity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class UnavailableRemoteSessionGateway : SessionGateway {
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = flowOf(null)
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> = unavailable()
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = unavailable()
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> =
DomainResult.Success(Unit)
private fun <T> unavailable(): DomainResult<T> = DomainResult.Failure(
DomainError.InvalidRequest(detail = "Remote providers are not enabled in this build"),
)
}
@@ -0,0 +1,120 @@
package com.flagship.abox.manager.session.profile
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class ProviderProfileStoreImpl(
private val dataStore: DataStore<Preferences>,
) : ProviderProfileStore {
override fun observeProfiles(): Flow<List<ProviderProfile>> = dataStore.data.map(::profilesFrom)
override fun observeActiveProfile(): Flow<ProviderProfile?> = dataStore.data.map(::activeProfileFrom)
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
dataStore.data.map { preferencesFrom(it, profileId) }
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(dataStore.data.first())
override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(dataStore.data.first())
override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences =
preferencesFrom(dataStore.data.first(), profileId)
override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> = operation {
dataStore.edit { preferences ->
preferences[PROFILE_IDS] = preferences[PROFILE_IDS].orEmpty() + profile.id.value
preferences[profileTypeKey(profile.id)] = profile.type.name
preferences[profileNameKey(profile.id)] = profile.displayName
}
}
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
dataStore.edit { preferences ->
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
else preferences[ACTIVE_PROFILE_ID] = profileId.value
}
}
override suspend fun saveLoginPreferences(
preferences: LoginPreferences,
): DomainResult<Unit> = operation {
dataStore.edit { values ->
val profileId = preferences.profileId
values[rememberUsernameKey(profileId)] = preferences.rememberUsername
values[autoLoginKey(profileId)] = preferences.autoLoginEnabled
val username = preferences.rememberedUsername?.value
if (username == null) values.remove(usernameKey(profileId))
else values[usernameKey(profileId)] = username
}
}
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
preferences[PROFILE_IDS].orEmpty().mapNotNull { idValue ->
val id = ProviderProfileId(idValue)
val type = preferences[profileTypeKey(id)]
?.let { runCatching { ProviderType.valueOf(it) }.getOrNull() }
?: return@mapNotNull null
val displayName = preferences[profileNameKey(id)] ?: return@mapNotNull null
ProviderProfile(id, type, displayName)
}.sortedBy { it.id.value }
private fun activeProfileFrom(preferences: Preferences): ProviderProfile? {
val activeId = preferences[ACTIVE_PROFILE_ID] ?: return null
return profilesFrom(preferences).firstOrNull { it.id.value == activeId }
}
private fun preferencesFrom(
preferences: Preferences,
profileId: ProviderProfileId,
): LoginPreferences = LoginPreferences(
profileId = profileId,
rememberedUsername = preferences[usernameKey(profileId)]?.let(::Username),
rememberUsername = preferences[rememberUsernameKey(profileId)] ?: false,
autoLoginEnabled = preferences[autoLoginKey(profileId)] ?: false,
)
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
block()
DomainResult.Success(Unit)
} catch (error: Exception) {
DomainResult.Failure(
com.flagship.abox.manager.domain.DomainError.Unknown(
detail = error.message ?: error::class.java.simpleName,
),
)
}
companion object {
private val PROFILE_IDS = stringSetPreferencesKey("profile_ids")
private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
private fun profileTypeKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_type")
private fun profileNameKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_name")
private fun usernameKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_username")
private fun rememberUsernameKey(id: ProviderProfileId) =
booleanPreferencesKey("profile_${id.value}_remember_username")
private fun autoLoginKey(id: ProviderProfileId) =
booleanPreferencesKey("profile_${id.value}_auto_login")
}
}
@@ -0,0 +1,12 @@
package com.flagship.abox.manager.session.profile
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
private const val SESSION_DATA_STORE_NAME = "provider_session"
val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
name = SESSION_DATA_STORE_NAME,
)
@@ -0,0 +1,47 @@
package com.flagship.abox.manager.session
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.session.credential.CredentialCipher
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
import java.io.File
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CredentialStoreTest {
@Test
fun storesClearsAndIsolatesCredentials() = runTest {
val file = File.createTempFile("credentials", ".preferences_pb").apply { delete() }
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val store = KeystoreCredentialStore(dataStore, ReversingCipher())
val first = ProviderProfileId("alpha")
val second = ProviderProfileId("bravo")
val firstCredentials = LoginCredentials(Username("one"), Password("secret-one"))
val secondCredentials = LoginCredentials(Username("two"), Password("secret-two"))
store.saveCredentials(first, firstCredentials)
store.saveCredentials(second, secondCredentials)
store.clearCredentials(first)
assertNull((store.loadCredentials(first) as DomainResult.Success).value)
assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value)
}
private class ReversingCipher : CredentialCipher {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray =
(profileId.value.toByteArray() + byteArrayOf(0) + plaintext).reversedArray()
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
val decoded = encrypted.reversedArray()
val delimiter = decoded.indexOf(0)
require(decoded.copyOfRange(0, delimiter).decodeToString() == profileId.value)
return decoded.copyOfRange(delimiter + 1, decoded.size)
}
}
}
@@ -0,0 +1,34 @@
package com.flagship.abox.manager.session
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
import java.io.File
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class ProviderProfileStoreTest {
@Test
fun persistsActiveProfileAndLoginPreferences() = runTest {
val file = File.createTempFile("profiles", ".preferences_pb").apply { delete() }
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val store = ProviderProfileStoreImpl(dataStore)
val id = ProviderProfileId("abox")
val profile = ProviderProfile(id, ProviderType.ABOX, "ABox")
val preferences = LoginPreferences(id, Username("demo"), true, true)
assertEquals(DomainResult.Success(Unit), store.saveProfile(profile))
store.setActiveProfile(id)
store.saveLoginPreferences(preferences)
assertEquals(profile, store.observeActiveProfile().first())
assertEquals(preferences, store.observeLoginPreferences(id).first())
}
}