Add spot in encrypted shared preferences for tokens (#1038)

This commit is contained in:
David Perez 2024-02-20 14:03:36 -06:00 committed by Álison Fernandes
parent b14ed30af3
commit 946565ae54
4 changed files with 143 additions and 0 deletions

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import kotlinx.coroutines.flow.Flow
@ -209,4 +210,19 @@ interface AuthDiskSource {
* Stores the [policies] for the given [userId].
*/
fun storePolicies(userId: String, policies: List<SyncResponseJson.Policy>?)
/**
* Gets the account tokens for the given [userId].
*/
fun getAccountTokens(userId: String): AccountTokensJson?
/**
* Emits updates that track [getAccountTokens]. This will replay the last known value, if any.
*/
fun getAccountTokensFlow(userId: String): Flow<AccountTokensJson?>
/**
* Stores the [accountTokens] for the given [userId].
*/
fun storeAccountTokens(userId: String, accountTokens: AccountTokensJson?)
}

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY
import com.x8bit.bitwarden.data.platform.datasource.disk.BaseEncryptedDiskSource
@ -16,6 +17,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
private const val ACCOUNT_TOKENS_KEY = "$ENCRYPTED_BASE_KEY:accountTokens"
private const val BIOMETRICS_UNLOCK_KEY = "$ENCRYPTED_BASE_KEY:userKeyBiometricUnlock"
private const val USER_AUTO_UNLOCK_KEY_KEY = "$ENCRYPTED_BASE_KEY:userKeyAutoUnlock"
private const val UNIQUE_APP_ID_KEY = "$BASE_KEY:appId"
@ -60,6 +62,8 @@ class AuthDiskSourceImpl(
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
private val mutablePoliciesFlowMap =
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Policy>?>>()
private val mutableAccountTokensFlowMap =
mutableMapOf<String, MutableSharedFlow<AccountTokensJson?>>()
override val uniqueAppId: String
get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId()
@ -111,6 +115,7 @@ class AuthDiskSourceImpl(
storeUserBiometricUnlockKey(userId = userId, biometricsKey = null)
storeMasterPasswordHash(userId = userId, passwordHash = null)
storePolicies(userId = userId, policies = null)
storeAccountTokens(userId = userId, accountTokens = null)
}
override fun getLastActiveTimeMillis(userId: String): Long? =
@ -308,6 +313,22 @@ class AuthDiskSourceImpl(
getMutablePoliciesFlow(userId = userId).tryEmit(policies)
}
override fun getAccountTokens(userId: String): AccountTokensJson? =
getEncryptedString(key = "${ACCOUNT_TOKENS_KEY}_$userId")
?.let { json.decodeFromStringOrNull(it) }
override fun getAccountTokensFlow(userId: String): Flow<AccountTokensJson?> =
getMutableAccountTokensFlow(userId = userId)
.onSubscription { emit(getAccountTokens(userId = userId)) }
override fun storeAccountTokens(userId: String, accountTokens: AccountTokensJson?) {
putEncryptedString(
key = "${ACCOUNT_TOKENS_KEY}_$userId",
value = accountTokens?.let { json.encodeToString(it) },
)
getMutableAccountTokensFlow(userId = userId).tryEmit(accountTokens)
}
private fun generateAndStoreUniqueAppId(): String =
UUID
.randomUUID()
@ -329,4 +350,11 @@ class AuthDiskSourceImpl(
mutablePoliciesFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableAccountTokensFlow(
userId: String,
): MutableSharedFlow<AccountTokensJson?> =
mutableAccountTokensFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
}

View file

@ -190,6 +190,13 @@ class AuthDiskSourceTest {
userId = userId,
policies = listOf(createMockPolicy()),
)
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
),
)
authDiskSource.clearData(userId = userId)
@ -202,6 +209,7 @@ class AuthDiskSourceTest {
assertNull(authDiskSource.getOrganizationKeys(userId = userId))
assertNull(authDiskSource.getOrganizations(userId = userId))
assertNull(authDiskSource.getPolicies(userId = userId))
assertNull(authDiskSource.getAccountTokens(userId = userId))
}
@Test
@ -826,6 +834,71 @@ class AuthDiskSourceTest {
json.parseToJsonElement(requireNotNull(actual)),
)
}
@Test
fun `getAccountTokens should pull from SharedPreferences`() {
val baseKey = "bwSecureStorage:accountTokens"
val mockUserId = "mockUserId"
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
)
fakeEncryptedSharedPreferences.edit {
putString("${baseKey}_$mockUserId", json.encodeToString(accountTokens))
}
val actual = authDiskSource.getAccountTokens(userId = mockUserId)
assertEquals(accountTokens, actual)
}
@Test
fun `getAccountTokensFlow should react to changes from storeAccountTokens`() = runTest {
val mockUserId = "mockUserId"
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
)
authDiskSource.getAccountTokensFlow(userId = mockUserId).test {
// The initial values of the Flow and the property are in sync
assertNull(authDiskSource.getAccountTokens(userId = mockUserId))
assertNull(awaitItem())
// Updating the repository updates shared preferences
authDiskSource.storeAccountTokens(
userId = mockUserId,
accountTokens = accountTokens,
)
assertEquals(accountTokens, awaitItem())
// clear the repository clears shared preferences
authDiskSource.storeAccountTokens(
userId = mockUserId,
accountTokens = null,
)
assertNull(awaitItem())
}
}
@Test
fun `storeAccountTokens should update SharedPreferences`() {
val baseKey = "bwSecureStorage:accountTokens"
val mockUserId = "mockUserId"
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
)
authDiskSource.storeAccountTokens(
userId = mockUserId,
accountTokens = accountTokens,
)
val actual = fakeEncryptedSharedPreferences.getString(
key = "${baseKey}_$mockUserId",
defaultValue = null,
)
assertEquals(
json.encodeToJsonElement(accountTokens),
json.parseToJsonElement(requireNotNull(actual)),
)
}
}
private const val USER_STATE_JSON = """

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.disk.util
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
@ -20,6 +21,8 @@ class FakeAuthDiskSource : AuthDiskSource {
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
private val mutablePoliciesFlowMap =
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Policy>?>>()
private val mutableAccountTokensFlowMap =
mutableMapOf<String, MutableSharedFlow<AccountTokensJson?>>()
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
private val storedLastActiveTimeMillis = mutableMapOf<String, Long?>()
@ -33,6 +36,7 @@ class FakeAuthDiskSource : AuthDiskSource {
private val storedOrganizations =
mutableMapOf<String, List<SyncResponseJson.Profile.Organization>?>()
private val storedOrganizationKeys = mutableMapOf<String, Map<String, String>?>()
private val storedAccountTokens = mutableMapOf<String, AccountTokensJson?>()
private val storedBiometricKeys = mutableMapOf<String, String?>()
private val storedMasterPasswordHashes = mutableMapOf<String, String?>()
private val storedPolicies = mutableMapOf<String, List<SyncResponseJson.Policy>?>()
@ -57,10 +61,13 @@ class FakeAuthDiskSource : AuthDiskSource {
storedEncryptedPins.remove(userId)
storedOrganizations.remove(userId)
storedPolicies.remove(userId)
storedAccountTokens.remove(userId)
storedBiometricKeys.remove(userId)
storedOrganizationKeys.remove(userId)
mutableOrganizationsFlowMap.remove(userId)
mutablePoliciesFlowMap.remove(userId)
mutableAccountTokensFlowMap.remove(userId)
}
override fun getLastActiveTimeMillis(userId: String): Long? =
@ -180,6 +187,18 @@ class FakeAuthDiskSource : AuthDiskSource {
getMutablePoliciesFlow(userId = userId).tryEmit(policies)
}
override fun getAccountTokens(userId: String): AccountTokensJson? =
storedAccountTokens[userId]
override fun getAccountTokensFlow(userId: String): Flow<AccountTokensJson?> =
getMutableAccountTokensFlow(userId = userId)
.onSubscription { emit(getAccountTokens(userId)) }
override fun storeAccountTokens(userId: String, accountTokens: AccountTokensJson?) {
storedAccountTokens[userId] = accountTokens
getMutableAccountTokensFlow(userId = userId).tryEmit(accountTokens)
}
/**
* Assert that the given [userState] matches the currently tracked value.
*/
@ -304,5 +323,12 @@ class FakeAuthDiskSource : AuthDiskSource {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableAccountTokensFlow(
userId: String,
): MutableSharedFlow<AccountTokensJson?> =
mutableAccountTokensFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
//endregion Private helper functions
}