mirror of
https://github.com/bitwarden/android.git
synced 2024-11-26 19:36:18 +03:00
BIT-778: Account recovery (#905)
This commit is contained in:
parent
10471a7ea6
commit
608779ba68
14 changed files with 308 additions and 95 deletions
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 = "",
|
||||||
|
|
|
@ -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 = "",
|
||||||
|
|
Loading…
Reference in a new issue