mirror of
https://github.com/bitwarden/android.git
synced 2026-06-04 03:36:32 -05:00
BIT-1921: Add JIT Provisioning (#1133)
This commit is contained in:
committed by
Álison Fernandes
parent
509ef72546
commit
5b1545f53b
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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].
|
||||
*/
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user