BIT-1687: Password reprompt for items (#936)

This commit is contained in:
Shannon Draeker 2024-02-01 00:35:05 -07:00 committed by Álison Fernandes
parent 2e3200f53d
commit c5e8faccc3
9 changed files with 252 additions and 3 deletions

View file

@ -65,12 +65,19 @@ sealed class ListingItemOverflowAction : Parcelable {
* Represents the vault actions.
*/
sealed class VaultAction : ListingItemOverflowAction() {
/**
* Whether the action requires a master password re-prompt if that
* setting is enabled for the selected item.
*/
abstract val requiresPasswordReprompt: Boolean
/**
* Click on the view cipher overflow option.
*/
@Parcelize
data class ViewClick(val cipherId: String) : VaultAction() {
override val title: Text get() = R.string.view.asText()
override val requiresPasswordReprompt: Boolean get() = false
}
/**
@ -79,6 +86,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class EditClick(val cipherId: String) : VaultAction() {
override val title: Text get() = R.string.edit.asText()
override val requiresPasswordReprompt: Boolean get() = true
}
/**
@ -87,6 +95,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class CopyUsernameClick(val username: String) : VaultAction() {
override val title: Text get() = R.string.copy_username.asText()
override val requiresPasswordReprompt: Boolean get() = false
}
/**
@ -95,6 +104,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class CopyPasswordClick(val password: String) : VaultAction() {
override val title: Text get() = R.string.copy_password.asText()
override val requiresPasswordReprompt: Boolean get() = true
}
/**
@ -103,6 +113,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class CopyTotpClick(val totpCode: String) : VaultAction() {
override val title: Text get() = R.string.copy_totp.asText()
override val requiresPasswordReprompt: Boolean get() = false
}
/**
@ -111,6 +122,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class CopyNumberClick(val number: String) : VaultAction() {
override val title: Text get() = R.string.copy_number.asText()
override val requiresPasswordReprompt: Boolean get() = true
}
/**
@ -119,6 +131,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class CopySecurityCodeClick(val securityCode: String) : VaultAction() {
override val title: Text get() = R.string.copy_security_code.asText()
override val requiresPasswordReprompt: Boolean get() = true
}
/**
@ -127,6 +140,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class CopyNoteClick(val notes: String) : VaultAction() {
override val title: Text get() = R.string.copy_notes.asText()
override val requiresPasswordReprompt: Boolean get() = false
}
/**
@ -135,6 +149,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class LaunchClick(val url: String) : VaultAction() {
override val title: Text get() = R.string.launch.asText()
override val requiresPasswordReprompt: Boolean get() = false
}
}
}

View file

@ -17,6 +17,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenGroupItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderTextWithSupportLabel
import com.x8bit.bitwarden.ui.platform.components.model.toIconResources
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers
import kotlinx.collections.immutable.toPersistentList
@ -28,6 +29,7 @@ import kotlinx.collections.immutable.toPersistentList
fun VaultContent(
state: VaultState.ViewState.Content,
vaultHandlers: VaultHandlers,
onOverflowOptionClick: (action: ListingItemOverflowAction.VaultAction) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
@ -86,7 +88,15 @@ fun VaultContent(
supportingLabel = favoriteItem.supportingLabel?.invoke(),
onClick = { vaultHandlers.vaultItemClick(favoriteItem) },
overflowOptions = favoriteItem.overflowOptions,
onOverflowOptionClick = vaultHandlers.overflowOptionClick,
onOverflowOptionClick = { action ->
if (favoriteItem.shouldShowMasterPasswordReprompt &&
action.requiresPasswordReprompt
) {
onOverflowOptionClick(action)
} else {
vaultHandlers.overflowOptionClick(action)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(
@ -245,7 +255,15 @@ fun VaultContent(
supportingLabel = noFolderItem.supportingLabel?.invoke(),
onClick = { vaultHandlers.vaultItemClick(noFolderItem) },
overflowOptions = noFolderItem.overflowOptions,
onOverflowOptionClick = vaultHandlers.overflowOptionClick,
onOverflowOptionClick = { action ->
if (noFolderItem.shouldShowMasterPasswordReprompt &&
action.requiresPasswordReprompt
) {
onOverflowOptionClick(action)
} else {
vaultHandlers.overflowOptionClick(action)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),

View file

@ -43,6 +43,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenMasterPasswordDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
@ -55,6 +56,7 @@ import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.LocalExitManager
import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import kotlinx.collections.immutable.persistentListOf
@ -190,6 +192,21 @@ private fun VaultScreenScaffold(
)
}
var masterPasswordRepromptAction by remember {
mutableStateOf<ListingItemOverflowAction.VaultAction?>(null)
}
masterPasswordRepromptAction?.let { action ->
BitwardenMasterPasswordDialog(
onConfirmClick = { password ->
masterPasswordRepromptAction = null
vaultHandlers.masterPasswordRepromptSubmit(action, password)
},
onDismissRequest = {
masterPasswordRepromptAction = null
},
)
}
BitwardenScaffold(
topBar = {
BitwardenMediumTopAppBar(
@ -276,6 +293,7 @@ private fun VaultScreenScaffold(
is VaultState.ViewState.Content -> VaultContent(
state = viewState,
vaultHandlers = vaultHandlers,
onOverflowOptionClick = { masterPasswordRepromptAction = it },
modifier = innerModifier,
)

View file

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@ -144,6 +145,11 @@ class VaultViewModel @Inject constructor(
is VaultAction.DialogDismiss -> handleDialogDismiss()
is VaultAction.RefreshPull -> handleRefreshPull()
is VaultAction.OverflowOptionClick -> handleOverflowOptionClick(action)
is VaultAction.MasterPasswordRepromptSubmit -> {
handleMasterPasswordRepromptSubmit(action)
}
is VaultAction.Internal -> handleInternalAction(action)
}
}
@ -326,6 +332,20 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleMasterPasswordRepromptSubmit(
action: VaultAction.MasterPasswordRepromptSubmit,
) {
viewModelScope.launch {
val result = authRepository.validatePassword(action.password)
sendAction(
VaultAction.Internal.ValidatePasswordResultReceive(
overflowAction = action.overflowAction,
result = result,
),
)
}
}
private fun handleCopyNoteClick(action: ListingItemOverflowAction.VaultAction.CopyNoteClick) {
clipboardManager.setText(action.notes)
}
@ -390,6 +410,10 @@ class VaultViewModel @Inject constructor(
is VaultAction.Internal.IconLoadingSettingReceive -> handleIconLoadingSettingReceive(
action,
)
is VaultAction.Internal.ValidatePasswordResultReceive -> {
handleValidatePasswordResultReceive(action)
}
}
}
@ -525,6 +549,39 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleValidatePasswordResultReceive(
action: VaultAction.Internal.ValidatePasswordResultReceive,
) {
when (val result = action.result) {
ValidatePasswordResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
)
}
}
is ValidatePasswordResult.Success -> {
if (!result.isValid) {
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_master_password.asText(),
),
)
}
return
}
// Complete the overflow action.
trySendAction(VaultAction.OverflowOptionClick(action.overflowAction))
}
}
}
//endregion VaultAction Handlers
}
@ -721,6 +778,11 @@ data class VaultState(
*/
abstract val overflowOptions: List<ListingItemOverflowAction.VaultAction>
/**
* Whether to prompt the user for their password when they select an overflow option.
*/
abstract val shouldShowMasterPasswordReprompt: Boolean
/**
* Represents a login item within the vault.
*
@ -733,6 +795,7 @@ data class VaultState(
override val startIcon: IconData = IconData.Local(R.drawable.ic_login_item),
override val extraIconList: List<IconRes> = emptyList(),
override val overflowOptions: List<ListingItemOverflowAction.VaultAction>,
override val shouldShowMasterPasswordReprompt: Boolean,
val username: Text?,
) : VaultItem() {
override val supportingLabel: Text? get() = username
@ -751,6 +814,7 @@ data class VaultState(
override val startIcon: IconData = IconData.Local(R.drawable.ic_card_item),
override val extraIconList: List<IconRes> = emptyList(),
override val overflowOptions: List<ListingItemOverflowAction.VaultAction>,
override val shouldShowMasterPasswordReprompt: Boolean,
val brand: Text? = null,
val lastFourDigits: Text? = null,
) : VaultItem() {
@ -779,6 +843,7 @@ data class VaultState(
override val startIcon: IconData = IconData.Local(R.drawable.ic_identity_item),
override val extraIconList: List<IconRes> = emptyList(),
override val overflowOptions: List<ListingItemOverflowAction.VaultAction>,
override val shouldShowMasterPasswordReprompt: Boolean,
val firstName: Text?,
) : VaultItem() {
override val supportingLabel: Text? get() = firstName
@ -795,6 +860,7 @@ data class VaultState(
override val startIcon: IconData = IconData.Local(R.drawable.ic_secure_note_item),
override val extraIconList: List<IconRes> = emptyList(),
override val overflowOptions: List<ListingItemOverflowAction.VaultAction>,
override val shouldShowMasterPasswordReprompt: Boolean,
) : VaultItem() {
override val supportingLabel: Text? get() = null
}
@ -1033,6 +1099,15 @@ sealed class VaultAction {
val overflowAction: ListingItemOverflowAction.VaultAction,
) : VaultAction()
/**
* User submitted their master password to authenticate before continuing with
* the selected overflow action.
*/
data class MasterPasswordRepromptSubmit(
val overflowAction: ListingItemOverflowAction.VaultAction,
val password: String,
) : VaultAction()
/**
* Models actions that the [VaultViewModel] itself might send.
*/
@ -1070,6 +1145,14 @@ sealed class VaultAction {
data class VaultDataReceive(
val vaultData: DataState<VaultData>,
) : Internal()
/**
* Indicates that a result for verifying the user's master password has been received.
*/
data class ValidatePasswordResultReceive(
val overflowAction: ListingItemOverflowAction.VaultAction,
val result: ValidatePasswordResult,
) : Internal()
}
}

View file

@ -33,6 +33,7 @@ data class VaultHandlers(
val tryAgainClick: () -> Unit,
val dialogDismiss: () -> Unit,
val overflowOptionClick: (ListingItemOverflowAction.VaultAction) -> Unit,
val masterPasswordRepromptSubmit: (ListingItemOverflowAction.VaultAction, String) -> Unit,
) {
companion object {
/**
@ -79,6 +80,14 @@ data class VaultHandlers(
overflowOptionClick = {
viewModel.trySendAction(VaultAction.OverflowOptionClick(it))
},
masterPasswordRepromptSubmit = { action, password ->
viewModel.trySendAction(
VaultAction.MasterPasswordRepromptSubmit(
overflowAction = action,
password = password,
),
)
},
)
}
}

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.util
import android.net.Uri
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
@ -160,6 +161,7 @@ private fun CipherView.toVaultItemOrNull(
),
overflowOptions = toOverflowActions(),
extraIconList = toLabelIcons(),
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
)
CipherType.SECURE_NOTE -> VaultState.ViewState.VaultItem.SecureNote(
@ -167,6 +169,7 @@ private fun CipherView.toVaultItemOrNull(
name = name.asText(),
overflowOptions = toOverflowActions(),
extraIconList = toLabelIcons(),
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
)
CipherType.CARD -> VaultState.ViewState.VaultItem.Card(
@ -178,6 +181,7 @@ private fun CipherView.toVaultItemOrNull(
?.asText(),
overflowOptions = toOverflowActions(),
extraIconList = toLabelIcons(),
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
)
CipherType.IDENTITY -> VaultState.ViewState.VaultItem.Identity(
@ -186,6 +190,7 @@ private fun CipherView.toVaultItemOrNull(
firstName = identity?.firstName?.asText(),
overflowOptions = toOverflowActions(),
extraIconList = toLabelIcons(),
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
)
}
}

View file

@ -386,7 +386,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordRepromptSubmit for a request Success with a valid password should should post to the AutofillSelectionManager`() =
fun `MasterPasswordRepromptSubmit for a request Success with a valid password should post to the AutofillSelectionManager`() =
runTest {
setupMockUri()
val cipherView = createMockCipherView(number = 1)

View file

@ -736,6 +736,7 @@ class VaultScreenTest : BaseComposeTest() {
name = itemText.asText(),
username = username.asText(),
overflowOptions = emptyList(),
shouldShowMasterPasswordReprompt = false,
)
mutableStateFlow.update {
it.copy(
@ -857,6 +858,7 @@ class VaultScreenTest : BaseComposeTest() {
name = itemText.asText(),
username = userName.asText(),
overflowOptions = emptyList(),
shouldShowMasterPasswordReprompt = false,
)
mutableStateFlow.update {
it.copy(

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@ -1328,6 +1329,104 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `MasterPasswordRepromptSubmit for a request Error should show a generic error dialog`() =
runTest {
val password = "password"
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Error
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(
VaultAction.MasterPasswordRepromptSubmit(
overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = password,
),
password = password,
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordRepromptSubmit for a request Success with an invalid password should show an invalid password dialog`() =
runTest {
val password = "password"
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = false)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(
VaultAction.MasterPasswordRepromptSubmit(
overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = password,
),
password = password,
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_master_password.asText(),
),
),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordRepromptSubmit for a request Success with a valid password should continue the action`() =
runTest {
val password = "password"
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = true)
val viewModel = createViewModel()
viewModel.trySendAction(
VaultAction.MasterPasswordRepromptSubmit(
overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = password,
),
password = password,
),
)
verify(exactly = 1) {
clipboardManager.setText(password)
}
}
private fun createViewModel(): VaultViewModel =
VaultViewModel(
authRepository = authRepository,