BIT-1921: Add JIT Provisioning (#1133)

This commit is contained in:
Caleb Derosier
2024-03-13 12:14:28 -06:00
committed by Álison Fernandes
parent 509ef72546
commit 5b1545f53b
22 changed files with 1574 additions and 99 deletions

View File

@@ -44,10 +44,10 @@ sealed class GetTokenResponseJson {
val expiresInSeconds: Int,
@SerialName("Key")
val key: String,
val key: String?,
@SerialName("PrivateKey")
val privateKey: String,
val privateKey: String?,
@SerialName("Kdf")
val kdfType: KdfTypeJson,

View File

@@ -68,6 +68,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
val yubiKeyResultFlow: Flow<YubiKeyResult>
/**
* The organization identifier currently associated with this user.
*/
var organizationIdentifier: String?
/**
* The two-factor response data necessary for login and also to populate the
* Two-Factor Login screen.
@@ -153,12 +158,14 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
/**
* Attempt to login using a SSO flow. Updated access token will be reflected in [authStateFlow].
*/
@Suppress("LongParameterList")
suspend fun login(
email: String,
ssoCode: String,
ssoCodeVerifier: String,
ssoRedirectUri: String,
captchaToken: String?,
organizationIdentifier: String,
): LoginResult
/**
@@ -210,11 +217,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
): ResetPasswordResult
/**
* Sets the user's password to [password] for the user within the given [organizationId] with
* an optional [passwordHint].
* Sets the user's password to [password] for the user within the given [organizationIdentifier]
* with an optional [passwordHint].
*/
suspend fun setPassword(
organizationId: String,
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult

View File

@@ -59,6 +59,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJsonWithPassword
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
@@ -157,6 +158,8 @@ class AuthRepositoryImpl(
private val ioScope = CoroutineScope(dispatcherManager.io)
override var organizationIdentifier: String? = null
override var twoFactorResponse: TwoFactorRequired? = null
override val activeUserId: String? get() = authDiskSource.userState?.activeUserId
@@ -400,6 +403,7 @@ class AuthRepositoryImpl(
ssoCodeVerifier: String,
ssoRedirectUri: String,
captchaToken: String?,
organizationIdentifier: String,
): LoginResult = loginCommon(
email = email,
authModel = IdentityTokenAuthModel.SingleSignOn(
@@ -408,19 +412,21 @@ class AuthRepositoryImpl(
ssoRedirectUri = ssoRedirectUri,
),
captchaToken = captchaToken,
orgIdentifier = organizationIdentifier,
)
/**
* A helper function to extract the common logic of logging in through
* any of the available methods.
*/
@Suppress("LongMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
private suspend fun loginCommon(
email: String,
password: String? = null,
authModel: IdentityTokenAuthModel,
twoFactorData: TwoFactorDataModel? = null,
deviceData: DeviceDataModel? = null,
orgIdentifier: String? = null,
captchaToken: String?,
): LoginResult = identityService
.getToken(
@@ -474,6 +480,11 @@ class AuthRepositoryImpl(
)
}
// Set the current organization identifier for use in JIT provisioning.
if (loginResponse.userDecryptionOptions?.hasMasterPassword == false) {
organizationIdentifier = orgIdentifier
}
// Remove any cached data after successfully logging in.
identityTokenAuthModel = null
twoFactorResponse = null
@@ -482,17 +493,19 @@ class AuthRepositoryImpl(
// Attempt to unlock the vault with password if possible.
password?.let {
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
userKey = loginResponse.key,
privateKey = loginResponse.privateKey,
masterPassword = it,
// We can separately unlock the vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
if (loginResponse.privateKey != null && loginResponse.key != null) {
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
userKey = loginResponse.key,
privateKey = loginResponse.privateKey,
masterPassword = it,
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
}
// Save the master password hash.
authSdkSource
@@ -515,34 +528,37 @@ class AuthRepositoryImpl(
}
// Attempt to unlock the vault with auth request if possible.
deviceData?.let { model ->
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
method = model
.masterPasswordHash
?.let {
AuthRequestMethod.MasterKey(
protectedMasterKey = model.asymmetricalKey,
authRequestKey = loginResponse.key,
)
}
?: AuthRequestMethod.UserKey(
protectedUserKey = model.asymmetricalKey,
),
),
// We can separately unlock the vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
// We are purposely not storing the master password hash here since it
// is not formatted in in a manner that we can use. We will store it
// properly the next time the user enters their master password and
// it is validated.
// These values will only be null during the Just-in-Time provisioning flow.
if (loginResponse.privateKey != null && loginResponse.key != null) {
deviceData?.let { model ->
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
method = model
.masterPasswordHash
?.let {
AuthRequestMethod.MasterKey(
protectedMasterKey = model.asymmetricalKey,
authRequestKey = loginResponse.key,
)
}
?: AuthRequestMethod.UserKey(
protectedUserKey = model.asymmetricalKey,
),
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
// We are purposely not storing the master password hash here since
// it is not formatted in in a manner that we can use. We will store
// it properly the next time the user enters their master password
// and it is validated.
}
}
authDiskSource.storeAccountTokens(
@@ -805,8 +821,9 @@ class AuthRepositoryImpl(
)
}
@Suppress("LongMethod")
override suspend fun setPassword(
organizationId: String,
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
@@ -832,28 +849,40 @@ class AuthRepositoryImpl(
kdf = activeAccount.profile.toSdkParams(),
)
.flatMap { keyResponse ->
accountsService.setPassword(
body = SetPasswordRequestJson(
passwordHash = passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationId,
kdfIterations = activeAccount.profile.kdfIterations,
kdfMemory = activeAccount.profile.kdfMemory,
kdfParallelism = activeAccount.profile.kdfParallelism,
kdfType = activeAccount.profile.kdfType,
key = keyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = keyResponse.keys.public,
encryptedPrivateKey = keyResponse.keys.private,
accountsService
.setPassword(
body = SetPasswordRequestJson(
passwordHash = passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
kdfIterations = activeAccount.profile.kdfIterations,
kdfMemory = activeAccount.profile.kdfMemory,
kdfParallelism = activeAccount.profile.kdfParallelism,
kdfType = activeAccount.profile.kdfType,
key = keyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = keyResponse.keys.public,
encryptedPrivateKey = keyResponse.keys.private,
),
),
),
)
)
.onSuccess {
authDiskSource.storePrivateKey(
userId = activeAccount.profile.userId,
privateKey = keyResponse.keys.private,
)
authDiskSource.storeUserKey(
userId = activeAccount.profile.userId,
userKey = keyResponse.encryptedUserKey,
)
}
}
.onSuccess {
authDiskSource.storeMasterPasswordHash(
userId = activeAccount.profile.userId,
passwordHash = passwordHash,
)
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword()
}
.fold(
onFailure = { SetPasswordResult.Error },

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
@@ -40,6 +41,35 @@ fun UserStateJson.toUpdatedUserStateJson(
)
}
/**
* Updates the [UserStateJson] to set the `hasMasterPassword` value to `true` after a user sets
* their password.
*/
fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
val account = this.accounts[activeUserId] ?: return this
val profile = account.profile
val updatedProfile = profile
.copy(
userDecryptionOptions = profile
.userDecryptionOptions
?.copy(hasMasterPassword = true)
?: UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
),
)
val updatedAccount = account.copy(profile = updatedProfile)
return this
.copy(
accounts = accounts
.toMutableMap()
.apply {
replace(activeUserId, updatedAccount)
},
)
}
/**
* Converts the given [UserStateJson] to a [UserState] using the given [vaultState].
*/

View File

@@ -20,6 +20,8 @@ import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestin
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHintDestination
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
import com.x8bit.bitwarden.ui.auth.feature.setpassword.setPasswordDestination
import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.trustedDeviceDestination
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination
@@ -49,6 +51,7 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
enterpriseSignOnDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToSetPassword = { navController.navigateToSetPassword() },
onNavigateToTwoFactorLogin = { emailAddress ->
navController.navigateToTwoFactorLogin(
emailAddress = emailAddress,
@@ -56,6 +59,7 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
},
)
setPasswordDestination()
landingDestination(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { emailAddress ->

View File

@@ -38,6 +38,7 @@ fun NavController.navigateToEnterpriseSignOn(
*/
fun NavGraphBuilder.enterpriseSignOnDestination(
onNavigateBack: () -> Unit,
onNavigateToSetPassword: () -> Unit,
onNavigateToTwoFactorLogin: (emailAddress: String) -> Unit,
) {
composableWithSlideTransitions(
@@ -48,6 +49,7 @@ fun NavGraphBuilder.enterpriseSignOnDestination(
) {
EnterpriseSignOnScreen(
onNavigateBack = onNavigateBack,
onNavigateToSetPassword = onNavigateToSetPassword,
onNavigateToTwoFactorLogin = onNavigateToTwoFactorLogin,
)
}

View File

@@ -50,6 +50,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
@Composable
fun EnterpriseSignOnScreen(
onNavigateBack: () -> Unit,
onNavigateToSetPassword: () -> Unit,
onNavigateToTwoFactorLogin: (String) -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: EnterpriseSignOnViewModel = hiltViewModel(),
@@ -67,6 +68,10 @@ fun EnterpriseSignOnScreen(
intentManager.startCustomTabsActivity(event.uri)
}
is EnterpriseSignOnEvent.NavigateToSetPassword -> {
onNavigateToSetPassword()
}
is EnterpriseSignOnEvent.NavigateToTwoFactorLogin -> {
onNavigateToTwoFactorLogin(event.emailAddress)
}

View File

@@ -343,7 +343,8 @@ class EnterpriseSignOnViewModel @Inject constructor(
ssoCode = ssoCallbackResult.code,
ssoCodeVerifier = ssoData.codeVerifier,
ssoRedirectUri = SSO_URI,
captchaToken = mutableStateFlow.value.captchaToken,
captchaToken = state.captchaToken,
organizationIdentifier = state.orgIdentifierInput,
)
sendAction(EnterpriseSignOnAction.Internal.OnLoginResult(result))
}
@@ -472,6 +473,11 @@ sealed class EnterpriseSignOnEvent {
*/
data class NavigateToCaptcha(val uri: Uri) : EnterpriseSignOnEvent()
/**
* Navigates to the set master password screen.
*/
data object NavigateToSetPassword : EnterpriseSignOnEvent()
/**
* Navigates to the two-factor login screen.
*/

View File

@@ -0,0 +1,28 @@
package com.x8bit.bitwarden.ui.auth.feature.setpassword
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
const val SET_PASSWORD_ROUTE: String = "set_password"
/**
* Add the Set Password screen to the nav graph.
*/
fun NavGraphBuilder.setPasswordDestination() {
composable(
route = SET_PASSWORD_ROUTE,
) {
SetPasswordScreen()
}
}
/**
* Navigate to the Set Password screen.
*/
fun NavController.navigateToSetPassword(
navOptions: NavOptions? = null,
) {
this.navigate(SET_PASSWORD_ROUTE, navOptions)
}

View File

@@ -0,0 +1,206 @@
package com.x8bit.bitwarden.ui.auth.feature.setpassword
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenPolicyWarningText
/**
* The top level composable for the Set Master Password screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SetPasswordScreen(
viewModel: SetPasswordViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
SetPasswordDialogs(
dialogState = state.dialogState,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.DialogDismiss) }
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenMediumTopAppBar(
title = stringResource(id = R.string.set_master_password),
scrollBehavior = scrollBehavior,
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.cancel),
onClick = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.CancelClick) }
},
modifier = Modifier.semantics { testTag = "CancelButton" },
)
BitwardenTextButton(
label = stringResource(id = R.string.submit),
onClick = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.SubmitClick) }
},
modifier = Modifier.semantics { testTag = "SubmitButton" },
)
},
)
},
) { innerPadding ->
SetPasswordScreenContent(
state = state,
onPasswordInputChanged = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.PasswordInputChanged(it)) }
},
onRetypePasswordInputChanged = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.RetypePasswordInputChanged(it)) }
},
onPasswordHintInputChanged = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.PasswordHintInputChanged(it)) }
},
modifier = Modifier
.padding(innerPadding)
.imePadding()
.fillMaxSize(),
)
}
}
@Composable
@Suppress("LongMethod")
private fun SetPasswordScreenContent(
state: SetPasswordState,
onPasswordInputChanged: (String) -> Unit,
onRetypePasswordInputChanged: (String) -> Unit,
onPasswordHintInputChanged: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState()),
) {
Text(
text = stringResource(
id = R.string.your_organization_requires_you_to_set_a_master_password,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenPolicyWarningText(
text = stringResource(id = R.string.reset_password_auto_enroll_invite_warning),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.master_password),
value = state.passwordInput,
onValueChange = onPasswordInputChanged,
hint = stringResource(id = R.string.master_password_description),
modifier = Modifier
.semantics { testTag = "NewPasswordField" }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.retype_master_password),
value = state.retypePasswordInput,
onValueChange = onRetypePasswordInputChanged,
modifier = Modifier
.semantics { testTag = "RetypePasswordField" }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(id = R.string.master_password_hint),
value = state.passwordHintInput,
onValueChange = onPasswordHintInputChanged,
hint = stringResource(id = R.string.master_password_hint_description),
modifier = Modifier
.semantics { testTag = "MasterPasswordHintLabel" }
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun SetPasswordDialogs(
dialogState: SetPasswordState.DialogState?,
onDismissRequest: () -> Unit,
) {
when (dialogState) {
is SetPasswordState.DialogState.Error -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = dialogState.title,
message = dialogState.message,
),
onDismissRequest = onDismissRequest,
)
}
is SetPasswordState.DialogState.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(
text = dialogState.message,
),
)
}
null -> Unit
}
}

View File

@@ -0,0 +1,385 @@
package com.x8bit.bitwarden.ui.auth.feature.setpassword
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
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.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MIN_PASSWORD_LENGTH = 12
/**
* Manages application state for the Set Password screen.
*/
@HiltViewModel
@Suppress("TooManyFunctions")
class SetPasswordViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<SetPasswordState, SetPasswordEvent, SetPasswordAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val organizationIdentifier = authRepository.organizationIdentifier
if (organizationIdentifier.isNullOrBlank()) authRepository.logout()
SetPasswordState(
dialogState = null,
organizationIdentifier = organizationIdentifier.orEmpty(),
passwordInput = "",
passwordHintInput = "",
policies = authRepository.passwordPolicies.toDisplayLabels(),
retypePasswordInput = "",
)
},
) {
override fun handleAction(action: SetPasswordAction) {
when (action) {
SetPasswordAction.CancelClick -> handleCancelClick()
SetPasswordAction.SubmitClick -> handleSubmitClicked()
SetPasswordAction.DialogDismiss -> handleDialogDismiss()
is SetPasswordAction.PasswordInputChanged -> handlePasswordInputChanged(action)
is SetPasswordAction.RetypePasswordInputChanged -> {
handleRetypePasswordInputChanged(action)
}
is SetPasswordAction.PasswordHintInputChanged -> {
handlePasswordHintInputChanged(action)
}
is SetPasswordAction.Internal.ReceiveUnlockVaultResult -> {
handleReceiveUnlockVaultResult(action)
}
is SetPasswordAction.Internal.ReceiveSetPasswordResult -> {
handleReceiveSetPasswordResult(action)
}
is SetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult -> {
handleReceiveValidatePasswordAgainstPoliciesResult(action)
}
}
}
/**
* Dismiss the view if the user cancels the set master password functionality.
*/
private fun handleCancelClick() {
authRepository.logout()
}
/**
* Validate the user's current password when they submit.
*/
private fun handleSubmitClicked() {
// Display an error dialog if the new password field is blank.
if (state.passwordInput.isBlank()) {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.master_password.asText()),
),
)
}
return
}
// Validate password against policies if there are any.
if (state.policies.isNotEmpty()) {
viewModelScope.launch {
sendAction(
SetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult(
authRepository.validatePasswordAgainstPolicies(state.passwordInput),
),
)
}
} else if (state.passwordInput.length < MIN_PASSWORD_LENGTH) {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
),
)
}
} else if (state.passwordInput == state.retypePasswordInput) {
setPassword()
} else {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
),
)
}
}
}
/**
* Dismiss the dialog state.
*/
private fun handleDialogDismiss() {
mutableStateFlow.update {
it.copy(
dialogState = null,
)
}
}
/**
* Update the state with the new master password input.
*/
private fun handlePasswordInputChanged(action: SetPasswordAction.PasswordInputChanged) {
mutableStateFlow.update {
it.copy(
passwordInput = action.input,
)
}
}
/**
* Update the state with the re-typed master password input.
*/
private fun handleRetypePasswordInputChanged(
action: SetPasswordAction.RetypePasswordInputChanged,
) {
mutableStateFlow.update {
it.copy(
retypePasswordInput = action.input,
)
}
}
/**
* Update the state with the password hint input.
*/
private fun handlePasswordHintInputChanged(
action: SetPasswordAction.PasswordHintInputChanged,
) {
mutableStateFlow.update {
it.copy(
passwordHintInput = action.input,
)
}
}
private fun handleReceiveUnlockVaultResult(
action: SetPasswordAction.Internal.ReceiveUnlockVaultResult,
) {
when (action.result) {
is VaultUnlockResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
}
is VaultUnlockResult.AuthenticationError,
is VaultUnlockResult.InvalidStateError,
is VaultUnlockResult.GenericError,
-> {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
)
}
}
}
}
/**
* Show an alert if the set password attempt failed, otherwise attempt to unlock the vault.
*/
private fun handleReceiveSetPasswordResult(
action: SetPasswordAction.Internal.ReceiveSetPasswordResult,
) {
when (action.result) {
SetPasswordResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
)
}
}
SetPasswordResult.Success -> {
viewModelScope.launch {
sendAction(
SetPasswordAction.Internal.ReceiveUnlockVaultResult(
result = vaultRepository.unlockVaultWithMasterPassword(
masterPassword = state.passwordInput,
),
),
)
}
}
}
}
/**
* Display an alert if the password doesn't meet the policy requirements, then check that
* the new password matches the retyped password and that the current password is valid.
*/
private fun handleReceiveValidatePasswordAgainstPoliciesResult(
action: SetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult,
) {
// Display an error alert if the new password doesn't meet the policy requirements.
if (!action.meetsRequirements) {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.master_password_policy_validation_title.asText(),
message = R.string.master_password_policy_validation_message.asText(),
),
)
}
}
}
/**
* A helper function to launch the set password request.
*/
private fun setPassword() {
// Show the loading dialog.
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Loading(
message = R.string.updating_password.asText(),
),
)
}
viewModelScope.launch {
sendAction(
SetPasswordAction.Internal.ReceiveSetPasswordResult(
result = authRepository.setPassword(
organizationIdentifier = state.organizationIdentifier,
password = state.passwordInput,
passwordHint = state.passwordHintInput,
),
),
)
}
}
}
/**
* Models state of the Set Password screen.
*/
@Parcelize
data class SetPasswordState(
val dialogState: DialogState?,
val organizationIdentifier: String,
val passwordHintInput: String,
val passwordInput: String,
val policies: List<Text>,
val retypePasswordInput: String,
) : Parcelable {
/**
* Represents the current state of any dialogs on the screen.
*/
sealed class DialogState : Parcelable {
/**
* Represents an error dialog with the given [message] and optional [title]. If no title
* is specified a default will be provided.
*/
@Parcelize
data class Error(
val title: Text? = null,
val message: Text,
) : DialogState()
/**
* Represents a loading dialog with the given [message].
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
}
}
/**
* Models events for the Set Password screen.
*/
sealed class SetPasswordEvent
/**
* Models actions for the Set Password screen.
*/
sealed class SetPasswordAction {
/**
* Indicates that the user has confirmed logging out.
*/
data object CancelClick : SetPasswordAction()
/**
* Indicates that the user has clicked the submit button.
*/
data object SubmitClick : SetPasswordAction()
/**
* Indicates that the dialog has been dismissed.
*/
data object DialogDismiss : SetPasswordAction()
/**
* Indicates that the master password input has changed.
*/
data class PasswordInputChanged(val input: String) : SetPasswordAction()
/**
* Indicates that the re-type master password input has changed.
*/
data class RetypePasswordInputChanged(val input: String) : SetPasswordAction()
/**
* Indicates that the password hint input has changed.
*/
data class PasswordHintInputChanged(val input: String) : SetPasswordAction()
/**
* Models actions that the [SetPasswordViewModel] might send itself.
*/
sealed class Internal : SetPasswordAction() {
/**
* Indicates that a login result has been received.
*/
data class ReceiveUnlockVaultResult(
val result: VaultUnlockResult,
) : Internal()
/**
* Indicates that a set password result has been received.
*/
data class ReceiveSetPasswordResult(
val result: SetPasswordResult,
) : Internal()
/**
* Indicates that a validate password against policies result has been received.
*/
data class ReceiveValidatePasswordAgainstPoliciesResult(
val meetsRequirements: Boolean,
) : Internal()
}
}

View File

@@ -19,6 +19,8 @@ import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination
import com.x8bit.bitwarden.ui.auth.feature.setpassword.SET_PASSWORD_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination
@@ -87,6 +89,7 @@ fun RootNavScreen(
val targetRoute = when (state) {
RootNavState.Auth -> AUTH_GRAPH_ROUTE
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
is RootNavState.SetPassword -> SET_PASSWORD_ROUTE
RootNavState.Splash -> SPLASH_ROUTE
RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE
is RootNavState.VaultUnlocked,
@@ -126,6 +129,7 @@ fun RootNavScreen(
when (val currentState = state) {
RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions)
RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions)
is RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions)
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions)
is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(

View File

@@ -60,6 +60,8 @@ class RootNavViewModel @Inject constructor(
val userState = action.userState
val specialCircumstance = action.specialCircumstance
val updatedRootNavState = when {
userState?.activeAccount?.needsMasterPassword == true -> RootNavState.SetPassword
userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
userState == null ||
@@ -117,6 +119,12 @@ sealed class RootNavState : Parcelable {
@Parcelize
data object ResetPassword : RootNavState()
/**
* App should show set password graph.
*/
@Parcelize
data object SetPassword : RootNavState()
/**
* App should show splash nav graph.
*/