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

View file

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

View file

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

View file

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

View file

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

View file

@ -95,6 +95,7 @@ class VaultUnlockViewModel @Inject constructor(
fido2GetCredentialsRequest = null, fido2GetCredentialsRequest = null,
// TODO: [PM-13076] Handle Fido2CredentialAssertionRequest special circumstance // TODO: [PM-13076] Handle Fido2CredentialAssertionRequest special circumstance
fido2CredentialAssertionRequest = null, fido2CredentialAssertionRequest = null,
hasMasterPassword = activeAccount.hasMasterPassword,
) )
}, },
) { ) {
@ -138,7 +139,30 @@ class VaultUnlockViewModel @Inject constructor(
is VaultUnlockAction.BiometricsUnlockSuccess -> handleBiometricsUnlockSuccess(action) is VaultUnlockAction.BiometricsUnlockSuccess -> handleBiometricsUnlockSuccess(action)
VaultUnlockAction.UnlockClick -> handleUnlockClick() VaultUnlockAction.UnlockClick -> handleUnlockClick()
is VaultUnlockAction.Internal -> handleInternalAction(action) 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() { private fun handleAddAccountClick() {
@ -362,6 +386,7 @@ class VaultUnlockViewModel @Inject constructor(
isBiometricEnabled = userState.activeAccount.isBiometricsEnabled, isBiometricEnabled = userState.activeAccount.isBiometricsEnabled,
vaultUnlockType = userState.activeAccount.vaultUnlockType, vaultUnlockType = userState.activeAccount.vaultUnlockType,
input = "", input = "",
hasMasterPassword = userState.activeAccount.hasMasterPassword,
) )
} }
@ -403,6 +428,7 @@ data class VaultUnlockState(
val userId: String, val userId: String,
val fido2GetCredentialsRequest: Fido2GetCredentialsRequest? = null, val fido2GetCredentialsRequest: Fido2GetCredentialsRequest? = null,
val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest? = null, val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest? = null,
private val hasMasterPassword: Boolean,
) : Parcelable { ) : Parcelable {
/** /**
@ -434,6 +460,15 @@ data class VaultUnlockState(
val fido2RequestUserId: String? val fido2RequestUserId: String?
get() = fido2GetCredentialsRequest?.userId ?: fido2CredentialAssertionRequest?.userId 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. * Represents the various dialogs the vault unlock screen can display.
*/ */
@ -452,6 +487,12 @@ data class VaultUnlockState(
*/ */
@Parcelize @Parcelize
data object Loading : VaultUnlockDialog() 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() 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. * The user has clicked on the logout confirmation button.
*/ */
@ -548,6 +594,11 @@ sealed class VaultUnlockAction {
*/ */
data object BiometricsLockOut : 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. * 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_email">Copy email</string>
<string name="copy_phone">Copy phone number</string> <string name="copy_phone">Copy phone number</string>
<string name="copy_address">Copy address</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> </resources>

View file

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

View file

@ -541,6 +541,51 @@ class VaultUnlockScreenTest : BaseComposeTest() {
.assertDoesNotExist() .assertDoesNotExist()
composeTestRule.onNodeWithText("Unlock").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" private const val DEFAULT_ENVIRONMENT_URL: String = "vault.bitwarden.com"
@ -588,4 +633,5 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
showBiometricInvalidatedMessage = false, showBiometricInvalidatedMessage = false,
userId = ACTIVE_ACCOUNT_SUMMARY.userId, userId = ACTIVE_ACCOUNT_SUMMARY.userId,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
hasMasterPassword = true,
) )

View file

@ -1227,6 +1227,35 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
verify { fido2CredentialManager.isUserVerified = false } 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( private fun createViewModel(
state: VaultUnlockState? = null, state: VaultUnlockState? = null,
unlockType: UnlockType = UnlockType.STANDARD, unlockType: UnlockType = UnlockType.STANDARD,
@ -1275,6 +1304,7 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
showBiometricInvalidatedMessage = false, showBiometricInvalidatedMessage = false,
userId = USER_ID, userId = USER_ID,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
hasMasterPassword = true,
) )
private val TRUSTED_DEVICE: UserState.TrustedDevice = UserState.TrustedDevice( private val TRUSTED_DEVICE: UserState.TrustedDevice = UserState.TrustedDevice(