Add login with password flow (#1254)

This commit is contained in:
David Perez 2024-04-11 16:46:40 -05:00 committed by Álison Fernandes
parent f2301e15b9
commit 44728bba02
14 changed files with 270 additions and 50 deletions

View file

@ -373,13 +373,15 @@ class AuthRepositoryImpl(
userId = userId,
privateKey = keys.privateKey,
)
// Order matters here, we need to make sure that the vault is unlocked
// before we trust the device, to avoid state-base navigation issues.
vaultRepository.syncVaultState(userId = userId)
keys.deviceKey?.let { trustDeviceResponse ->
trustedDeviceManager.trustThisDevice(
userId = userId,
trustDeviceResponse = trustDeviceResponse,
)
}
vaultRepository.syncVaultState(userId = userId)
}
}
.fold(

View file

@ -10,6 +10,8 @@ import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDevice
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToTdeVaultUnlock
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.tdeVaultUnlockDestination
const val TRUSTED_DEVICE_GRAPH_ROUTE: String = "trusted_device_graph"
@ -43,7 +45,11 @@ fun NavGraphBuilder.trustedDeviceGraph(navController: NavHostController) {
loginType = LoginWithDeviceType.SSO_OTHER_DEVICE,
)
},
onNavigateToLock = {
navController.navigateToTdeVaultUnlock()
},
)
tdeVaultUnlockDestination()
twoFactorLoginDestination(
onNavigateBack = { navController.popBackStack() },
)

View file

@ -16,6 +16,7 @@ const val TRUSTED_DEVICE_ROUTE: String = "trusted_device"
fun NavGraphBuilder.trustedDeviceDestination(
onNavigateToAdminApproval: (emailAddress: String) -> Unit,
onNavigateToLoginWithOtherDevice: (emailAddress: String) -> Unit,
onNavigateToLock: (emailAddress: String) -> Unit,
) {
composableWithSlideTransitions(
route = TRUSTED_DEVICE_ROUTE,
@ -23,6 +24,7 @@ fun NavGraphBuilder.trustedDeviceDestination(
TrustedDeviceScreen(
onNavigateToAdminApproval = onNavigateToAdminApproval,
onNavigateToLoginWithOtherDevice = onNavigateToLoginWithOtherDevice,
onNavigateToLock = onNavigateToLock,
)
}
}

View file

@ -52,6 +52,7 @@ import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
fun TrustedDeviceScreen(
onNavigateToAdminApproval: (emailAddress: String) -> Unit,
onNavigateToLoginWithOtherDevice: (emailAddress: String) -> Unit,
onNavigateToLock: (emailAddress: String) -> Unit,
viewModel: TrustedDeviceViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -68,6 +69,10 @@ fun TrustedDeviceScreen(
onNavigateToLoginWithOtherDevice(event.email)
}
is TrustedDeviceEvent.NavigateToLockScreen -> {
onNavigateToLock(event.email)
}
is TrustedDeviceEvent.ShowToast -> {
Toast
.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT)

View file

@ -129,7 +129,8 @@ class TrustedDeviceViewModel @Inject constructor(
}
private fun handleApproveWithPasswordClick() {
sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()))
authRepository.shouldTrustDevice = state.isRemembered
sendEvent(TrustedDeviceEvent.NavigateToLockScreen(state.emailAddress))
}
private fun handleNotYouClick() {
@ -192,6 +193,13 @@ sealed class TrustedDeviceEvent {
val email: String,
) : TrustedDeviceEvent()
/**
* Navigates to the lock screen.
*/
data class NavigateToLockScreen(
val email: String,
) : TrustedDeviceEvent()
/**
* Displays the [message] as a toast.
*/

View file

@ -1,11 +1,33 @@
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
const val VAULT_UNLOCK_ROUTE: String = "vault_unlock"
private const val VAULT_UNLOCK_TYPE: String = "unlock_type"
private const val TDE_VAULT_UNLOCK_ROUTE_PREFIX: String = "tde_vault_unlock"
private const val TDE_VAULT_UNLOCK_ROUTE: String =
"$TDE_VAULT_UNLOCK_ROUTE_PREFIX/{$VAULT_UNLOCK_TYPE}"
private const val VAULT_UNLOCK_ROUTE_PREFIX: String = "vault_unlock"
const val VAULT_UNLOCK_ROUTE: String = "$VAULT_UNLOCK_ROUTE_PREFIX/{$VAULT_UNLOCK_TYPE}"
/**
* Class to retrieve vault unlock arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class VaultUnlockArgs(
val unlockType: UnlockType,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
unlockType = checkNotNull(savedStateHandle.get<UnlockType>(VAULT_UNLOCK_TYPE)),
)
}
/**
* Navigate to the Vault Unlock screen.
@ -13,7 +35,10 @@ const val VAULT_UNLOCK_ROUTE: String = "vault_unlock"
fun NavController.navigateToVaultUnlock(
navOptions: NavOptions? = null,
) {
navigate(VAULT_UNLOCK_ROUTE, navOptions)
navigate(
route = "$VAULT_UNLOCK_ROUTE_PREFIX/${UnlockType.STANDARD}",
navOptions = navOptions,
)
}
/**
@ -22,6 +47,35 @@ fun NavController.navigateToVaultUnlock(
fun NavGraphBuilder.vaultUnlockDestination() {
composable(
route = VAULT_UNLOCK_ROUTE,
arguments = listOf(
navArgument(VAULT_UNLOCK_TYPE) { type = NavType.EnumType(UnlockType::class.java) },
),
) {
VaultUnlockScreen()
}
}
/**
* Navigate to the Vault Unlock screen for TDE.
*/
fun NavController.navigateToTdeVaultUnlock(
navOptions: NavOptions? = null,
) {
navigate(
route = "$TDE_VAULT_UNLOCK_ROUTE_PREFIX/${UnlockType.TDE}",
navOptions = navOptions,
)
}
/**
* Add the Vault Unlock screen to the TDE nav graph.
*/
fun NavGraphBuilder.tdeVaultUnlockDestination() {
composable(
route = TDE_VAULT_UNLOCK_ROUTE,
arguments = listOf(
navArgument(VAULT_UNLOCK_TYPE) { type = NavType.EnumType(UnlockType::class.java) },
),
) {
VaultUnlockScreen()
}

View file

@ -138,11 +138,13 @@ fun VaultUnlockScreen(
scrollBehavior = scrollBehavior,
navigationIcon = null,
actions = {
BitwardenAccountActionItem(
initials = state.initials,
color = state.avatarColor,
onClick = { accountMenuVisible = !accountMenuVisible },
)
if (state.showAccountMenu) {
BitwardenAccountActionItem(
initials = state.initials,
color = state.avatarColor,
onClick = { accountMenuVisible = !accountMenuVisible },
)
}
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
OverflowMenuItemData(
@ -162,29 +164,33 @@ fun VaultUnlockScreen(
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
BitwardenPasswordField(
label = state.vaultUnlockType.unlockScreenInputLabel(),
value = state.input,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.InputChanged(it)) }
},
keyboardType = state.vaultUnlockType.unlockScreenKeyboardType,
showPasswordTestTag = state.vaultUnlockType.inputFieldVisibilityToggleTestTag,
modifier = Modifier
.semantics { testTag = state.vaultUnlockType.unlockScreenInputTestTag }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = state.vaultUnlockType.unlockScreenMessage(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
if (!state.hideInput) {
BitwardenPasswordField(
label = state.vaultUnlockType.unlockScreenInputLabel(),
value = state.input,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.InputChanged(it)) }
},
keyboardType = state.vaultUnlockType.unlockScreenKeyboardType,
showPasswordTestTag = state
.vaultUnlockType
.inputFieldVisibilityToggleTestTag,
modifier = Modifier
.semantics { testTag = state.vaultUnlockType.unlockScreenInputTestTag }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = state.vaultUnlockType.unlockScreenMessage(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
}
Text(
text = stringResource(
id = R.string.logged_in_as_on,
@ -220,17 +226,19 @@ fun VaultUnlockScreen(
)
Spacer(modifier = Modifier.height(12.dp))
}
BitwardenFilledButton(
label = stringResource(id = R.string.unlock),
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
},
isEnabled = state.input.isNotEmpty(),
modifier = Modifier
.semantics { testTag = "UnlockVaultButton" }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
if (!state.hideInput) {
BitwardenFilledButton(
label = stringResource(id = R.string.unlock),
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
},
isEnabled = state.input.isNotEmpty(),
modifier = Modifier
.semantics { testTag = "UnlockVaultButton" }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.navigationBarsPadding())
}

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenErrorMessage
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
@ -45,22 +46,33 @@ class VaultUnlockViewModel @Inject constructor(
) : BaseViewModel<VaultUnlockState, VaultUnlockEvent, VaultUnlockAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val userState = requireNotNull(authRepository.userStateFlow.value)
val trustedDevice = userState.activeAccount.trustedDevice
val accountSummaries = userState.toAccountSummaries()
val activeAccountSummary = userState.toActiveAccountSummary()
val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(
userId = userState.activeUserId,
)
val vaultUnlockType = userState.activeAccount.vaultUnlockType
val hasNoMasterPassword = trustedDevice?.hasMasterPassword == false
val hideInput = hasNoMasterPassword && vaultUnlockType == VaultUnlockType.MASTER_PASSWORD
val isBiometricsEnabled = userState.activeAccount.isBiometricsEnabled
if (hasNoMasterPassword && vaultUnlockType != VaultUnlockType.PIN && !isBiometricsEnabled) {
// There is no valid way to unlock this app.
authRepository.logout()
}
VaultUnlockState(
accountSummaries = accountSummaries,
avatarColorString = activeAccountSummary.avatarColorHex,
hideInput = hideInput,
initials = activeAccountSummary.initials,
email = activeAccountSummary.email,
dialog = null,
environmentUrl = environmentRepo.environment.label,
input = "",
isBiometricEnabled = userState.activeAccount.isBiometricsEnabled,
isBiometricEnabled = isBiometricsEnabled,
isBiometricsValid = isBiometricsValid,
vaultUnlockType = userState.activeAccount.vaultUnlockType,
showAccountMenu = VaultUnlockArgs(savedStateHandle).unlockType == UnlockType.STANDARD,
vaultUnlockType = vaultUnlockType,
)
},
) {
@ -272,6 +284,7 @@ class VaultUnlockViewModel @Inject constructor(
data class VaultUnlockState(
val accountSummaries: List<AccountSummary>,
private val avatarColorString: String,
val hideInput: Boolean,
val initials: String,
val email: String,
val environmentUrl: String,
@ -279,6 +292,7 @@ data class VaultUnlockState(
val input: String,
val isBiometricsValid: Boolean,
val isBiometricEnabled: Boolean,
val showAccountMenu: Boolean,
val vaultUnlockType: VaultUnlockType,
) : Parcelable {

View file

@ -0,0 +1,11 @@
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VaultUnlockScreen
/**
* Represents the different ways you may want to display the [VaultUnlockScreen].
*/
enum class UnlockType {
STANDARD,
TDE,
}

View file

@ -26,6 +26,7 @@ class TrustedDeviceScreenTest : BaseComposeTest() {
private var onNavigateToAdminApprovalEmail: String? = null
private var onNavigateToLoginWithOtherDeviceEmail: String? = null
private var onNavigateToLockEmail: String? = null
private val mutableEventFlow = bufferedMutableSharedFlow<TrustedDeviceEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -41,6 +42,7 @@ class TrustedDeviceScreenTest : BaseComposeTest() {
viewModel = viewModel,
onNavigateToAdminApproval = { onNavigateToAdminApprovalEmail = it },
onNavigateToLoginWithOtherDevice = { onNavigateToLoginWithOtherDeviceEmail = it },
onNavigateToLock = { onNavigateToLockEmail = it },
)
}
}
@ -59,6 +61,13 @@ class TrustedDeviceScreenTest : BaseComposeTest() {
assertEquals(onNavigateToLoginWithOtherDeviceEmail, email)
}
@Test
fun `on NavigateToLockScreen event should invoke NavigateToLockScreen`() {
val email = "test@bitwarden.com"
mutableEventFlow.tryEmit(TrustedDeviceEvent.NavigateToLockScreen(email))
assertEquals(onNavigateToLockEmail, email)
}
@Test
fun `on back click should send BackClick`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()

View file

@ -31,6 +31,7 @@ class TrustedDeviceViewModelTest : BaseViewModelTest() {
private val authRepository: AuthRepository = mockk {
every { authStateFlow } returns mutableAuthStateFlow
every { userStateFlow } returns mutableUserStateFlow
every { shouldTrustDevice = any() } just runs
every { logout() } just runs
}
private val environmentRepo: FakeEnvironmentRepository = FakeEnvironmentRepository()
@ -196,12 +197,15 @@ class TrustedDeviceViewModelTest : BaseViewModelTest() {
}
@Test
fun `on ApproveWithPasswordClick emits ShowToast`() = runTest {
fun `on ApproveWithPasswordClick emits NavigateToLockScreen`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithPasswordClick)
assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem())
assertEquals(TrustedDeviceEvent.NavigateToLockScreen(email = EMAIL), awaitItem())
}
verify(exactly = 1) {
authRepository.shouldTrustDevice = true
}
}

View file

@ -382,6 +382,32 @@ class VaultUnlockScreenTest : BaseComposeTest() {
viewModel.trySendAction(VaultUnlockAction.BiometricsLockOut)
}
}
@Test
fun `account button should update according to state`() {
mutableStateFlow.update { it.copy(showAccountMenu = true) }
composeTestRule.onNodeWithText("AU").assertIsDisplayed()
mutableStateFlow.update { it.copy(showAccountMenu = false) }
composeTestRule.onNodeWithText("AU").assertDoesNotExist()
}
@Test
fun `input field and unlock button should update according to state`() {
mutableStateFlow.update { it.copy(hideInput = false) }
composeTestRule.onNodeWithText("Master password").assertIsDisplayed()
composeTestRule
.onNodeWithText("Your vault is locked. Verify your master password to continue.")
.assertIsDisplayed()
composeTestRule.onNodeWithText("Unlock").assertIsDisplayed()
mutableStateFlow.update { it.copy(hideInput = true) }
composeTestRule.onNodeWithText("Master password").assertDoesNotExist()
composeTestRule
.onNodeWithText("Your vault is locked. Verify your master password to continue.")
.assertDoesNotExist()
composeTestRule.onNodeWithText("Unlock").assertDoesNotExist()
}
}
private const val DEFAULT_ENVIRONMENT_URL: String = "vault.bitwarden.com"
@ -419,9 +445,11 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
dialog = null,
email = "bit@bitwarden.com",
environmentUrl = DEFAULT_ENVIRONMENT_URL,
hideInput = false,
initials = "AU",
input = "",
isBiometricsValid = true,
isBiometricEnabled = true,
showAccountMenu = true,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
)

View file

@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentReposito
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
@ -68,6 +69,60 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
assertEquals(state, viewModel.stateFlow.value)
}
@Test
fun `on init should logout when has no master password, no pin, and no biometrics`() {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(
DEFAULT_ACCOUNT.copy(
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
isBiometricsEnabled = false,
trustedDevice = TRUSTED_DEVICE,
),
),
)
createViewModel()
verify(exactly = 1) {
authRepository.logout()
}
}
@Test
fun `on init should not logout when has no master password and no pin, with biometrics`() {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(
DEFAULT_ACCOUNT.copy(
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
isBiometricsEnabled = true,
trustedDevice = TRUSTED_DEVICE,
),
),
)
createViewModel()
verify(exactly = 0) {
authRepository.logout()
}
}
@Test
fun `on init should not logout when has no master password and no biometrics, with pin`() {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(
DEFAULT_ACCOUNT.copy(
vaultUnlockType = VaultUnlockType.PIN,
isBiometricsEnabled = false,
trustedDevice = TRUSTED_DEVICE,
),
),
)
createViewModel()
verify(exactly = 0) {
authRepository.logout()
}
}
@Test
fun `environment url should update when environment repo emits an update`() {
val viewModel = createViewModel()
@ -714,12 +769,16 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
}
private fun createViewModel(
state: VaultUnlockState? = DEFAULT_STATE,
state: VaultUnlockState? = null,
unlockType: UnlockType = UnlockType.STANDARD,
environmentRepo: EnvironmentRepository = environmentRepository,
vaultRepo: VaultRepository = vaultRepository,
biometricsEncryptionManager: BiometricsEncryptionManager = encryptionManager,
): VaultUnlockViewModel = VaultUnlockViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
savedStateHandle = SavedStateHandle().apply {
set("state", state)
set("unlock_type", unlockType)
},
authRepository = authRepository,
vaultRepo = vaultRepo,
environmentRepo = environmentRepo,
@ -742,15 +801,25 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
),
avatarColorString = "#aa00aa",
email = "active@bitwarden.com",
hideInput = false,
initials = "AU",
dialog = null,
environmentUrl = Environment.Us.label,
input = "",
isBiometricsValid = true,
isBiometricEnabled = false,
showAccountMenu = true,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
)
private val TRUSTED_DEVICE: UserState.TrustedDevice = UserState.TrustedDevice(
isDeviceTrusted = false,
hasMasterPassword = false,
hasAdminApproval = false,
hasLoginApprovingDevice = false,
hasResetPasswordPermission = false,
)
private val DEFAULT_ACCOUNT = UserState.Account(
userId = "activeUserId",
name = "Active User",

View file

@ -77,7 +77,7 @@ class RootNavScreenTest : BaseComposeTest() {
rootNavStateFlow.value = RootNavState.VaultLocked
composeTestRule.runOnIdle {
fakeNavHostController.assertLastNavigation(
route = "vault_unlock",
route = "vault_unlock/STANDARD",
navOptions = expectedNavOptions,
)
}