BIT-778: Account recovery (#905)

This commit is contained in:
Shannon Draeker 2024-01-31 12:37:47 -07:00 committed by Álison Fernandes
parent 10471a7ea6
commit 608779ba68
14 changed files with 308 additions and 95 deletions

View file

@ -15,6 +15,12 @@ interface AuthenticatedAccountsApi {
@HTTP(method = "DELETE", path = "/accounts", hasBody = true) @HTTP(method = "DELETE", path = "/accounts", hasBody = true)
suspend fun deleteAccount(@Body body: DeleteAccountRequestJson): Result<Unit> suspend fun deleteAccount(@Body body: DeleteAccountRequestJson): Result<Unit>
/**
* Resets the temporary password.
*/
@HTTP(method = "PUT", path = "/accounts/update-temp-password", hasBody = true)
suspend fun resetTempPassword(@Body body: ResetPasswordRequestJson): Result<Unit>
/** /**
* Resets the password. * Resets the password.
*/ */

View file

@ -14,7 +14,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ResetPasswordRequestJson( data class ResetPasswordRequestJson(
@SerialName("masterPasswordHash") @SerialName("masterPasswordHash")
val currentPasswordHash: String, val currentPasswordHash: String?,
@SerialName("newMasterPasswordHash") @SerialName("newMasterPasswordHash")
val newPasswordHash: String, val newPasswordHash: String,

View file

@ -64,6 +64,11 @@ class AccountsServiceImpl constructor(
override suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result<Unit> = override suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result<Unit> =
accountsApi.resendVerificationCodeEmail(body = body) accountsApi.resendVerificationCodeEmail(body = body)
override suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit> = override suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit> {
authenticatedAccountsApi.resetPassword(body = body) return if (body.currentPasswordHash == null) {
authenticatedAccountsApi.resetTempPassword(body = body)
} else {
authenticatedAccountsApi.resetPassword(body = body)
}
}
} }

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository package com.x8bit.bitwarden.data.auth.repository
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
@ -90,6 +91,11 @@ interface AuthRepository : AuthenticatorProvider {
*/ */
val hasExportVaultPoliciesEnabled: Boolean val hasExportVaultPoliciesEnabled: Boolean
/**
* The reason for resetting the password.
*/
val passwordResetReason: ForcePasswordResetReason?
/** /**
* Clears the pending deletion state that occurs when the an account is successfully deleted. * Clears the pending deletion state that occurs when the an account is successfully deleted.
*/ */
@ -172,11 +178,11 @@ interface AuthRepository : AuthenticatorProvider {
): PasswordHintResult ): PasswordHintResult
/** /**
* Resets the users password from the [currentPassword] to the [newPassword] and * Resets the users password from the [currentPassword] (or null for account recovery resets),
* optional [passwordHint]. * to the [newPassword] and optional [passwordHint].
*/ */
suspend fun resetPassword( suspend fun resetPassword(
currentPassword: String, currentPassword: String?,
newPassword: String, newPassword: String,
passwordHint: String?, passwordHint: String?,
): ResetPasswordResult ): ResetPasswordResult

View file

@ -248,6 +248,13 @@ class AuthRepositoryImpl(
?: false ?: false
} ?: false } ?: false
override val passwordResetReason: ForcePasswordResetReason?
get() = authDiskSource
.userState
?.activeAccount
?.profile
?.forcePasswordResetReason
init { init {
pushManager pushManager
.syncOrgKeysFlow .syncOrgKeysFlow
@ -267,6 +274,13 @@ class AuthRepositoryImpl(
authDiskSource.currentUserPoliciesListFlow authDiskSource.currentUserPoliciesListFlow
.onEach { policies -> .onEach { policies ->
val userId = activeUserId ?: return@onEach val userId = activeUserId ?: return@onEach
// If the password already has to be reset for some other reason, there's no
// need to check the password policies.
if (passwordResetReason != null) return@onEach
// Otherwise check the user's password against the policies and set or
// clear the force reset reason accordingly.
storeUserResetPasswordReason( storeUserResetPasswordReason(
userId = userId, userId = userId,
reason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN reason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN
@ -659,7 +673,7 @@ class AuthRepositoryImpl(
@Suppress("ReturnCount") @Suppress("ReturnCount")
override suspend fun resetPassword( override suspend fun resetPassword(
currentPassword: String, currentPassword: String?,
newPassword: String, newPassword: String,
passwordHint: String?, passwordHint: String?,
): ResetPasswordResult { ): ResetPasswordResult {
@ -667,17 +681,19 @@ class AuthRepositoryImpl(
.userState .userState
?.activeAccount ?.activeAccount
?: return ResetPasswordResult.Error ?: return ResetPasswordResult.Error
val currentPasswordHash = authSdkSource val currentPasswordHash = currentPassword?.let {
.hashPassword( authSdkSource
email = activeAccount.profile.email, .hashPassword(
password = currentPassword, email = activeAccount.profile.email,
kdf = activeAccount.profile.toSdkParams(), password = it,
purpose = HashPurpose.SERVER_AUTHORIZATION, kdf = activeAccount.profile.toSdkParams(),
) purpose = HashPurpose.SERVER_AUTHORIZATION,
.fold( )
onFailure = { return ResetPasswordResult.Error }, .fold(
onSuccess = { it }, onFailure = { return ResetPasswordResult.Error },
) onSuccess = { it },
)
}
return vaultSdkSource return vaultSdkSource
.updatePassword( .updatePassword(
userId = activeAccount.profile.userId, userId = activeAccount.profile.userId,

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
@ -33,7 +34,11 @@ fun GetTokenResponseJson.Success.toUserState(
organizationId = null, organizationId = null,
avatarColorHex = null, avatarColorHex = null,
hasPremium = jwtTokenData.hasPremium, hasPremium = jwtTokenData.hasPremium,
forcePasswordResetReason = null, forcePasswordResetReason = if (this.shouldForcePasswordReset) {
ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET
} else {
null
},
kdfType = this.kdfType, kdfType = this.kdfType,
kdfIterations = this.kdfIterations, kdfIterations = this.kdfIterations,
kdfMemory = this.kdfMemory, kdfMemory = this.kdfMemory,

View file

@ -58,6 +58,9 @@ fun UserStateJson.toUserState(
.values .values
.map { accountJson -> .map { accountJson ->
val userId = accountJson.profile.userId val userId = accountJson.profile.userId
val vaultUnlocked = vaultState.statusFor(userId) == VaultUnlockData.Status.UNLOCKED
val needsPasswordReset = accountJson.profile.forcePasswordResetReason != null
UserState.Account( UserState.Account(
userId = accountJson.profile.userId, userId = accountJson.profile.userId,
name = accountJson.profile.name, name = accountJson.profile.name,
@ -70,9 +73,8 @@ fun UserStateJson.toUserState(
.toEnvironmentUrlsOrDefault(), .toEnvironmentUrlsOrDefault(),
isPremium = accountJson.profile.hasPremium == true, isPremium = accountJson.profile.hasPremium == true,
isLoggedIn = accountJson.isLoggedIn, isLoggedIn = accountJson.isLoggedIn,
isVaultUnlocked = vaultState.statusFor(userId) == isVaultUnlocked = vaultUnlocked && !needsPasswordReset,
VaultUnlockData.Status.UNLOCKED, needsPasswordReset = needsPasswordReset,
needsPasswordReset = accountJson.profile.forcePasswordResetReason != null,
organizations = userOrganizationsList organizations = userOrganizationsList
.find { it.userId == userId } .find { it.userId == userId }
?.organizations ?.organizations

View file

@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
@ -164,8 +165,14 @@ private fun ResetPasswordScreeContent(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
val instructionsTextId =
if (state.resetReason == ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN) {
R.string.update_weak_master_password_warning
} else {
R.string.update_master_password_warning
}
Text( Text(
text = stringResource(id = R.string.update_weak_master_password_warning), text = stringResource(id = instructionsTextId),
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -182,28 +189,29 @@ private fun ResetPasswordScreeContent(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val passwordPolicyContent = listOf( if (state.resetReason == ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN) {
stringResource(id = R.string.master_password_policy_in_effect), val passwordPolicyContent = listOf(
) stringResource(id = R.string.master_password_policy_in_effect),
.plus(state.policies.map { it() }) )
.joinToString("\n") .plus(state.policies.map { it() })
Text( .joinToString("\n")
text = passwordPolicyContent, Text(
textAlign = TextAlign.Start, text = passwordPolicyContent,
style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier color = MaterialTheme.colorScheme.onSurfaceVariant,
.padding(horizontal = 16.dp) modifier = Modifier
.border( .padding(horizontal = 16.dp)
width = 1.dp, .border(
color = MaterialTheme.colorScheme.primary, width = 1.dp,
shape = RoundedCornerShape(4.dp), color = MaterialTheme.colorScheme.primary,
) shape = RoundedCornerShape(4.dp),
.padding(16.dp) )
.fillMaxWidth(), .padding(16.dp)
) .fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
BitwardenPasswordField( BitwardenPasswordField(
label = stringResource(id = R.string.current_master_password), label = stringResource(id = R.string.current_master_password),
@ -215,7 +223,8 @@ private fun ResetPasswordScreeContent(
.fillMaxWidth(), .fillMaxWidth(),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
}
BitwardenPasswordField( BitwardenPasswordField(
label = stringResource(id = R.string.master_password), label = stringResource(id = R.string.master_password),

View file

@ -4,6 +4,7 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
@ -11,6 +12,7 @@ import com.x8bit.bitwarden.ui.auth.feature.resetpassword.util.toDisplayLabels
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -20,6 +22,7 @@ import kotlinx.parcelize.Parcelize
import javax.inject.Inject import javax.inject.Inject
private const val KEY_STATE = "state" private const val KEY_STATE = "state"
private const val MIN_PASSWORD_LENGTH = 12
/** /**
* Manages application state for the Reset Password screen. * Manages application state for the Reset Password screen.
@ -33,6 +36,7 @@ class ResetPasswordViewModel @Inject constructor(
initialState = savedStateHandle[KEY_STATE] initialState = savedStateHandle[KEY_STATE]
?: ResetPasswordState( ?: ResetPasswordState(
policies = authRepository.passwordPolicies.toDisplayLabels(), policies = authRepository.passwordPolicies.toDisplayLabels(),
resetReason = authRepository.passwordResetReason,
dialogState = null, dialogState = null,
currentPasswordInput = "", currentPasswordInput = "",
passwordInput = "", passwordInput = "",
@ -93,8 +97,7 @@ class ResetPasswordViewModel @Inject constructor(
*/ */
private fun handleSubmitClicked() { private fun handleSubmitClicked() {
// Display an error dialog if the new password field is blank. // Display an error dialog if the new password field is blank.
val password = state.passwordInput if (state.passwordInput.isBlank()) {
if (password.isBlank()) {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
dialogState = ResetPasswordState.DialogState.Error( dialogState = ResetPasswordState.DialogState.Error(
@ -107,12 +110,35 @@ class ResetPasswordViewModel @Inject constructor(
return return
} }
// Check if the new password meets the policy requirements. // Check if the new password meets the policy requirements, if applicable.
viewModelScope.launch { if (state.resetReason == ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN) {
val result = authRepository.validatePasswordAgainstPolicies(password) viewModelScope.launch {
sendAction( val result = authRepository.validatePasswordAgainstPolicies(state.passwordInput)
ResetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult(result), sendAction(
) ResetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult(
result,
),
)
}
} else {
// Otherwise, simply verify that the password meets the minimum length requirement.
if (state.passwordInput.length < MIN_PASSWORD_LENGTH) {
mutableStateFlow.update {
it.copy(
dialogState = ResetPasswordState.DialogState.Error(
title = null,
message = R.string.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
),
)
}
} else {
// Check that the re-typed password matches.
if (!checkRetypedPassword()) return
// Otherwise, if the password checks out, attempt to reset it.
resetPassword()
}
} }
} }
@ -236,24 +262,7 @@ class ResetPasswordViewModel @Inject constructor(
) )
} }
} else { } else {
// Show the loading dialog. resetPassword()
mutableStateFlow.update {
it.copy(
dialogState = ResetPasswordState.DialogState.Loading(
message = R.string.updating_password.asText(),
),
)
}
viewModelScope.launch {
val result = authRepository.resetPassword(
currentPassword = state.currentPasswordInput,
newPassword = state.passwordInput,
passwordHint = state.passwordHintInput,
)
trySendAction(
ResetPasswordAction.Internal.ReceiveResetPasswordResult(result),
)
}
} }
} }
} }
@ -279,18 +288,8 @@ class ResetPasswordViewModel @Inject constructor(
return return
} }
// Display an error alert if the re-typed password doesn't match the new password. // Check that the re-typed password matches.
if (state.passwordInput != state.retypePasswordInput) { if (!checkRetypedPassword()) return
mutableStateFlow.update {
it.copy(
dialogState = ResetPasswordState.DialogState.Error(
title = null,
message = R.string.master_password_confirmation_val_message.asText(),
),
)
}
return
}
// Check that the entered current password is correct. // Check that the entered current password is correct.
viewModelScope.launch { viewModelScope.launch {
@ -299,6 +298,48 @@ class ResetPasswordViewModel @Inject constructor(
trySendAction(ResetPasswordAction.Internal.ReceiveValidatePasswordResult(result)) trySendAction(ResetPasswordAction.Internal.ReceiveValidatePasswordResult(result))
} }
} }
/**
* A helper function to determine if the re-typed password matches and
* display an alert if not. Returns true if the passwords match.
*/
private fun checkRetypedPassword(): Boolean {
if (state.passwordInput == state.retypePasswordInput) return true
mutableStateFlow.update {
it.copy(
dialogState = ResetPasswordState.DialogState.Error(
title = null,
message = R.string.master_password_confirmation_val_message.asText(),
),
)
}
return false
}
/**
* A helper function to launch the reset password request.
*/
private fun resetPassword() {
// Show the loading dialog.
mutableStateFlow.update {
it.copy(
dialogState = ResetPasswordState.DialogState.Loading(
message = R.string.updating_password.asText(),
),
)
}
viewModelScope.launch {
val result = authRepository.resetPassword(
currentPassword = state.currentPasswordInput.orNullIfBlank(),
newPassword = state.passwordInput,
passwordHint = state.passwordHintInput,
)
trySendAction(
ResetPasswordAction.Internal.ReceiveResetPasswordResult(result),
)
}
}
} }
/** /**
@ -307,6 +348,7 @@ class ResetPasswordViewModel @Inject constructor(
@Parcelize @Parcelize
data class ResetPasswordState( data class ResetPasswordState(
val policies: List<Text>, val policies: List<Text>,
val resetReason: ForcePasswordResetReason?,
val dialogState: DialogState?, val dialogState: DialogState?,
val currentPasswordInput: String, val currentPasswordInput: String,
val passwordInput: String, val passwordInput: String,

View file

@ -243,6 +243,21 @@ class AccountsServiceTest : BaseServiceTest() {
assertTrue(result.isSuccess) assertTrue(result.isSuccess)
} }
@Test
fun `resetPassword with empty response and null current password is success`() = runTest {
val response = MockResponse().setBody("")
server.enqueue(response)
val result = service.resetPassword(
body = ResetPasswordRequestJson(
currentPasswordHash = null,
newPasswordHash = "",
passwordHint = null,
key = "",
),
)
assertTrue(result.isSuccess)
}
companion object { companion object {
private const val EMAIL = "email" private const val EMAIL = "email"
private val registerRequestBody = RegisterRequestJson( private val registerRequestBody = RegisterRequestJson(

View file

@ -540,6 +540,25 @@ class AuthRepositoryTest {
assertTrue(repository.hasExportVaultPoliciesEnabled) assertTrue(repository.hasExportVaultPoliciesEnabled)
} }
@Test
fun `passwordResetReason should pull from the user's profile in AuthDiskSource`() = runTest {
val updatedProfile = ACCOUNT_1.profile.copy(
forcePasswordResetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
)
fakeAuthDiskSource.userState = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1.copy(
profile = updatedProfile,
),
),
)
assertEquals(
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
repository.passwordResetReason,
)
}
@Test @Test
fun `clear Pending Account Deletion should unblock userState updates`() = runTest { fun `clear Pending Account Deletion should unblock userState updates`() = runTest {
val masterPassword = "hello world" val masterPassword = "hello world"

View file

@ -79,7 +79,7 @@ private val GET_TOKEN_RESPONSE_SUCCESS = GetTokenResponseJson.Success(
kdfMemory = 16, kdfMemory = 16,
kdfParallelism = 4, kdfParallelism = 4,
privateKey = "privateKey", privateKey = "privateKey",
shouldForcePasswordReset = true, shouldForcePasswordReset = false,
shouldResetMasterPassword = true, shouldResetMasterPassword = true,
twoFactorToken = null, twoFactorToken = null,
masterPasswordPolicyOptions = null, masterPasswordPolicyOptions = null,

View file

@ -8,6 +8,7 @@ import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordAction import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordAction
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordEvent import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordEvent
@ -109,10 +110,37 @@ class ResetPasswordScreenTest : BaseComposeTest() {
} }
@Test @Test
@Suppress("MaxLineLength") fun `instructions text should update according to state`() {
fun `password instructions should update according to state`() { val weakPasswordString = "Your master password does not meet one or more " +
val baseString = "of your organization policies. In order to access the vault, you must " +
"One or more organization policies require your master password to meet the following requirements:" "update your master password now. Proceeding will log you out of your " +
"current session, requiring you to log back in. Active sessions on other " +
"devices may continue to remain active for up to one hour."
composeTestRule
.onNodeWithText(weakPasswordString)
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET,
)
}
val adminChangeString =
"Your master password was recently changed by an administrator in " +
"your organization. In order to access the vault, you must update your master " +
"password now. Proceeding will log you out of your current session, " +
"requiring you to log back in. Active sessions on other devices may continue " +
"to remain active for up to one hour."
composeTestRule
.onNodeWithText(adminChangeString)
.assertIsDisplayed()
}
@Test
fun `detailed instructions should update according to state`() {
val baseString = "One or more organization policies require your master password to " +
"meet the following requirements:"
composeTestRule composeTestRule
.onNodeWithText(baseString) .onNodeWithText(baseString)
.assertIsDisplayed() .assertIsDisplayed()
@ -131,6 +159,16 @@ class ResetPasswordScreenTest : BaseComposeTest() {
composeTestRule composeTestRule
.onNodeWithText(updatedString) .onNodeWithText(updatedString)
.assertIsDisplayed() .assertIsDisplayed()
mutableStateFlow.update {
it.copy(
resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET,
)
}
composeTestRule
.onNodeWithText(baseString)
.assertDoesNotExist()
} }
@Test @Test
@ -142,6 +180,23 @@ class ResetPasswordScreenTest : BaseComposeTest() {
} }
} }
@Test
fun `current password field should update according to state`() {
composeTestRule
.onNodeWithText("Current master password")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET,
)
}
composeTestRule
.onNodeWithText("Current master password")
.assertDoesNotExist()
}
@Test @Test
fun `password input change should send PasswordInputChange action`() { fun `password input change should send PasswordInputChange action`() {
val input = "Test123" val input = "Test123"
@ -172,6 +227,7 @@ class ResetPasswordScreenTest : BaseComposeTest() {
private val DEFAULT_STATE = ResetPasswordState( private val DEFAULT_STATE = ResetPasswordState(
policies = emptyList(), policies = emptyList(),
resetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
dialogState = null, dialogState = null,
currentPasswordInput = "", currentPasswordInput = "",
passwordInput = "", passwordInput = "",

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.auth.feature.resetPassword
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
@ -23,8 +24,9 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class ResetPasswordViewModelTest : BaseViewModelTest() { class ResetPasswordViewModelTest : BaseViewModelTest() {
private val authRepository: AuthRepository = mockk() { private val authRepository: AuthRepository = mockk {
every { passwordPolicies } returns emptyList() every { passwordPolicies } returns emptyList()
every { passwordResetReason } returns ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN
} }
private val savedStateHandle = SavedStateHandle() private val savedStateHandle = SavedStateHandle()
@ -82,11 +84,37 @@ class ResetPasswordViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `SubmitClicked with invalid password shows error alert`() = runTest { fun `SubmitClicked with invalid password shows error alert for weak password reason`() =
runTest {
val password = "Test123"
coEvery {
authRepository.validatePasswordAgainstPolicies(password)
} returns false
val viewModel = createViewModel()
viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(password))
viewModel.eventFlow.test {
viewModel.trySendAction(ResetPasswordAction.SubmitClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = ResetPasswordState.DialogState.Error(
title = R.string.master_password_policy_validation_title.asText(),
message = R.string.master_password_policy_validation_message.asText(),
),
passwordInput = password,
),
viewModel.stateFlow.value,
)
}
}
@Test
fun `SubmitClicked with invalid password shows error alert for admin reset reason`() = runTest {
val password = "Test123" val password = "Test123"
coEvery { every {
authRepository.validatePasswordAgainstPolicies(password) authRepository.passwordResetReason
} returns false } returns ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(password)) viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(password))
@ -95,9 +123,11 @@ class ResetPasswordViewModelTest : BaseViewModelTest() {
assertEquals( assertEquals(
DEFAULT_STATE.copy( DEFAULT_STATE.copy(
resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET,
dialogState = ResetPasswordState.DialogState.Error( dialogState = ResetPasswordState.DialogState.Error(
title = R.string.master_password_policy_validation_title.asText(), title = null,
message = R.string.master_password_policy_validation_message.asText(), message = R.string.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
), ),
passwordInput = password, passwordInput = password,
), ),
@ -311,8 +341,10 @@ class ResetPasswordViewModelTest : BaseViewModelTest() {
) )
} }
private const val MIN_PASSWORD_LENGTH = 12
private val DEFAULT_STATE = ResetPasswordState( private val DEFAULT_STATE = ResetPasswordState(
policies = emptyList(), policies = emptyList(),
resetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
dialogState = null, dialogState = null,
currentPasswordInput = "", currentPasswordInput = "",
passwordInput = "", passwordInput = "",