PM-15062 Checking if the user has a no longer supported biometric as their only way of unlocking their account. (#4338)

This commit is contained in:
Dave Severns 2024-11-20 15:45:26 -05:00 committed by GitHub
parent 3092ba1fc6
commit ec8e934bf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 194 additions and 20 deletions

View file

@ -2,8 +2,10 @@ package com.x8bit.bitwarden.data.auth.datasource.disk.model
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
/**
* Represents the current account information for a given user.
@ -45,6 +47,7 @@ data class AccountJson(
* @property kdfParallelism The number of threads to use when calculating a password hash.
* @property userDecryptionOptions The options available to a user for decryption.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class Profile(
@SerialName("userId")
@ -86,7 +89,8 @@ data class AccountJson(
@SerialName("kdfParallelism")
val kdfParallelism: Int?,
@SerialName("accountDecryptionOptions")
@SerialName("userDecryptionOptions")
@JsonNames("accountDecryptionOptions")
val userDecryptionOptions: UserDecryptionOptionsJson?,
)

View file

@ -1,15 +1,19 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
/**
* Decryption options related to a user's key connector.
*
* @property keyConnectorUrl URL to the user's key connector.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class KeyConnectorUserDecryptionOptionsJson(
@SerialName("KeyConnectorUrl")
@SerialName("keyConnectorUrl")
@JsonNames("KeyConnectorUrl")
val keyConnectorUrl: String,
)

View file

@ -1,7 +1,9 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
/**
* Decryption options related to a user's trusted device.
@ -13,20 +15,26 @@ import kotlinx.serialization.Serializable
* @property hasManageResetPasswordPermission Whether or not the user has manage reset password
* permission.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class TrustedDeviceUserDecryptionOptionsJson(
@SerialName("EncryptedPrivateKey")
@SerialName("encryptedPrivateKey")
@JsonNames("EncryptedPrivateKey")
val encryptedPrivateKey: String?,
@SerialName("EncryptedUserKey")
@SerialName("encryptedUserKey")
@JsonNames("EncryptedUserKey")
val encryptedUserKey: String?,
@SerialName("HasAdminApproval")
@SerialName("hasAdminApproval")
@JsonNames("HasAdminApproval")
val hasAdminApproval: Boolean,
@SerialName("HasLoginApprovingDevice")
@SerialName("hasLoginApprovingDevice")
@JsonNames("HasLoginApprovingDevice")
val hasLoginApprovingDevice: Boolean,
@SerialName("HasManageResetPasswordPermission")
@SerialName("hasManageResetPasswordPermission")
@JsonNames("HasManageResetPasswordPermission")
val hasManageResetPasswordPermission: Boolean,
)

View file

@ -1,7 +1,9 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
/**
* The options available to a user for decryption.
@ -12,14 +14,18 @@ import kotlinx.serialization.Serializable
* device.
* @property keyConnectorUserDecryptionOptions Decryption options related to a user's key connector.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class UserDecryptionOptionsJson(
@SerialName("HasMasterPassword")
@SerialName("hasMasterPassword")
@JsonNames("HasMasterPassword")
val hasMasterPassword: Boolean,
@SerialName("TrustedDeviceOption")
@SerialName("trustedDeviceOption")
@JsonNames("TrustedDeviceOption")
val trustedDeviceUserDecryptionOptions: TrustedDeviceUserDecryptionOptionsJson?,
@SerialName("KeyConnectorOption")
@SerialName("keyConnectorOption")
@JsonNames("KeyConnectorOption")
val keyConnectorUserDecryptionOptions: KeyConnectorUserDecryptionOptionsJson?,
)

View file

@ -17,6 +17,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -85,6 +86,12 @@ fun VaultUnlockScreen(
val context = LocalContext.current
val resources = context.resources
LaunchedEffect(state.requiresBiometricsLogin) {
if (state.requiresBiometricsLogin && !biometricsManager.isBiometricsSupported) {
viewModel.trySendAction(VaultUnlockAction.BiometricsNoLongerSupported)
}
}
val onBiometricsUnlockSuccess: (cipher: Cipher?) -> Unit = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(it)) }
}
@ -148,6 +155,22 @@ fun VaultUnlockScreen(
visibilityState = LoadingDialogState.Shown(R.string.loading.asText()),
)
VaultUnlockState.VaultUnlockDialog.BiometricsNoLongerSupported -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = R.string.biometrics_no_longer_supported_title.asText(),
message = R.string.biometrics_no_longer_supported.asText(),
),
onDismissRequest = remember {
{
viewModel.trySendAction(
VaultUnlockAction.DismissBiometricsNoLongerSupportedDialog,
)
}
},
)
}
null -> Unit
}

View file

@ -95,6 +95,7 @@ class VaultUnlockViewModel @Inject constructor(
fido2GetCredentialsRequest = null,
// TODO: [PM-13076] Handle Fido2CredentialAssertionRequest special circumstance
fido2CredentialAssertionRequest = null,
hasMasterPassword = activeAccount.hasMasterPassword,
)
},
) {
@ -138,9 +139,32 @@ class VaultUnlockViewModel @Inject constructor(
is VaultUnlockAction.BiometricsUnlockSuccess -> handleBiometricsUnlockSuccess(action)
VaultUnlockAction.UnlockClick -> handleUnlockClick()
is VaultUnlockAction.Internal -> handleInternalAction(action)
VaultUnlockAction.BiometricsNoLongerSupported -> {
handleBiometricsNoLongerSupported()
}
VaultUnlockAction.DismissBiometricsNoLongerSupportedDialog -> {
handleDismissBiometricsNoLongerSupportedDialog()
}
}
}
private fun handleBiometricsNoLongerSupported() {
mutableStateFlow.update {
it.copy(
dialog = VaultUnlockState.VaultUnlockDialog.BiometricsNoLongerSupported,
)
}
}
private fun handleDismissBiometricsNoLongerSupportedDialog() {
mutableStateFlow.update {
it.copy(dialog = null)
}
authRepository.logout()
authRepository.hasPendingAccountAddition = true
}
private fun handleAddAccountClick() {
authRepository.hasPendingAccountAddition = true
}
@ -362,6 +386,7 @@ class VaultUnlockViewModel @Inject constructor(
isBiometricEnabled = userState.activeAccount.isBiometricsEnabled,
vaultUnlockType = userState.activeAccount.vaultUnlockType,
input = "",
hasMasterPassword = userState.activeAccount.hasMasterPassword,
)
}
@ -403,6 +428,7 @@ data class VaultUnlockState(
val userId: String,
val fido2GetCredentialsRequest: Fido2GetCredentialsRequest? = null,
val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest? = null,
private val hasMasterPassword: Boolean,
) : Parcelable {
/**
@ -434,6 +460,15 @@ data class VaultUnlockState(
val fido2RequestUserId: String?
get() = fido2GetCredentialsRequest?.userId ?: fido2CredentialAssertionRequest?.userId
/**
* If the user requires biometrics to be able to unlock the account.
*/
val requiresBiometricsLogin: Boolean
get() = when (vaultUnlockType) {
VaultUnlockType.MASTER_PASSWORD -> !hasMasterPassword && isBiometricEnabled
VaultUnlockType.PIN -> false
}
/**
* Represents the various dialogs the vault unlock screen can display.
*/
@ -452,6 +487,12 @@ data class VaultUnlockState(
*/
@Parcelize
data object Loading : VaultUnlockDialog()
/**
* Show dialog for when biometrics the user has is no longer supported.
*/
@Parcelize
data object BiometricsNoLongerSupported : VaultUnlockDialog()
}
}
@ -496,6 +537,11 @@ sealed class VaultUnlockAction {
*/
data object DismissDialog : VaultUnlockAction()
/**
* The user has dismissed the biometrics not supported dialog
*/
data object DismissBiometricsNoLongerSupportedDialog : VaultUnlockAction()
/**
* The user has clicked on the logout confirmation button.
*/
@ -548,6 +594,11 @@ sealed class VaultUnlockAction {
*/
data object BiometricsLockOut : VaultUnlockAction()
/**
* The user has biometric unlock setup that is no longer valid.
*/
data object BiometricsNoLongerSupported : VaultUnlockAction()
/**
* The user has clicked the unlock button.
*/

View file

@ -1095,4 +1095,6 @@ Do you want to switch to this account?</string>
<string name="copy_email">Copy email</string>
<string name="copy_phone">Copy phone number</string>
<string name="copy_address">Copy address</string>
<string name="biometrics_no_longer_supported_title">Biometrics are no longer supported on this device</string>
<string name="biometrics_no_longer_supported">Youve been logged out because your devices biometrics dont meet the latest security requirements. To update settings, log in once again or contact your administrator for access.</string>
</resources>

View file

@ -1266,17 +1266,17 @@ private const val USER_STATE_JSON = """
"kdfIterations": 600000,
"kdfMemory": 16,
"kdfParallelism": 4,
"accountDecryptionOptions": {
"HasMasterPassword": true,
"TrustedDeviceOption": {
"EncryptedPrivateKey": "encryptedPrivateKey",
"EncryptedUserKey": "encryptedUserKey",
"HasAdminApproval": true,
"HasLoginApprovingDevice": true,
"HasManageResetPasswordPermission": true
"userDecryptionOptions": {
"hasMasterPassword": true,
"trustedDeviceOption": {
"encryptedPrivateKey": "encryptedPrivateKey",
"encryptedUserKey": "encryptedUserKey",
"hasAdminApproval": true,
"hasLoginApprovingDevice": true,
"hasManageResetPasswordPermission": true
},
"KeyConnectorOption": {
"KeyConnectorUrl": "keyConnectorUrl"
"keyConnectorOption": {
"keyConnectorUrl": "keyConnectorUrl"
}
}
},

View file

@ -541,6 +541,51 @@ class VaultUnlockScreenTest : BaseComposeTest() {
.assertDoesNotExist()
composeTestRule.onNodeWithText("Unlock").assertDoesNotExist()
}
@Test
fun `biometrics not supported dialog shows correctly`() {
mutableStateFlow.update {
it.copy(dialog = VaultUnlockState.VaultUnlockDialog.BiometricsNoLongerSupported)
}
composeTestRule
.onNodeWithText("Biometrics are no longer supported on this device")
.assertIsDisplayed()
}
@Test
fun `DismissBiometricsNoLongerSupportedDialog should be sent when dialog is dismissed`() {
mutableStateFlow.update {
it.copy(dialog = VaultUnlockState.VaultUnlockDialog.BiometricsNoLongerSupported)
}
composeTestRule
.onNodeWithText("Biometrics are no longer supported on this device")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Ok")
.performClick()
verify {
viewModel.trySendAction(VaultUnlockAction.DismissBiometricsNoLongerSupportedDialog)
}
}
@Suppress("MaxLineLength")
@Test
fun `when biometric is needed but no longer supported BiometricsNoLongerSupported action is sent`() {
every { biometricsManager.isBiometricsSupported } returns false
mutableStateFlow.update {
it.copy(
isBiometricEnabled = true,
hasMasterPassword = false,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
)
}
composeTestRule.waitForIdle()
verify {
viewModel.trySendAction(VaultUnlockAction.BiometricsNoLongerSupported)
}
}
}
private const val DEFAULT_ENVIRONMENT_URL: String = "vault.bitwarden.com"
@ -588,4 +633,5 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
showBiometricInvalidatedMessage = false,
userId = ACTIVE_ACCOUNT_SUMMARY.userId,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
hasMasterPassword = true,
)

View file

@ -1227,6 +1227,35 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
verify { fido2CredentialManager.isUserVerified = false }
}
@Test
fun `on BiometricsNoLongerSupported should show correct dialog state`() {
val viewModel = createViewModel()
viewModel.trySendAction(VaultUnlockAction.BiometricsNoLongerSupported)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultUnlockState.VaultUnlockDialog.BiometricsNoLongerSupported,
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `on DismissBiometricsNoLongerSupportedDialog should dismiss dialog state and log the user out`() {
val viewModel = createViewModel()
viewModel.trySendAction(VaultUnlockAction.DismissBiometricsNoLongerSupportedDialog)
assertEquals(
DEFAULT_STATE.copy(
dialog = null,
),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
authRepository.logout()
authRepository.hasPendingAccountAddition = true
}
}
private fun createViewModel(
state: VaultUnlockState? = null,
unlockType: UnlockType = UnlockType.STANDARD,
@ -1275,6 +1304,7 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
showBiometricInvalidatedMessage = false,
userId = USER_ID,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
hasMasterPassword = true,
)
private val TRUSTED_DEVICE: UserState.TrustedDevice = UserState.TrustedDevice(