mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-802: Enforce master password policy (#849)
Co-authored-by: Sean Weiser <125889608+sean-livefront@users.noreply.github.com>
This commit is contained in:
parent
b3f23ab172
commit
2be6c9042f
36 changed files with 640 additions and 20 deletions
|
@ -177,6 +177,14 @@ interface AuthDiskSource {
|
|||
*/
|
||||
fun getOrganizationsFlow(userId: String): Flow<List<SyncResponseJson.Profile.Organization>?>
|
||||
|
||||
/**
|
||||
* Stores the organization data for the given [userId].
|
||||
*/
|
||||
fun storeOrganizations(
|
||||
userId: String,
|
||||
organizations: List<SyncResponseJson.Profile.Organization>?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the master password hash for the given [userId].
|
||||
*/
|
||||
|
@ -188,10 +196,17 @@ interface AuthDiskSource {
|
|||
fun storeMasterPasswordHash(userId: String, passwordHash: String?)
|
||||
|
||||
/**
|
||||
* Stores the organization data for the given [userId].
|
||||
* Gets the policies for the given [userId].
|
||||
*/
|
||||
fun storeOrganizations(
|
||||
userId: String,
|
||||
organizations: List<SyncResponseJson.Profile.Organization>?,
|
||||
)
|
||||
fun getPolicies(userId: String): List<SyncResponseJson.Policy>?
|
||||
|
||||
/**
|
||||
* Emits updates that track [getPolicies]. This will replay the last known value, if any.
|
||||
*/
|
||||
fun getPoliciesFlow(userId: String): Flow<List<SyncResponseJson.Policy>?>
|
||||
|
||||
/**
|
||||
* Stores the [policies] for the given [userId].
|
||||
*/
|
||||
fun storePolicies(userId: String, policies: List<SyncResponseJson.Policy>?)
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ private const val ORGANIZATIONS_KEY = "$BASE_KEY:organizations"
|
|||
private const val ORGANIZATION_KEYS_KEY = "$BASE_KEY:encOrgKeys"
|
||||
private const val TWO_FACTOR_TOKEN_KEY = "$BASE_KEY:twoFactorToken"
|
||||
private const val MASTER_PASSWORD_HASH_KEY = "$BASE_KEY:keyHash"
|
||||
private const val POLICIES_KEY = "$BASE_KEY:policies"
|
||||
|
||||
/**
|
||||
* Primary implementation of [AuthDiskSource].
|
||||
|
@ -56,6 +57,8 @@ class AuthDiskSourceImpl(
|
|||
private val inMemoryPinProtectedUserKeys = mutableMapOf<String, String?>()
|
||||
private val mutableOrganizationsFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
|
||||
private val mutablePoliciesFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Policy>?>>()
|
||||
|
||||
override val uniqueAppId: String
|
||||
get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId()
|
||||
|
@ -106,6 +109,7 @@ class AuthDiskSourceImpl(
|
|||
storeOrganizations(userId = userId, organizations = null)
|
||||
storeUserBiometricUnlockKey(userId = userId, biometricsKey = null)
|
||||
storeMasterPasswordHash(userId = userId, passwordHash = null)
|
||||
storePolicies(userId = userId, policies = null)
|
||||
}
|
||||
|
||||
override fun getLastActiveTimeMillis(userId: String): Long? =
|
||||
|
@ -276,6 +280,33 @@ class AuthDiskSourceImpl(
|
|||
putString(key = "${MASTER_PASSWORD_HASH_KEY}_$userId", value = passwordHash)
|
||||
}
|
||||
|
||||
override fun getPolicies(userId: String): List<SyncResponseJson.Policy>? =
|
||||
getString(key = "${POLICIES_KEY}_$userId")
|
||||
?.let {
|
||||
// The policies are stored as a map.
|
||||
val policiesMap: Map<String, SyncResponseJson.Policy> =
|
||||
json.decodeFromString(it)
|
||||
policiesMap.values.toList()
|
||||
}
|
||||
|
||||
override fun getPoliciesFlow(
|
||||
userId: String,
|
||||
): Flow<List<SyncResponseJson.Policy>?> =
|
||||
getMutablePoliciesFlow(userId = userId)
|
||||
.onSubscription { emit(getPolicies(userId = userId)) }
|
||||
|
||||
override fun storePolicies(userId: String, policies: List<SyncResponseJson.Policy>?) {
|
||||
putString(
|
||||
key = "${POLICIES_KEY}_$userId",
|
||||
value = policies?.let { nonNullPolicies ->
|
||||
// The policies are stored as a map.
|
||||
val policiesMap = nonNullPolicies.associateBy { it.id }
|
||||
json.encodeToString(policiesMap)
|
||||
},
|
||||
)
|
||||
getMutablePoliciesFlow(userId = userId).tryEmit(policies)
|
||||
}
|
||||
|
||||
private fun generateAndStoreUniqueAppId(): String =
|
||||
UUID
|
||||
.randomUUID()
|
||||
|
@ -290,4 +321,11 @@ class AuthDiskSourceImpl(
|
|||
mutableOrganizationsFlowMap.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutablePoliciesFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<List<SyncResponseJson.Policy>?> =
|
||||
mutablePoliciesFlowMap.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
|
||||
|
@ -227,4 +228,12 @@ interface AuthRepository : AuthenticatorProvider {
|
|||
* Validates the master password for the current logged in user.
|
||||
*/
|
||||
suspend fun validatePassword(password: String): ValidatePasswordResult
|
||||
|
||||
/**
|
||||
* Validates the given [password] against a MasterPassword [policy].
|
||||
*/
|
||||
suspend fun validatePasswordAgainstPolicy(
|
||||
password: String,
|
||||
policy: PolicyInformation.MasterPassword,
|
||||
): Boolean
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.os.SystemClock
|
|||
import com.bitwarden.crypto.HashPurpose
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
|
||||
|
@ -24,6 +25,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
|
||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
||||
|
@ -37,6 +39,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
|
||||
|
@ -47,6 +50,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.currentUserPoliciesListFlow
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJson
|
||||
|
@ -61,6 +66,8 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
|||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||
import com.x8bit.bitwarden.data.platform.util.flatMap
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
@ -116,6 +123,12 @@ class AuthRepositoryImpl(
|
|||
*/
|
||||
private var resendEmailJsonRequest: ResendEmailJsonRequest? = null
|
||||
|
||||
/**
|
||||
* The password that needs to be checked against any organization policies before
|
||||
* the user can complete the login flow.
|
||||
*/
|
||||
private var passwordToCheck: String? = null
|
||||
|
||||
/**
|
||||
* A scope intended for use when simply collecting multiple flows in order to combine them. The
|
||||
* use of [Dispatchers.Unconfined] allows for this to happen synchronously whenever any of
|
||||
|
@ -219,6 +232,21 @@ class AuthRepositoryImpl(
|
|||
.logoutFlow
|
||||
.onEach { logout() }
|
||||
.launchIn(unconfinedScope)
|
||||
|
||||
// When the policies for the user have been set, complete the login process.
|
||||
authDiskSource.currentUserPoliciesListFlow
|
||||
.onEach { policies ->
|
||||
val userId = activeUserId ?: return@onEach
|
||||
if (passwordPassesPolicies(policies)) {
|
||||
vaultRepository.completeUnlock(userId = userId)
|
||||
} else {
|
||||
storeUserResetPasswordReason(
|
||||
userId = userId,
|
||||
reason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(unconfinedScope)
|
||||
}
|
||||
|
||||
override fun clearPendingAccountDeletion() {
|
||||
|
@ -402,6 +430,10 @@ class AuthRepositoryImpl(
|
|||
passwordHash = passwordHash,
|
||||
)
|
||||
}
|
||||
|
||||
// Cache the password to verify against any password policies
|
||||
// after the sync completes.
|
||||
passwordToCheck = password
|
||||
}
|
||||
|
||||
authDiskSource.userState = userStateJson
|
||||
|
@ -821,6 +853,67 @@ class AuthRepositoryImpl(
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "ReturnCount")
|
||||
override suspend fun validatePasswordAgainstPolicy(
|
||||
password: String,
|
||||
policy: PolicyInformation.MasterPassword,
|
||||
): Boolean {
|
||||
// Check the password against all the enforced rules in the policy.
|
||||
policy.minLength?.let { minLength ->
|
||||
if (minLength > 0 && password.length < minLength) return false
|
||||
}
|
||||
policy.minComplexity?.let { minComplexity ->
|
||||
// If there was a problem checking the complexity of the password, ignore
|
||||
// the complexity checks and continue checking the other aspects of the policy.
|
||||
val profile = authDiskSource.userState?.activeAccount?.profile ?: return@let
|
||||
val passwordStrengthResult = getPasswordStrength(profile.email, password)
|
||||
val passwordStrength = (passwordStrengthResult as? PasswordStrengthResult.Success)
|
||||
?.passwordStrength
|
||||
?.toInt()
|
||||
?: return@let
|
||||
if (minComplexity > 0 && passwordStrength < minComplexity) return false
|
||||
}
|
||||
policy.requireUpper?.let { requiresUpper ->
|
||||
if (requiresUpper && !password.any { it.isUpperCase() }) return false
|
||||
}
|
||||
policy.requireLower?.let { requiresLower ->
|
||||
if (requiresLower && !password.any { it.isLowerCase() }) return false
|
||||
}
|
||||
policy.requireNumbers?.let { requiresNumbers ->
|
||||
if (requiresNumbers && !password.any { it.isDigit() }) return false
|
||||
}
|
||||
policy.requireSpecial?.let { requiresSpecial ->
|
||||
if (requiresSpecial && !password.contains("^.*[!@#$%\\^&*].*$".toRegex())) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if there are any [PolicyInformation.MasterPassword] policies that the user's
|
||||
* master password has failed to pass.
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun passwordPassesPolicies(policies: List<SyncResponseJson.Policy>?): Boolean {
|
||||
// If the user is logging on without a password or if there are no policies,
|
||||
// the check should complete.
|
||||
val password = passwordToCheck ?: return true
|
||||
val policyList = policies ?: return true
|
||||
|
||||
// If there are no master password policies that are enabled and should be
|
||||
// enforced on login, the check should complete.
|
||||
val passwordPolicies = policyList
|
||||
.filter { it.type == PolicyTypeJson.MASTER_PASSWORD && it.isEnabled }
|
||||
.mapNotNull { it.policyInformation as? PolicyInformation.MasterPassword }
|
||||
.filter { it.enforceOnLogin == true }
|
||||
|
||||
// Check the password against all the policies.
|
||||
val failingPolicies = passwordPolicies.filter { policy ->
|
||||
!validatePasswordAgainstPolicy(password, policy)
|
||||
}
|
||||
return failingPolicies.isEmpty()
|
||||
}
|
||||
|
||||
private suspend fun getFingerprintPhrase(
|
||||
publicKey: String,
|
||||
): UserFingerprintResult {
|
||||
|
@ -866,4 +959,23 @@ class AuthRepositoryImpl(
|
|||
VaultUnlockType.MASTER_PASSWORD
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the saved state with the force password reset reason.
|
||||
*/
|
||||
private fun storeUserResetPasswordReason(userId: String, reason: ForcePasswordResetReason?) {
|
||||
val accounts = authDiskSource
|
||||
.userState
|
||||
?.accounts
|
||||
?.toMutableMap()
|
||||
?: return
|
||||
val account = accounts[userId] ?: return
|
||||
val updatedProfile = account
|
||||
.profile
|
||||
.copy(forcePasswordResetReason = reason)
|
||||
accounts[userId] = account.copy(profile = updatedProfile)
|
||||
authDiskSource.userState = authDiskSource
|
||||
.userState
|
||||
?.copy(accounts = accounts)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,8 @@ data class UserState(
|
|||
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
|
||||
* authentication to view their vault.
|
||||
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked.
|
||||
* @property isVaultPendingUnlock Whether or not the user's vault is currently pending being
|
||||
* unlocked, such as when the password policy has not completed verification yet.
|
||||
* @property organizations List of [Organization]s the user is associated with, if any.
|
||||
* @property isBiometricsEnabled Indicates that the biometrics mechanism for unlocking the
|
||||
* user's vault is enabled.
|
||||
|
@ -54,6 +56,7 @@ data class UserState(
|
|||
val isPremium: Boolean,
|
||||
val isLoggedIn: Boolean,
|
||||
val isVaultUnlocked: Boolean,
|
||||
val isVaultPendingUnlock: Boolean,
|
||||
val organizations: List<Organization>,
|
||||
val isBiometricsEnabled: Boolean,
|
||||
val vaultUnlockType: VaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||
|
|
|
@ -2,10 +2,12 @@ package com.x8bit.bitwarden.data.auth.repository.util
|
|||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
|
@ -53,3 +55,22 @@ val AuthDiskSource.userOrganizationsListFlow: Flow<List<UserOrganizations>>
|
|||
) { values -> values.toList() }
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
/**
|
||||
* Returns a [Flow] that emits distinct updates to the
|
||||
* current user's [SyncResponseJson.Policy] list.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val AuthDiskSource.currentUserPoliciesListFlow: Flow<List<SyncResponseJson.Policy>?>
|
||||
get() =
|
||||
this
|
||||
.userStateFlow
|
||||
.flatMapLatest { userStateJson ->
|
||||
userStateJson
|
||||
?.activeUserId
|
||||
?.let { activeUserId ->
|
||||
this.getPoliciesFlow(activeUserId)
|
||||
}
|
||||
?: emptyFlow()
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
|
|
@ -72,6 +72,9 @@ fun UserStateJson.toUserState(
|
|||
isLoggedIn = accountJson.isLoggedIn,
|
||||
isVaultUnlocked = vaultState.statusFor(userId) ==
|
||||
VaultUnlockData.Status.UNLOCKED,
|
||||
isVaultPendingUnlock = vaultState.statusFor(userId) ==
|
||||
VaultUnlockData.Status.PENDING ||
|
||||
accountJson.profile.forcePasswordResetReason != null,
|
||||
organizations = userOrganizationsList
|
||||
.find { it.userId == userId }
|
||||
?.organizations
|
||||
|
|
|
@ -30,6 +30,12 @@ interface VaultLockManager {
|
|||
*/
|
||||
fun lockVault(userId: String)
|
||||
|
||||
/**
|
||||
* Complete the unlock flow for a given [userId], moving their pendingUnlock status
|
||||
* to a full unlock.
|
||||
*/
|
||||
fun completeUnlock(userId: String)
|
||||
|
||||
/**
|
||||
* Locks the vault for the current user if currently unlocked.
|
||||
*/
|
||||
|
|
|
@ -92,6 +92,10 @@ class VaultLockManagerImpl(
|
|||
setVaultToLocked(userId = userId)
|
||||
}
|
||||
|
||||
override fun completeUnlock(userId: String) {
|
||||
setVaultToUnlocked(userId = userId)
|
||||
}
|
||||
|
||||
override fun lockVaultForCurrentUser() {
|
||||
activeUserId?.let {
|
||||
lockVault(it)
|
||||
|
@ -164,7 +168,7 @@ class VaultLockManagerImpl(
|
|||
.also {
|
||||
if (it is VaultUnlockResult.Success) {
|
||||
clearInvalidUnlockCount(userId = userId)
|
||||
setVaultToUnlocked(userId = userId)
|
||||
setVaultToPendingUnlocked(userId = userId)
|
||||
} else {
|
||||
incrementInvalidUnlockCount(userId = userId)
|
||||
}
|
||||
|
@ -210,6 +214,12 @@ class VaultLockManagerImpl(
|
|||
storeUserAutoUnlockKeyIfNecessary(userId = userId)
|
||||
}
|
||||
|
||||
private fun setVaultToPendingUnlocked(userId: String) {
|
||||
mutableVaultUnlockDataStateFlow.update {
|
||||
it.update(userId, VaultUnlockData.Status.PENDING)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setVaultToLocked(userId: String) {
|
||||
vaultSdkSource.clearCrypto(userId = userId)
|
||||
mutableVaultUnlockDataStateFlow.update {
|
||||
|
|
|
@ -314,6 +314,10 @@ class VaultRepositoryImpl(
|
|||
|
||||
unlockVaultForOrganizationsIfNecessary(syncResponse = syncResponse)
|
||||
storeProfileData(syncResponse = syncResponse)
|
||||
authDiskSource.storePolicies(
|
||||
userId = userId,
|
||||
policies = syncResponse.policies,
|
||||
)
|
||||
vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse)
|
||||
settingsDiskSource.storeLastSyncTime(userId = userId, clock.instant())
|
||||
},
|
||||
|
|
|
@ -61,6 +61,7 @@ class RootNavViewModel @Inject constructor(
|
|||
val updatedRootNavState = when {
|
||||
userState == null ||
|
||||
!userState.activeAccount.isLoggedIn ||
|
||||
userState.activeAccount.isVaultPendingUnlock ||
|
||||
userState.hasPendingAccountAddition -> RootNavState.Auth
|
||||
|
||||
userState.activeAccount.isVaultUnlocked -> {
|
||||
|
|
|
@ -262,6 +262,7 @@ class MainViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
|
|||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
|
@ -184,6 +185,10 @@ class AuthDiskSourceTest {
|
|||
userId = userId,
|
||||
organizations = listOf(createMockOrganization(1)),
|
||||
)
|
||||
authDiskSource.storePolicies(
|
||||
userId = userId,
|
||||
policies = listOf(createMockPolicy()),
|
||||
)
|
||||
|
||||
authDiskSource.clearData(userId = userId)
|
||||
|
||||
|
@ -195,6 +200,7 @@ class AuthDiskSourceTest {
|
|||
assertNull(authDiskSource.getPrivateKey(userId = userId))
|
||||
assertNull(authDiskSource.getOrganizationKeys(userId = userId))
|
||||
assertNull(authDiskSource.getOrganizations(userId = userId))
|
||||
assertNull(authDiskSource.getPolicies(userId = userId))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -752,6 +758,73 @@ class AuthDiskSourceTest {
|
|||
actual,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPolicies should pull from SharedPreferences`() {
|
||||
val policiesBaseKey = "bwPreferencesStorage:policies"
|
||||
val mockUserId = "mockUserId"
|
||||
val mockPolicies = listOf(
|
||||
createMockPolicy(number = 0),
|
||||
createMockPolicy(number = 1),
|
||||
)
|
||||
val mockPoliciesMap = mockPolicies.associateBy { it.id }
|
||||
fakeSharedPreferences
|
||||
.edit {
|
||||
putString(
|
||||
"${policiesBaseKey}_$mockUserId",
|
||||
json.encodeToString(mockPoliciesMap),
|
||||
)
|
||||
}
|
||||
val actual = authDiskSource.getPolicies(userId = mockUserId)
|
||||
assertEquals(
|
||||
mockPolicies,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPoliciesFlow should react to changes in getOrganizations`() = runTest {
|
||||
val mockUserId = "mockUserId"
|
||||
val mockPolicies = listOf(
|
||||
createMockPolicy(number = 0),
|
||||
createMockPolicy(number = 1),
|
||||
)
|
||||
authDiskSource.getPoliciesFlow(userId = mockUserId).test {
|
||||
// The initial values of the Flow and the property are in sync
|
||||
assertNull(authDiskSource.getPolicies(userId = mockUserId))
|
||||
assertNull(awaitItem())
|
||||
|
||||
// Updating the repository updates shared preferences
|
||||
authDiskSource.storePolicies(
|
||||
userId = mockUserId,
|
||||
policies = mockPolicies,
|
||||
)
|
||||
assertEquals(mockPolicies, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storePolicies should update SharedPreferences`() {
|
||||
val policiesBaseKey = "bwPreferencesStorage:policies"
|
||||
val mockUserId = "mockUserId"
|
||||
val mockPolicies = listOf(
|
||||
createMockPolicy(number = 0),
|
||||
createMockPolicy(number = 1),
|
||||
)
|
||||
val mockPoliciesMap = mockPolicies.associateBy { it.id }
|
||||
authDiskSource.storePolicies(
|
||||
userId = mockUserId,
|
||||
policies = mockPolicies,
|
||||
)
|
||||
val actual = fakeSharedPreferences.getString(
|
||||
"${policiesBaseKey}_$mockUserId",
|
||||
null,
|
||||
)
|
||||
assertEquals(
|
||||
json.encodeToJsonElement(mockPoliciesMap),
|
||||
json.parseToJsonElement(requireNotNull(actual)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val USER_STATE_JSON = """
|
||||
|
|
|
@ -18,6 +18,8 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
|
||||
private val mutableOrganizationsFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
|
||||
private val mutablePoliciesFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Policy>?>>()
|
||||
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
|
||||
|
||||
private val storedLastActiveTimeMillis = mutableMapOf<String, Long?>()
|
||||
|
@ -33,6 +35,7 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
private val storedOrganizationKeys = mutableMapOf<String, Map<String, String>?>()
|
||||
private val storedBiometricKeys = mutableMapOf<String, String?>()
|
||||
private val storedMasterPasswordHashes = mutableMapOf<String, String?>()
|
||||
private val storedPolicies = mutableMapOf<String, List<SyncResponseJson.Policy>?>()
|
||||
|
||||
override var userState: UserStateJson? = null
|
||||
set(value) {
|
||||
|
@ -53,10 +56,11 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
storedPinProtectedUserKeys.remove(userId)
|
||||
storedEncryptedPins.remove(userId)
|
||||
storedOrganizations.remove(userId)
|
||||
storedPolicies.remove(userId)
|
||||
storedBiometricKeys.remove(userId)
|
||||
|
||||
storedOrganizationKeys.remove(userId)
|
||||
mutableOrganizationsFlowMap.remove(userId)
|
||||
mutablePoliciesFlowMap.remove(userId)
|
||||
}
|
||||
|
||||
override fun getLastActiveTimeMillis(userId: String): Long? =
|
||||
|
@ -164,6 +168,18 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
storedMasterPasswordHashes[userId] = passwordHash
|
||||
}
|
||||
|
||||
override fun getPolicies(
|
||||
userId: String,
|
||||
): List<SyncResponseJson.Policy>? = storedPolicies[userId]
|
||||
|
||||
override fun getPoliciesFlow(userId: String): Flow<List<SyncResponseJson.Policy>?> =
|
||||
getMutablePoliciesFlow(userId = userId).onSubscription { emit(getPolicies(userId)) }
|
||||
|
||||
override fun storePolicies(userId: String, policies: List<SyncResponseJson.Policy>?) {
|
||||
storedPolicies[userId] = policies
|
||||
getMutablePoliciesFlow(userId = userId).tryEmit(policies)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given [userState] matches the currently tracked value.
|
||||
*/
|
||||
|
@ -262,6 +278,16 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
assertEquals(organizations, storedOrganizations[userId])
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the [policies] were stored successfully using the [userId].
|
||||
*/
|
||||
fun assertPolicies(
|
||||
userId: String,
|
||||
policies: List<SyncResponseJson.Policy>?,
|
||||
) {
|
||||
assertEquals(policies, storedPolicies[userId])
|
||||
}
|
||||
|
||||
//region Private helper functions
|
||||
|
||||
private fun getMutableOrganizationsFlow(
|
||||
|
@ -271,5 +297,12 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutablePoliciesFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<List<SyncResponseJson.Policy>?> =
|
||||
mutablePoliciesFlowMap.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
//endregion Private helper functions
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import com.bitwarden.crypto.Kdf
|
|||
import com.bitwarden.crypto.RsaKeyPair
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
|
||||
|
@ -56,6 +57,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.createMockMasterPasswordPolicy
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
|
||||
|
@ -72,7 +74,9 @@ import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentReposito
|
|||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
|
||||
|
@ -88,6 +92,9 @@ import io.mockk.unmockkStatic
|
|||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
|
@ -108,9 +115,10 @@ class AuthRepositoryTest {
|
|||
private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk()
|
||||
private val newAuthRequestService: NewAuthRequestService = mockk()
|
||||
private val organizationService: OrganizationService = mockk()
|
||||
private val mutableVaultStateFlow = MutableStateFlow(VAULT_STATE)
|
||||
private val mutableVaultUnlockDataStateFlow = MutableStateFlow(VAULT_UNLOCK_DATA)
|
||||
private val vaultRepository: VaultRepository = mockk {
|
||||
every { vaultUnlockDataStateFlow } returns mutableVaultStateFlow
|
||||
every { vaultUnlockDataStateFlow } returns mutableVaultUnlockDataStateFlow
|
||||
every { completeUnlock(any()) } just runs
|
||||
every { deleteVaultData(any()) } just runs
|
||||
every { clearUnlockedData() } just runs
|
||||
}
|
||||
|
@ -270,11 +278,11 @@ class AuthRepositoryTest {
|
|||
repository.userStateFlow.value,
|
||||
)
|
||||
|
||||
mutableVaultStateFlow.value = VAULT_STATE
|
||||
mutableVaultUnlockDataStateFlow.value = VAULT_UNLOCK_DATA
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
assertEquals(
|
||||
SINGLE_USER_STATE_1.toUserState(
|
||||
vaultState = VAULT_STATE,
|
||||
vaultState = VAULT_UNLOCK_DATA,
|
||||
userOrganizationsList = emptyList(),
|
||||
hasPendingAccountAddition = false,
|
||||
isBiometricsEnabledProvider = { false },
|
||||
|
@ -296,7 +304,7 @@ class AuthRepositoryTest {
|
|||
}
|
||||
assertEquals(
|
||||
MULTI_USER_STATE.toUserState(
|
||||
vaultState = VAULT_STATE,
|
||||
vaultState = VAULT_UNLOCK_DATA,
|
||||
userOrganizationsList = emptyList(),
|
||||
hasPendingAccountAddition = false,
|
||||
isBiometricsEnabledProvider = { false },
|
||||
|
@ -306,7 +314,7 @@ class AuthRepositoryTest {
|
|||
)
|
||||
|
||||
val emptyVaultState = emptyList<VaultUnlockData>()
|
||||
mutableVaultStateFlow.value = emptyVaultState
|
||||
mutableVaultUnlockDataStateFlow.value = emptyVaultState
|
||||
assertEquals(
|
||||
MULTI_USER_STATE.toUserState(
|
||||
vaultState = emptyVaultState,
|
||||
|
@ -344,6 +352,122 @@ class AuthRepositoryTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Suppress("MaxLineLength")
|
||||
fun `loading the policies should emit masterPasswordPolicyFlow if the password fails any checks`() =
|
||||
runTest {
|
||||
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
|
||||
coEvery {
|
||||
accountsService.preLogin(email = EMAIL)
|
||||
} returns Result.success(PRE_LOGIN_SUCCESS)
|
||||
coEvery {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||
username = EMAIL,
|
||||
password = PASSWORD_HASH,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
} returns Result.success(successResponse)
|
||||
coEvery {
|
||||
vaultRepository.unlockVault(
|
||||
userId = USER_ID_1,
|
||||
email = EMAIL,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
userKey = successResponse.key,
|
||||
privateKey = successResponse.privateKey,
|
||||
organizationKeys = null,
|
||||
masterPassword = PASSWORD,
|
||||
)
|
||||
} returns VaultUnlockResult.Success
|
||||
coEvery { vaultRepository.syncIfNecessary() } just runs
|
||||
every {
|
||||
GET_TOKEN_RESPONSE_SUCCESS.toUserState(
|
||||
previousUserState = null,
|
||||
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
|
||||
)
|
||||
} returns SINGLE_USER_STATE_1
|
||||
|
||||
// Start the login flow so that all the necessary data is cached.
|
||||
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||
|
||||
// Set policies that will fail the password.
|
||||
fakeAuthDiskSource.storePolicies(
|
||||
userId = USER_ID_1,
|
||||
policies = listOf(
|
||||
createMockPolicy(
|
||||
type = PolicyTypeJson.MASTER_PASSWORD,
|
||||
isEnabled = true,
|
||||
data = buildJsonObject {
|
||||
put(key = "minLength", value = 100)
|
||||
put(key = "minComplexity", value = null)
|
||||
put(key = "requireUpper", value = null)
|
||||
put(key = "requireLower", value = null)
|
||||
put(key = "requireNumbers", value = null)
|
||||
put(key = "requireSpecial", value = null)
|
||||
put(key = "enforceOnLogin", value = true)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Verify the results.
|
||||
assertEquals(LoginResult.Success, result)
|
||||
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
|
||||
coVerify { accountsService.preLogin(email = EMAIL) }
|
||||
fakeAuthDiskSource.assertPrivateKey(
|
||||
userId = USER_ID_1,
|
||||
privateKey = "privateKey",
|
||||
)
|
||||
fakeAuthDiskSource.assertUserKey(
|
||||
userId = USER_ID_1,
|
||||
userKey = "key",
|
||||
)
|
||||
fakeAuthDiskSource.assertMasterPasswordHash(
|
||||
userId = USER_ID_1,
|
||||
passwordHash = PASSWORD_HASH,
|
||||
)
|
||||
coVerify {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||
username = EMAIL,
|
||||
password = PASSWORD_HASH,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
vaultRepository.unlockVault(
|
||||
userId = USER_ID_1,
|
||||
email = EMAIL,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
userKey = successResponse.key,
|
||||
privateKey = successResponse.privateKey,
|
||||
organizationKeys = null,
|
||||
masterPassword = PASSWORD,
|
||||
)
|
||||
vaultRepository.syncIfNecessary()
|
||||
}
|
||||
assertEquals(
|
||||
UserStateJson(
|
||||
activeUserId = USER_ID_1,
|
||||
accounts = mapOf(
|
||||
USER_ID_1 to ACCOUNT_1.copy(
|
||||
profile = ACCOUNT_1.profile.copy(
|
||||
forcePasswordResetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
fakeAuthDiskSource.userState,
|
||||
)
|
||||
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
|
||||
verify { vaultRepository.clearUnlockedData() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rememberedEmailAddress should pull from and update AuthDiskSource`() {
|
||||
// AuthDiskSource and the repository start with the same value.
|
||||
|
@ -379,14 +503,14 @@ class AuthRepositoryTest {
|
|||
val masterPassword = "hello world"
|
||||
val hashedMasterPassword = "dlrow olleh"
|
||||
val originalUserState = SINGLE_USER_STATE_1.toUserState(
|
||||
vaultState = VAULT_STATE,
|
||||
vaultState = VAULT_UNLOCK_DATA,
|
||||
userOrganizationsList = emptyList(),
|
||||
hasPendingAccountAddition = false,
|
||||
isBiometricsEnabledProvider = { false },
|
||||
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
|
||||
)
|
||||
val finalUserState = SINGLE_USER_STATE_2.toUserState(
|
||||
vaultState = VAULT_STATE,
|
||||
vaultState = VAULT_UNLOCK_DATA,
|
||||
userOrganizationsList = emptyList(),
|
||||
hasPendingAccountAddition = false,
|
||||
isBiometricsEnabledProvider = { false },
|
||||
|
@ -700,6 +824,7 @@ class AuthRepositoryTest {
|
|||
)
|
||||
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
|
||||
verify { vaultRepository.clearUnlockedData() }
|
||||
verify { vaultRepository.completeUnlock(userId = USER_ID_1) }
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
|
@ -2053,7 +2178,7 @@ class AuthRepositoryTest {
|
|||
fun `switchAccount when the given userId is the same as the current activeUserId should reset any pending account additions`() {
|
||||
val originalUserId = USER_ID_1
|
||||
val originalUserState = SINGLE_USER_STATE_1.toUserState(
|
||||
vaultState = VAULT_STATE,
|
||||
vaultState = VAULT_UNLOCK_DATA,
|
||||
userOrganizationsList = emptyList(),
|
||||
hasPendingAccountAddition = false,
|
||||
isBiometricsEnabledProvider = { false },
|
||||
|
@ -2084,7 +2209,7 @@ class AuthRepositoryTest {
|
|||
fun `switchAccount when the given userId does not correspond to a saved account should do nothing`() {
|
||||
val invalidId = "invalidId"
|
||||
val originalUserState = SINGLE_USER_STATE_1.toUserState(
|
||||
vaultState = VAULT_STATE,
|
||||
vaultState = VAULT_UNLOCK_DATA,
|
||||
userOrganizationsList = emptyList(),
|
||||
hasPendingAccountAddition = false,
|
||||
isBiometricsEnabledProvider = { false },
|
||||
|
@ -2113,7 +2238,7 @@ class AuthRepositoryTest {
|
|||
fun `switchAccount when the userId is valid should update the current UserState, clear the previously unlocked data, and reset any pending account additions`() {
|
||||
val updatedUserId = USER_ID_2
|
||||
val originalUserState = MULTI_USER_STATE.toUserState(
|
||||
vaultState = VAULT_STATE,
|
||||
vaultState = VAULT_UNLOCK_DATA,
|
||||
userOrganizationsList = emptyList(),
|
||||
hasPendingAccountAddition = false,
|
||||
isBiometricsEnabledProvider = { false },
|
||||
|
@ -2817,6 +2942,38 @@ class AuthRepositoryTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePasswordAgainstPolicy validates password against policy requirements`() = runTest {
|
||||
var policy = createMockMasterPasswordPolicy(minLength = 10)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = "123", policy = policy))
|
||||
|
||||
val password = "simple"
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
coEvery {
|
||||
authSdkSource.passwordStrength(
|
||||
email = SINGLE_USER_STATE_1.activeAccount.profile.email,
|
||||
password = password,
|
||||
)
|
||||
} returns Result.success(LEVEL_0)
|
||||
policy = createMockMasterPasswordPolicy(minComplexity = 1)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = password, policy = policy))
|
||||
|
||||
policy = createMockMasterPasswordPolicy(requireUpper = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = "lower", policy = policy))
|
||||
|
||||
policy = createMockMasterPasswordPolicy(requireLower = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = "UPPER", policy = policy))
|
||||
|
||||
policy = createMockMasterPasswordPolicy(requireNumbers = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = "letters", policy = policy))
|
||||
|
||||
policy = createMockMasterPasswordPolicy(requireSpecial = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = "letters", policy = policy))
|
||||
|
||||
policy = createMockMasterPasswordPolicy(minLength = 5)
|
||||
assertTrue(repository.validatePasswordAgainstPolicy(password = "password", policy = policy))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val UNIQUE_APP_ID = "testUniqueAppId"
|
||||
private const val EMAIL = "test@bitwarden.com"
|
||||
|
@ -2959,7 +3116,7 @@ class AuthRepositoryTest {
|
|||
organizations = ORGANIZATIONS.toOrganizations(),
|
||||
),
|
||||
)
|
||||
private val VAULT_STATE = listOf(
|
||||
private val VAULT_UNLOCK_DATA = listOf(
|
||||
VaultUnlockData(
|
||||
userId = USER_ID_1,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
/**
|
||||
* Create a mock [PolicyInformation.MasterPassword] with a given parameters.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun createMockMasterPasswordPolicy(
|
||||
minLength: Int? = null,
|
||||
minComplexity: Int? = null,
|
||||
requireUpper: Boolean? = null,
|
||||
requireLower: Boolean? = null,
|
||||
requireNumbers: Boolean? = null,
|
||||
requireSpecial: Boolean? = null,
|
||||
enforceOnLogin: Boolean? = null,
|
||||
): PolicyInformation.MasterPassword =
|
||||
PolicyInformation.MasterPassword(
|
||||
minLength = minLength,
|
||||
minComplexity = minComplexity,
|
||||
requireUpper = requireUpper,
|
||||
requireLower = requireLower,
|
||||
requireNumbers = requireNumbers,
|
||||
requireSpecial = requireSpecial,
|
||||
enforceOnLogin = enforceOnLogin,
|
||||
)
|
|
@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
@ -154,4 +155,37 @@ class AuthDiskSourceExtensionsTest {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `currentUserPoliciesListFlow should emit changes to current user's policy data`() =
|
||||
runTest {
|
||||
val userId = "userId1"
|
||||
val userStateJson = mockk<UserStateJson>() {
|
||||
every { activeUserId } returns userId
|
||||
}
|
||||
authDiskSource.apply {
|
||||
userState = userStateJson
|
||||
storePolicies(
|
||||
userId = userId,
|
||||
policies = listOf(createMockPolicy()),
|
||||
)
|
||||
}
|
||||
|
||||
authDiskSource.currentUserPoliciesListFlow.test {
|
||||
assertEquals(
|
||||
listOf(createMockPolicy()),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
authDiskSource.storePolicies(
|
||||
userId = userId,
|
||||
policies = listOf(createMockPolicy(number = 3)),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(createMockPolicy(number = 3)),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -107,6 +107,7 @@ class UserStateJsonExtensionsTest {
|
|||
isPremium = false,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = "organizationId",
|
||||
|
@ -128,6 +129,7 @@ class UserStateJsonExtensionsTest {
|
|||
every { email } returns "activeEmail"
|
||||
every { avatarColorHex } returns "activeAvatarColorHex"
|
||||
every { hasPremium } returns null
|
||||
every { forcePasswordResetReason } returns null
|
||||
},
|
||||
tokens = AccountJson.Tokens(
|
||||
accessToken = "accessToken",
|
||||
|
@ -180,6 +182,7 @@ class UserStateJsonExtensionsTest {
|
|||
isPremium = true,
|
||||
isLoggedIn = false,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = "organizationId",
|
||||
|
@ -202,6 +205,7 @@ class UserStateJsonExtensionsTest {
|
|||
every { email } returns "activeEmail"
|
||||
every { avatarColorHex } returns null
|
||||
every { hasPremium } returns true
|
||||
every { forcePasswordResetReason } returns null
|
||||
},
|
||||
tokens = AccountJson.Tokens(
|
||||
accessToken = null,
|
||||
|
|
|
@ -8,12 +8,13 @@ import kotlinx.serialization.json.JsonObject
|
|||
fun createMockPolicy(
|
||||
number: Int = 1,
|
||||
type: PolicyTypeJson = PolicyTypeJson.MASTER_PASSWORD,
|
||||
isEnabled: Boolean = false,
|
||||
data: JsonObject? = null,
|
||||
): SyncResponseJson.Policy =
|
||||
SyncResponseJson.Policy(
|
||||
organizationId = "mockOrganizationId-$number",
|
||||
id = "mockId-$number",
|
||||
type = type,
|
||||
isEnabled = false,
|
||||
isEnabled = isEnabled,
|
||||
data = data,
|
||||
)
|
||||
|
|
|
@ -723,6 +723,7 @@ class VaultLockManagerTest {
|
|||
privateKey = privateKey,
|
||||
organizationKeys = organizationKeys,
|
||||
)
|
||||
vaultLockManager.completeUnlock(userId = USER_ID)
|
||||
|
||||
assertEquals(VaultUnlockResult.Success, result)
|
||||
assertEquals(
|
||||
|
@ -826,6 +827,7 @@ class VaultLockManagerTest {
|
|||
privateKey = privateKey,
|
||||
organizationKeys = organizationKeys,
|
||||
)
|
||||
vaultLockManager.completeUnlock(userId = USER_ID)
|
||||
|
||||
assertEquals(VaultUnlockResult.Success, result)
|
||||
assertEquals(
|
||||
|
@ -1360,6 +1362,7 @@ class VaultLockManagerTest {
|
|||
),
|
||||
organizationKeys = organizationKeys,
|
||||
)
|
||||
vaultLockManager.completeUnlock(userId = userId)
|
||||
|
||||
assertEquals(VaultUnlockResult.Success, result)
|
||||
coVerify(exactly = 1) {
|
||||
|
|
|
@ -56,6 +56,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockDomains
|
|||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockFolder
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganizationKeys
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSend
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSendJsonRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSyncResponse
|
||||
|
@ -514,6 +515,10 @@ class VaultRepositoryTest {
|
|||
userId = "mockId-1",
|
||||
organizations = listOf(createMockOrganization(number = 1)),
|
||||
)
|
||||
fakeAuthDiskSource.assertPolicies(
|
||||
userId = "mockId-1",
|
||||
policies = listOf(createMockPolicy(number = 1)),
|
||||
)
|
||||
coVerify {
|
||||
vaultDiskSource.replaceVaultData(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
|
|
|
@ -71,6 +71,7 @@ class LandingViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -202,6 +203,7 @@ class LandingViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
@ -252,6 +254,7 @@ class LandingViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = false,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
|
|
@ -127,6 +127,7 @@ class LoginViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -116,6 +116,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -149,6 +150,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = true,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -725,6 +727,7 @@ private val DEFAULT_ACCOUNT = UserState.Account(
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
|
|
@ -47,6 +47,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = false,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -73,6 +74,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -84,6 +86,35 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
assertEquals(RootNavState.Auth, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the active user has a pending unlocked vault the nav state should be Auth`() {
|
||||
mutableUserStateFlow.tryEmit(
|
||||
UserState(
|
||||
activeUserId = "activeUserId",
|
||||
accounts = listOf(
|
||||
UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "name",
|
||||
email = "email",
|
||||
avatarColorHex = "avatarColorHex",
|
||||
environment = Environment.Us,
|
||||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = true,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(
|
||||
RootNavState.Auth,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the active user has an unlocked vault the nav state should be VaultUnlocked`() {
|
||||
mutableUserStateFlow.tryEmit(
|
||||
|
@ -99,6 +130,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -133,6 +165,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -171,6 +204,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -202,6 +236,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -974,6 +974,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -192,6 +192,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1786,6 +1786,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -1002,6 +1002,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
|
|||
isPremium = false,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
|
|
@ -553,6 +553,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -1497,6 +1497,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -1372,6 +1372,7 @@ private val DEFAULT_ACCOUNT = UserState.Account(
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
|
|
@ -422,6 +422,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
|
|
@ -94,6 +94,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = if (hasOrganizations) {
|
||||
listOf(
|
||||
|
|
|
@ -162,6 +162,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -1279,6 +1280,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -1291,6 +1293,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isPremium = false,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -69,6 +69,7 @@ class UserStateExtensionsTest {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -86,6 +87,7 @@ class UserStateExtensionsTest {
|
|||
isPremium = false,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -107,6 +109,7 @@ class UserStateExtensionsTest {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -128,6 +131,7 @@ class UserStateExtensionsTest {
|
|||
isPremium = true,
|
||||
isLoggedIn = false,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -164,6 +168,7 @@ class UserStateExtensionsTest {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -198,6 +203,7 @@ class UserStateExtensionsTest {
|
|||
isPremium = false,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -236,6 +242,7 @@ class UserStateExtensionsTest {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -262,6 +269,7 @@ class UserStateExtensionsTest {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
@ -297,6 +305,7 @@ class UserStateExtensionsTest {
|
|||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
|
Loading…
Add table
Reference in a new issue