Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-9407] Confirm overwrite existing passkey in edit mode #3542

Merged
merged 20 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cc3d4e9
Introduce FIDO 2 user verification to add edit item
SaintPatrck Jul 11, 2024
7999e66
Merge remote-tracking branch 'refs/remotes/origin/main' into PM-8137/โ€ฆ
SaintPatrck Jul 15, 2024
a7f9bf7
Update branch and handle UV not supported
SaintPatrck Jul 15, 2024
82cd7c9
Chop down call and correct null option handling
SaintPatrck Jul 15, 2024
2dca33d
Docs
SaintPatrck Jul 15, 2024
7dcc56e
Merge branch 'main' into PM-8137/user-verificatoin-add-edit-item
SaintPatrck Jul 15, 2024
8a09734
Merge branch 'main' into PM-8137/user-verificatoin-add-edit-item
SaintPatrck Jul 15, 2024
49b545e
[PM-8137] Skip verification if verified during add/edit
SaintPatrck Jul 15, 2024
db8cad6
Detekt fix
SaintPatrck Jul 16, 2024
c237f22
Show FIDO 2 error dialog when UV not supported
SaintPatrck Jul 16, 2024
56de950
Merge remote-tracking branch 'refs/remotes/origin/main' into PM-8137/โ€ฆ
SaintPatrck Jul 16, 2024
b0a17aa
[PM-9407] Create reusable overwrite passkey confirmation dialog
SaintPatrck Jul 16, 2024
b1299c8
Merge branch 'refs/heads/PM-9407/overwrite-passkey-confirmation-dialoโ€ฆ
SaintPatrck Jul 16, 2024
6f7bc9f
[PM-9407] Confirm overwrite existing passkey in edit mode
SaintPatrck Jul 16, 2024
e89ea5d
Merge remote-tracking branch 'refs/remotes/origin/main' into PM-9407/โ€ฆ
SaintPatrck Jul 16, 2024
2c8003d
Update bitwarden sdk
SaintPatrck Jul 16, 2024
7b5bc1d
Fix failing tests
SaintPatrck Jul 16, 2024
72743e9
Add tests for confirmation click action
SaintPatrck Jul 17, 2024
0b9ba49
Detekt
SaintPatrck Jul 17, 2024
883feca
Merge branch 'main' into PM-9407/replace-existing-passkey-addedit
SaintPatrck Jul 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingConten
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
Expand Down Expand Up @@ -168,6 +169,13 @@ fun VaultAddEditScreen(
onFido2ErrorDismiss = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.Fido2ErrorDialogDismissed) }
},
onConfirmOverwriteExistingPasskey = remember(viewModel) {
{
viewModel.trySendAction(
action = VaultAddEditAction.Common.ConfirmOverwriteExistingPasskeyClick,
)
}
},
)

if (pendingDeleteCipher) {
Expand Down Expand Up @@ -302,6 +310,7 @@ private fun VaultAddEditItemDialogs(
onDismissRequest: () -> Unit,
onAutofillDismissRequest: () -> Unit,
onFido2ErrorDismiss: () -> Unit,
onConfirmOverwriteExistingPasskey: () -> Unit,
) {
when (dialogState) {
is VaultAddEditState.DialogState.Loading -> {
Expand Down Expand Up @@ -340,6 +349,13 @@ private fun VaultAddEditItemDialogs(
)
}

is VaultAddEditState.DialogState.OverwritePasskeyConfirmationPrompt -> {
BitwardenOverwritePasskeyConfirmationDialog(
onConfirmClick = onConfirmOverwriteExistingPasskey,
onDismissRequest = onDismissRequest,
)
}

null -> Unit
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKe
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
Expand Down Expand Up @@ -256,6 +257,10 @@ class VaultAddEditViewModel @Inject constructor(
handleHiddenFieldVisibilityChange(action)
}

is VaultAddEditAction.Common.ConfirmOverwriteExistingPasskeyClick -> {
handleConfirmOverwriteExistingPasskeyClick()
}

VaultAddEditAction.Common.UserVerificationSuccess -> {
handleUserVerificationSuccess()
}
Expand Down Expand Up @@ -367,7 +372,7 @@ class VaultAddEditViewModel @Inject constructor(
specialCircumstanceManager.specialCircumstance
?.toFido2RequestOrNull()
?.let { request ->
registerFido2Credential(request, content)
handleFido2RequestSpecialCircumstance(request, content.toCipherView())
return@onContent
}

Expand All @@ -394,13 +399,26 @@ class VaultAddEditViewModel @Inject constructor(
}
}

private fun handleFido2RequestSpecialCircumstance(
request: Fido2CredentialRequest,
cipherView: CipherView,
) {
if (cipherView.isActiveWithFido2Credentials) {
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.OverwritePasskeyConfirmationPrompt)
}
} else {
registerFido2Credential(request, cipherView)
}
}

private fun registerFido2Credential(
request: Fido2CredentialRequest,
content: VaultAddEditState.ViewState.Content,
cipherView: CipherView,
) {

if (fido2CredentialManager.isUserVerified) {
registerFido2CredentialToCipher(request, content.toCipherView())
registerFido2CredentialToCipher(request, cipherView)
return
}

Expand All @@ -413,7 +431,7 @@ class VaultAddEditViewModel @Inject constructor(

when (createOptions.authenticatorSelection.userVerification) {
UserVerificationRequirement.DISCOURAGED -> {
registerFido2CredentialToCipher(request, content.toCipherView())
registerFido2CredentialToCipher(request, cipherView)
}

UserVerificationRequirement.PREFERRED -> {
Expand Down Expand Up @@ -519,6 +537,18 @@ class VaultAddEditViewModel @Inject constructor(
}
}

private fun handleConfirmOverwriteExistingPasskeyClick() {
specialCircumstanceManager
.specialCircumstance
?.toFido2RequestOrNull()
?.let { request ->
onContent { content ->
registerFido2Credential(request, content.toCipherView())
}
}
?: showFido2ErrorDialog()
}

private fun handleUserVerificationLockOut() {
fido2CredentialManager.isUserVerified = false
showFido2ErrorDialog()
Expand Down Expand Up @@ -2049,6 +2079,12 @@ data class VaultAddEditState(
*/
@Parcelize
data class Fido2Error(val message: Text) : DialogState()

/**
* Displays the overwrite passkey confirmation prompt to the user.
*/
@Parcelize
data object OverwritePasskeyConfirmationPrompt : DialogState()
}
}

Expand Down Expand Up @@ -2186,6 +2222,11 @@ sealed class VaultAddEditAction {
*/
data object ConfirmDeleteClick : Common()

/**
* The user has confirmed overwriting the existing passkey.
*/
data object ConfirmOverwriteExistingPasskeyClick : Common()

/**
* Represents the action when a type option is selected.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3009,6 +3009,32 @@ class VaultAddEditScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationNotSupported) }
}

@Suppress("MaxLineLength")
@Test
fun `OverwritePasskeyConfirmationPrompt should display based on dialog state and send ConfirmOverwriteExistingPasskeyClick on Ok click`() {
val stateWithDialog = DEFAULT_STATE_LOGIN
.copy(dialog = VaultAddEditState.DialogState.OverwritePasskeyConfirmationPrompt)

mutableStateFlow.value = stateWithDialog

composeTestRule
.onNodeWithText("Overwrite passkey?")
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
composeTestRule
.onNodeWithText("This item already contains a passkey. Are you sure you want to overwrite the current passkey?")
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()

verify {
viewModel.trySendAction(VaultAddEditAction.Common.ConfirmOverwriteExistingPasskeyClick)
}
}

//region Helper functions

private fun updateLoginType(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRep
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.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
Expand Down Expand Up @@ -1393,6 +1394,181 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
}
}

SaintPatrck marked this conversation as resolved.
Show resolved Hide resolved
@Suppress("MaxLineLength")
@Test
fun `in edit mode during FIDO 2 registration, SaveClick should display ConfirmOverwriteExistingPasskeyDialog when original cipher has a passkey`() {
val cipherView = createMockCipherView(
number = 1,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val stateWithName = createVaultAddItemState(
vaultAddEditType = vaultAddEditType,
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
originalCipher = cipherView,
customFieldData = listOf(
VaultAddEditState.Custom.HiddenField(
itemId = "testId",
name = "mockName-1",
value = "mockValue-1",
),
),
notes = "mockNotes-1",
),
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = createMockFido2CredentialRequest(number = 1),
)
every {
cipherView.toViewState(
isClone = false,
isIndividualVaultDisabled = false,
resourceManager = resourceManager,
clock = fixedClock,
)
} returns stateWithName.viewState
mutableVaultDataFlow.value = DataState.Loaded(
createVaultData(cipherView = cipherView),
)

val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = vaultAddEditType,
),
)

viewModel.trySendAction(VaultAddEditAction.Common.SaveClick)

assertEquals(
VaultAddEditState.DialogState.OverwritePasskeyConfirmationPrompt,
viewModel.stateFlow.value.dialog,
)
}

@Suppress("MaxLineLength")
@Test
fun `ConfirmOverwriteExistingPasskeyClick should register credential when user is verified`() {
val cipherView = createMockCipherView(
number = 1,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val stateWithName = createVaultAddItemState(
vaultAddEditType = vaultAddEditType,
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
originalCipher = cipherView,
notes = "mockNotes-1",
),
)
val mockFidoRequest = createMockFido2CredentialRequest(number = 1)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = mockFidoRequest,
)
coEvery {
fido2CredentialManager.registerFido2Credential(
userId = mockFidoRequest.userId,
fido2CredentialRequest = mockFidoRequest,
selectedCipherView = any(),
)
} returns Fido2RegisterCredentialResult.Success("mockResponse")
every { authRepository.activeUserId } returns mockFidoRequest.userId
every {
cipherView.toViewState(
isClone = false,
isIndividualVaultDisabled = false,
resourceManager = resourceManager,
clock = fixedClock,
)
} returns stateWithName.viewState
every { fido2CredentialManager.isUserVerified } returns true

mutableVaultDataFlow.value = DataState.Loaded(
createVaultData(cipherView = cipherView),
)

val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = vaultAddEditType,
),
)
viewModel.trySendAction(VaultAddEditAction.Common.ConfirmOverwriteExistingPasskeyClick)

coVerify {
fido2CredentialManager.isUserVerified
fido2CredentialManager.registerFido2Credential(
userId = mockFidoRequest.userId,
fido2CredentialRequest = mockFidoRequest,
selectedCipherView = any(),
)
}
}

@Suppress("MaxLineLength")
@Test
fun `ConfirmOverwriteExistingPasskeyClick should check if user verification is required`() =
runTest {
val cipherView = createMockCipherView(
number = 1,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val stateWithName = createVaultAddItemState(
vaultAddEditType = vaultAddEditType,
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
originalCipher = cipherView,
customFieldData = listOf(
VaultAddEditState.Custom.HiddenField(
itemId = "testId",
name = "mockName-1",
value = "mockValue-1",
),
),
notes = "mockNotes-1",
),
)
val mockFidoRequest = createMockFido2CredentialRequest(number = 1)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = mockFidoRequest,
)
every {
cipherView.toViewState(
isClone = false,
isIndividualVaultDisabled = false,
resourceManager = resourceManager,
clock = fixedClock,
)
} returns stateWithName.viewState
every { fido2CredentialManager.isUserVerified } returns false
every {
fido2CredentialManager.getPasskeyCreateOptionsOrNull(any())
} returns createMockPublicKeyCredentialCreationOptions(
number = 1,
userVerificationRequirement = UserVerificationRequirement.REQUIRED,
)

mutableVaultDataFlow.value = DataState.Loaded(
createVaultData(cipherView = cipherView),
)

val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = vaultAddEditType,
),
)
viewModel.trySendAction(VaultAddEditAction.Common.ConfirmOverwriteExistingPasskeyClick)

coVerify {
fido2CredentialManager.isUserVerified
fido2CredentialManager.getPasskeyCreateOptionsOrNull(mockFidoRequest.requestJson)
}
}

@Test
fun `Saving item with an empty name field will cause a dialog to show up`() = runTest {
mutableVaultDataFlow.value = DataState.Loaded(
Expand Down