mirror of
https://github.com/bitwarden/android.git
synced 2026-06-06 14:28:45 -05:00
BIT-1490: Two factor login (#775)
This commit is contained in:
committed by
Álison Fernandes
parent
bc3a76260f
commit
3de3c8f0ed
@@ -117,6 +117,16 @@ interface AuthDiskSource {
|
||||
inMemoryOnly: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets a two-factor auth token using a user's [email].
|
||||
*/
|
||||
fun getTwoFactorToken(email: String): String?
|
||||
|
||||
/**
|
||||
* Stores a two-factor auth token using a user's [email].
|
||||
*/
|
||||
fun storeTwoFactorToken(email: String, twoFactorToken: String?)
|
||||
|
||||
/**
|
||||
* Retrieves an encrypted PIN for the given [userId].
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,7 @@ private const val PIN_PROTECTED_USER_KEY_KEY = "$BASE_KEY:pinKeyEncryptedUserKey
|
||||
private const val ENCRYPTED_PIN_KEY = "$BASE_KEY:protectedPin"
|
||||
private const val ORGANIZATIONS_KEY = "$BASE_KEY:organizations"
|
||||
private const val ORGANIZATION_KEYS_KEY = "$BASE_KEY:encOrgKeys"
|
||||
private const val TWO_FACTOR_TOKEN_KEY = "$BASE_KEY:twoFactorToken"
|
||||
|
||||
/**
|
||||
* Primary implementation of [AuthDiskSource].
|
||||
@@ -172,6 +173,16 @@ class AuthDiskSourceImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override fun getTwoFactorToken(email: String): String? =
|
||||
getString(key = "${TWO_FACTOR_TOKEN_KEY}_$email")
|
||||
|
||||
override fun storeTwoFactorToken(email: String, twoFactorToken: String?) {
|
||||
putString(
|
||||
key = "${TWO_FACTOR_TOKEN_KEY}_$email",
|
||||
value = twoFactorToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getEncryptedPin(userId: String): String? =
|
||||
getString(key = "${ENCRYPTED_PIN_KEY}_$userId")
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ sealed class GetTokenResponseJson {
|
||||
* @property shouldForcePasswordReset Whether or not the app must force a password reset.
|
||||
* @property shouldResetMasterPassword Whether or not the user is required to reset their
|
||||
* master password.
|
||||
* @property twoFactorToken If the user has chosen to remember the two-factor authorization,
|
||||
* this token will be cached and used for future auth requests.
|
||||
* @property masterPasswordPolicyOptions The options available for a user's master password.
|
||||
* @property userDecryptionOptions The options available to a user for decryption.
|
||||
*/
|
||||
@@ -64,6 +66,9 @@ sealed class GetTokenResponseJson {
|
||||
@SerialName("ResetMasterPassword")
|
||||
val shouldResetMasterPassword: Boolean,
|
||||
|
||||
@SerialName("TwoFactorToken")
|
||||
val twoFactorToken: String?,
|
||||
|
||||
@SerialName("MasterPasswordPolicy")
|
||||
val masterPasswordPolicyOptions: MasterPasswordPolicyOptionsJson?,
|
||||
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents different providers that can be used for two-factor login.
|
||||
*/
|
||||
@Serializable
|
||||
@Suppress("MagicNumber")
|
||||
enum class TwoFactorAuthMethod {
|
||||
@Serializable(TwoFactorAuthMethodSerializer::class)
|
||||
enum class TwoFactorAuthMethod(val value: UInt) {
|
||||
@SerialName("0")
|
||||
AUTHENTICATOR_APP,
|
||||
AUTHENTICATOR_APP(value = 0U),
|
||||
|
||||
@SerialName("1")
|
||||
EMAIL,
|
||||
EMAIL(value = 1U),
|
||||
|
||||
@SerialName("2")
|
||||
DUO,
|
||||
DUO(value = 2U),
|
||||
|
||||
@SerialName("3")
|
||||
YUBI_KEY,
|
||||
YUBI_KEY(value = 3U),
|
||||
|
||||
@SerialName("4")
|
||||
U2F,
|
||||
U2F(value = 4U),
|
||||
|
||||
@SerialName("5")
|
||||
REMEMBER,
|
||||
REMEMBER(value = 5U),
|
||||
|
||||
@SerialName("6")
|
||||
DUO_ORGANIZATION,
|
||||
DUO_ORGANIZATION(value = 6U),
|
||||
|
||||
@SerialName("7")
|
||||
FIDO_2_WEB_APP,
|
||||
FIDO_2_WEB_APP(value = 7U),
|
||||
|
||||
@SerialName("-1")
|
||||
RECOVERY_CODE,
|
||||
RECOVERY_CODE(value = 100U),
|
||||
}
|
||||
|
||||
@Keep
|
||||
private class TwoFactorAuthMethodSerializer :
|
||||
BaseEnumeratedIntSerializer<TwoFactorAuthMethod>(TwoFactorAuthMethod.values())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository
|
||||
|
||||
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.repository.model.AuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
@@ -49,9 +50,10 @@ interface AuthRepository : AuthenticatorProvider {
|
||||
val ssoCallbackResultFlow: Flow<SsoCallbackResult>
|
||||
|
||||
/**
|
||||
* The two-factor data necessary for login and also to populate the Two-Factor Login screen.
|
||||
* The two-factor response data necessary for login and also to populate the
|
||||
* Two-Factor Login screen.
|
||||
*/
|
||||
var twoFactorData: GetTokenResponseJson.TwoFactorRequired?
|
||||
var twoFactorResponse: GetTokenResponseJson.TwoFactorRequired?
|
||||
|
||||
/**
|
||||
* The currently persisted saved email address (or `null` if not set).
|
||||
@@ -82,6 +84,18 @@ interface AuthRepository : AuthenticatorProvider {
|
||||
captchaToken: String?,
|
||||
): LoginResult
|
||||
|
||||
/**
|
||||
* Repeat the previous login attempt but this time with Two-Factor authentication
|
||||
* information. Password is included if available to unlock the vault after
|
||||
* authentication. Updated access token will be reflected in [authStateFlow].
|
||||
*/
|
||||
suspend fun login(
|
||||
email: String,
|
||||
password: String?,
|
||||
twoFactorData: TwoFactorDataModel,
|
||||
captchaToken: String?,
|
||||
): LoginResult
|
||||
|
||||
/**
|
||||
* Log out the current user.
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRespon
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
|
||||
@@ -86,6 +88,12 @@ class AuthRepositoryImpl(
|
||||
) : AuthRepository {
|
||||
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow<Boolean>(false)
|
||||
|
||||
/**
|
||||
* The auth information to make the identity token request will need to be
|
||||
* cached to make the request again in the case of two-factor authentication.
|
||||
*/
|
||||
private var identityTokenAuthModel: IdentityTokenAuthModel? = null
|
||||
|
||||
/**
|
||||
* A scope intended for use when simply collecting multiple flows in order to combine them. The
|
||||
* use of [Dispatchers.Unconfined] allows for this to happen synchronously whenever any of
|
||||
@@ -93,7 +101,7 @@ class AuthRepositoryImpl(
|
||||
*/
|
||||
private val collectionScope = CoroutineScope(dispatcherManager.unconfined)
|
||||
|
||||
override var twoFactorData: TwoFactorRequired? = null
|
||||
override var twoFactorResponse: TwoFactorRequired? = null
|
||||
|
||||
override val activeUserId: String? get() = authDiskSource.userState?.activeUserId
|
||||
|
||||
@@ -180,7 +188,6 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
password: String,
|
||||
@@ -195,10 +202,10 @@ class AuthRepositoryImpl(
|
||||
purpose = HashPurpose.SERVER_AUTHORIZATION,
|
||||
)
|
||||
}
|
||||
.flatMap { passwordHash ->
|
||||
identityService.getToken(
|
||||
uniqueAppId = authDiskSource.uniqueAppId,
|
||||
.map { passwordHash ->
|
||||
loginCommon(
|
||||
email = email,
|
||||
password = password,
|
||||
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||
username = email,
|
||||
password = passwordHash,
|
||||
@@ -206,13 +213,53 @@ class AuthRepositoryImpl(
|
||||
captchaToken = captchaToken,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onFailure = { LoginResult.Error(errorMessage = null) },
|
||||
onSuccess = { it },
|
||||
)
|
||||
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
password: String?,
|
||||
twoFactorData: TwoFactorDataModel,
|
||||
captchaToken: String?,
|
||||
): LoginResult = identityTokenAuthModel?.let {
|
||||
loginCommon(
|
||||
email = email,
|
||||
password = password,
|
||||
authModel = it,
|
||||
twoFactorData = twoFactorData,
|
||||
captchaToken = captchaToken ?: twoFactorResponse?.captchaToken,
|
||||
)
|
||||
} ?: LoginResult.Error(errorMessage = null)
|
||||
|
||||
/**
|
||||
* A helper function to extract the common logic of logging in through
|
||||
* any of the available methods.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
private suspend fun loginCommon(
|
||||
email: String,
|
||||
password: String? = null,
|
||||
authModel: IdentityTokenAuthModel,
|
||||
twoFactorData: TwoFactorDataModel? = null,
|
||||
captchaToken: String?,
|
||||
): LoginResult = identityService
|
||||
.getToken(
|
||||
uniqueAppId = authDiskSource.uniqueAppId,
|
||||
email = email,
|
||||
authModel = authModel,
|
||||
twoFactorData = twoFactorData ?: getRememberedTwoFactorData(email),
|
||||
captchaToken = captchaToken,
|
||||
)
|
||||
.fold(
|
||||
onFailure = { LoginResult.Error(errorMessage = null) },
|
||||
onSuccess = { loginResponse ->
|
||||
when (loginResponse) {
|
||||
is CaptchaRequired -> LoginResult.CaptchaRequired(loginResponse.captchaKey)
|
||||
is TwoFactorRequired -> {
|
||||
twoFactorData = loginResponse
|
||||
identityTokenAuthModel = authModel
|
||||
twoFactorResponse = loginResponse
|
||||
LoginResult.TwoFactorRequired
|
||||
}
|
||||
|
||||
@@ -223,18 +270,38 @@ class AuthRepositoryImpl(
|
||||
.environment
|
||||
.environmentUrlData,
|
||||
)
|
||||
vaultRepository.clearUnlockedData()
|
||||
vaultRepository.unlockVault(
|
||||
userId = userStateJson.activeUserId,
|
||||
email = userStateJson.activeAccount.profile.email,
|
||||
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
||||
userKey = loginResponse.key,
|
||||
privateKey = loginResponse.privateKey,
|
||||
masterPassword = password,
|
||||
// We can separately unlock the vault for organization data after
|
||||
// receiving the sync response if this data is currently absent.
|
||||
organizationKeys = null,
|
||||
)
|
||||
|
||||
// If the user just authenticated with a two-factor code and selected
|
||||
// the option to remember it, then the API response will return a token
|
||||
// that will be used in place of the two-factor code on the next login
|
||||
// attempt.
|
||||
loginResponse.twoFactorToken?.let {
|
||||
authDiskSource.storeTwoFactorToken(
|
||||
email = email,
|
||||
twoFactorToken = it,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove any cached data after successfully logging in.
|
||||
identityTokenAuthModel = null
|
||||
twoFactorResponse = null
|
||||
|
||||
// Attempt to unlock the vault if possible.
|
||||
password?.let {
|
||||
vaultRepository.clearUnlockedData()
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
authDiskSource.userState = userStateJson
|
||||
authDiskSource.storeUserKey(
|
||||
userId = userStateJson.activeUserId,
|
||||
@@ -523,6 +590,18 @@ class AuthRepositoryImpl(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the remembered two-factor token associated with the user's email, if applicable.
|
||||
*/
|
||||
private fun getRememberedTwoFactorData(email: String): TwoFactorDataModel? =
|
||||
authDiskSource.getTwoFactorToken(email = email)?.let { twoFactorToken ->
|
||||
TwoFactorDataModel(
|
||||
code = twoFactorToken,
|
||||
method = TwoFactorAuthMethod.REMEMBER.value.toString(),
|
||||
remember = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getVaultUnlockType(
|
||||
userId: String,
|
||||
): VaultUnlockType =
|
||||
|
||||
@@ -74,7 +74,12 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
|
||||
emailAddress = emailAddress,
|
||||
)
|
||||
},
|
||||
onNavigateToTwoFactorLogin = { navController.navigateToTwoFactorLogin() },
|
||||
onNavigateToTwoFactorLogin = { emailAddress, password ->
|
||||
navController.navigateToTwoFactorLogin(
|
||||
emailAddress = emailAddress,
|
||||
password = password,
|
||||
)
|
||||
},
|
||||
)
|
||||
loginWithDeviceDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
|
||||
@@ -46,7 +46,7 @@ fun NavGraphBuilder.loginDestination(
|
||||
onNavigateToMasterPasswordHint: (emailAddress: String) -> Unit,
|
||||
onNavigateToEnterpriseSignOn: () -> Unit,
|
||||
onNavigateToLoginWithDevice: (emailAddress: String) -> Unit,
|
||||
onNavigateToTwoFactorLogin: () -> Unit,
|
||||
onNavigateToTwoFactorLogin: (emailAddress: String, password: String?) -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
route = LOGIN_ROUTE,
|
||||
|
||||
@@ -67,7 +67,7 @@ fun LoginScreen(
|
||||
onNavigateToMasterPasswordHint: (String) -> Unit,
|
||||
onNavigateToEnterpriseSignOn: () -> Unit,
|
||||
onNavigateToLoginWithDevice: (emailAddress: String) -> Unit,
|
||||
onNavigateToTwoFactorLogin: () -> Unit,
|
||||
onNavigateToTwoFactorLogin: (String, String?) -> Unit,
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
) {
|
||||
@@ -89,7 +89,10 @@ fun LoginScreen(
|
||||
onNavigateToLoginWithDevice(event.emailAddress)
|
||||
}
|
||||
|
||||
LoginEvent.NavigateToTwoFactorLogin -> onNavigateToTwoFactorLogin()
|
||||
is LoginEvent.NavigateToTwoFactorLogin -> {
|
||||
onNavigateToTwoFactorLogin(event.emailAddress, event.password)
|
||||
}
|
||||
|
||||
is LoginEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@@ -160,7 +160,12 @@ class LoginViewModel @Inject constructor(
|
||||
|
||||
is LoginResult.TwoFactorRequired -> {
|
||||
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
|
||||
sendEvent(LoginEvent.NavigateToTwoFactorLogin)
|
||||
sendEvent(
|
||||
LoginEvent.NavigateToTwoFactorLogin(
|
||||
emailAddress = state.emailAddress,
|
||||
password = state.passwordInput,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is LoginResult.Error -> {
|
||||
@@ -317,7 +322,10 @@ sealed class LoginEvent {
|
||||
/**
|
||||
* Navigates to the two-factor login screen.
|
||||
*/
|
||||
data object NavigateToTwoFactorLogin : LoginEvent()
|
||||
data class NavigateToTwoFactorLogin(
|
||||
val emailAddress: String,
|
||||
val password: String?,
|
||||
) : LoginEvent()
|
||||
|
||||
/**
|
||||
* Shows a toast with the given [message].
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
|
||||
private const val TWO_FACTOR_LOGIN_ROUTE = "two_factor_login"
|
||||
private const val EMAIL_ADDRESS = "email_address"
|
||||
private const val PASSWORD = "password"
|
||||
private const val TWO_FACTOR_LOGIN_PREFIX = "two_factor_login"
|
||||
private const val TWO_FACTOR_LOGIN_ROUTE =
|
||||
"$TWO_FACTOR_LOGIN_PREFIX/{${EMAIL_ADDRESS}}?$PASSWORD={$PASSWORD}"
|
||||
|
||||
/**
|
||||
* Class to retrieve Two-Factor Login arguments from the [SavedStateHandle].
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
data class TwoFactorLoginArgs(val emailAddress: String, val password: String?) {
|
||||
constructor(savedStateHandle: SavedStateHandle) : this(
|
||||
emailAddress = checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
|
||||
password = savedStateHandle[PASSWORD],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Two-Factor Login screen.
|
||||
*/
|
||||
fun NavController.navigateToTwoFactorLogin(navOptions: NavOptions? = null) {
|
||||
this.navigate(TWO_FACTOR_LOGIN_ROUTE, navOptions)
|
||||
fun NavController.navigateToTwoFactorLogin(
|
||||
emailAddress: String,
|
||||
password: String?,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate("$TWO_FACTOR_LOGIN_PREFIX/$emailAddress?$PASSWORD=$password", navOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,13 +38,18 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMetho
|
||||
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.description
|
||||
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.title
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
|
||||
@@ -71,12 +76,40 @@ fun TwoFactorLoginScreen(
|
||||
intentManager.launchUri("https://bitwarden.com/help/lost-two-step-device".toUri())
|
||||
}
|
||||
|
||||
is TwoFactorLoginEvent.NavigateToCaptcha -> {
|
||||
intentManager.startCustomTabsActivity(uri = event.uri)
|
||||
}
|
||||
|
||||
is TwoFactorLoginEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val dialog = state.dialogState) {
|
||||
is TwoFactorLoginState.DialogState.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
title = dialog.title ?: R.string.an_error_has_occurred.asText(),
|
||||
message = dialog.message,
|
||||
),
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(TwoFactorLoginAction.DialogDismiss) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is TwoFactorLoginState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(
|
||||
visibilityState = LoadingDialogState.Shown(
|
||||
text = dialog.message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
@@ -135,6 +168,7 @@ fun TwoFactorLoginScreen(
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun TwoFactorLoginScreenContent(
|
||||
state: TwoFactorLoginState,
|
||||
onCodeInputChange: (String) -> Unit,
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.util.availableAuthMethods
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.util.preferredAuthMethod
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.util.twoFactorDisplayEmail
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
|
||||
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.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -28,12 +37,16 @@ class TwoFactorLoginViewModel @Inject constructor(
|
||||
) : BaseViewModel<TwoFactorLoginState, TwoFactorLoginEvent, TwoFactorLoginAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: TwoFactorLoginState(
|
||||
authMethod = authRepository.twoFactorData.preferredAuthMethod,
|
||||
availableAuthMethods = authRepository.twoFactorData.availableAuthMethods,
|
||||
authMethod = authRepository.twoFactorResponse.preferredAuthMethod,
|
||||
availableAuthMethods = authRepository.twoFactorResponse.availableAuthMethods,
|
||||
codeInput = "",
|
||||
displayEmail = authRepository.twoFactorData.twoFactorDisplayEmail,
|
||||
displayEmail = authRepository.twoFactorResponse.twoFactorDisplayEmail,
|
||||
dialogState = null,
|
||||
isContinueButtonEnabled = false,
|
||||
isRememberMeEnabled = false,
|
||||
captchaToken = null,
|
||||
email = TwoFactorLoginArgs(savedStateHandle).emailAddress,
|
||||
password = TwoFactorLoginArgs(savedStateHandle).password,
|
||||
),
|
||||
) {
|
||||
init {
|
||||
@@ -41,6 +54,18 @@ class TwoFactorLoginViewModel @Inject constructor(
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
// Automatically attempt to login again if a captcha token is received.
|
||||
authRepository
|
||||
.captchaTokenResultFlow
|
||||
.onEach {
|
||||
sendAction(
|
||||
TwoFactorLoginAction.Internal.ReceiveCaptchaToken(
|
||||
tokenResult = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: TwoFactorLoginAction) {
|
||||
@@ -48,9 +73,38 @@ class TwoFactorLoginViewModel @Inject constructor(
|
||||
TwoFactorLoginAction.CloseButtonClick -> handleCloseButtonClicked()
|
||||
is TwoFactorLoginAction.CodeInputChanged -> handleCodeInputChanged(action)
|
||||
TwoFactorLoginAction.ContinueButtonClick -> handleContinueButtonClick()
|
||||
TwoFactorLoginAction.DialogDismiss -> handleDialogDismiss()
|
||||
is TwoFactorLoginAction.RememberMeToggle -> handleRememberMeToggle(action)
|
||||
TwoFactorLoginAction.ResendEmailClick -> handleResendEmailClick()
|
||||
is TwoFactorLoginAction.SelectAuthMethod -> handleSelectAuthMethod(action)
|
||||
|
||||
is TwoFactorLoginAction.Internal.ReceiveCaptchaToken -> {
|
||||
handleCaptchaTokenReceived(action.tokenResult)
|
||||
}
|
||||
|
||||
is TwoFactorLoginAction.Internal.ReceiveLoginResult -> handleReceiveLoginResult(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCaptchaTokenReceived(tokenResult: CaptchaCallbackTokenResult) {
|
||||
when (tokenResult) {
|
||||
CaptchaCallbackTokenResult.MissingToken -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Error(
|
||||
title = R.string.log_in_denied.asText(),
|
||||
message = R.string.captcha_failed.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is CaptchaCallbackTokenResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(captchaToken = tokenResult.token)
|
||||
}
|
||||
handleContinueButtonClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +124,42 @@ class TwoFactorLoginViewModel @Inject constructor(
|
||||
* Verify the input and attempt to authenticate with the code.
|
||||
*/
|
||||
private fun handleContinueButtonClick() {
|
||||
// TODO: Finish implementation (BIT-918)
|
||||
sendEvent(TwoFactorLoginEvent.ShowToast("Not yet implemented"))
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// If the user is manually entering a code, remove any white spaces, just in case.
|
||||
val code = mutableStateFlow.value.codeInput.let { rawCode ->
|
||||
if (mutableStateFlow.value.authMethod == TwoFactorAuthMethod.AUTHENTICATOR_APP ||
|
||||
mutableStateFlow.value.authMethod == TwoFactorAuthMethod.EMAIL
|
||||
) {
|
||||
rawCode.replace(" ", "")
|
||||
} else {
|
||||
rawCode
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.login(
|
||||
email = mutableStateFlow.value.email,
|
||||
password = mutableStateFlow.value.password,
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = code,
|
||||
method = mutableStateFlow.value.authMethod.value.toString(),
|
||||
remember = mutableStateFlow.value.isRememberMeEnabled,
|
||||
),
|
||||
captchaToken = mutableStateFlow.value.captchaToken,
|
||||
)
|
||||
sendAction(
|
||||
TwoFactorLoginAction.Internal.ReceiveLoginResult(
|
||||
loginResult = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +169,50 @@ class TwoFactorLoginViewModel @Inject constructor(
|
||||
sendEvent(TwoFactorLoginEvent.NavigateBack)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the dialog.
|
||||
*/
|
||||
private fun handleDialogDismiss() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the login result.
|
||||
*/
|
||||
private fun handleReceiveLoginResult(action: TwoFactorLoginAction.Internal.ReceiveLoginResult) {
|
||||
// Dismiss the loading overlay.
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
|
||||
when (val loginResult = action.loginResult) {
|
||||
// Launch the captcha flow if necessary.
|
||||
is LoginResult.CaptchaRequired -> {
|
||||
sendEvent(
|
||||
event = TwoFactorLoginEvent.NavigateToCaptcha(
|
||||
uri = generateUriForCaptcha(captchaId = loginResult.captchaId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// NO-OP: This error shouldn't be possible at this stage.
|
||||
is LoginResult.TwoFactorRequired -> Unit
|
||||
|
||||
// Display any error with the same invalid verification code message.
|
||||
is LoginResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.invalid_verification_code.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NO-OP: Let the auth flow handle navigation after this.
|
||||
is LoginResult.Success -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the state with the new toggle value.
|
||||
*/
|
||||
@@ -124,10 +256,38 @@ data class TwoFactorLoginState(
|
||||
val authMethod: TwoFactorAuthMethod,
|
||||
val availableAuthMethods: List<TwoFactorAuthMethod>,
|
||||
val codeInput: String,
|
||||
val dialogState: DialogState?,
|
||||
val displayEmail: String,
|
||||
val isContinueButtonEnabled: Boolean,
|
||||
val isRememberMeEnabled: Boolean,
|
||||
) : Parcelable
|
||||
// Internal
|
||||
val captchaToken: String?,
|
||||
val email: String,
|
||||
val password: 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]. It 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 Two-Factor Login screen.
|
||||
@@ -138,6 +298,11 @@ sealed class TwoFactorLoginEvent {
|
||||
*/
|
||||
data object NavigateBack : TwoFactorLoginEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the captcha verification screen.
|
||||
*/
|
||||
data class NavigateToCaptcha(val uri: Uri) : TwoFactorLoginEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the recovery code help page.
|
||||
*/
|
||||
@@ -155,7 +320,6 @@ sealed class TwoFactorLoginEvent {
|
||||
* Models actions for the Two-Factor Login screen.
|
||||
*/
|
||||
sealed class TwoFactorLoginAction {
|
||||
|
||||
/**
|
||||
* Indicates that the top-bar close button was clicked.
|
||||
*/
|
||||
@@ -173,6 +337,11 @@ sealed class TwoFactorLoginAction {
|
||||
*/
|
||||
data object ContinueButtonClick : TwoFactorLoginAction()
|
||||
|
||||
/**
|
||||
* Indicates that the dialog has been dismissed.
|
||||
*/
|
||||
data object DialogDismiss : TwoFactorLoginAction()
|
||||
|
||||
/**
|
||||
* Indicates that the Remember Me switch toggled.
|
||||
*/
|
||||
@@ -191,4 +360,23 @@ sealed class TwoFactorLoginAction {
|
||||
data class SelectAuthMethod(
|
||||
val authMethod: TwoFactorAuthMethod,
|
||||
) : TwoFactorLoginAction()
|
||||
|
||||
/**
|
||||
* Models actions that the [TwoFactorLoginViewModel] itself might send.
|
||||
*/
|
||||
sealed class Internal : TwoFactorLoginAction() {
|
||||
/**
|
||||
* Indicates a captcha callback token has been received.
|
||||
*/
|
||||
data class ReceiveCaptchaToken(
|
||||
val tokenResult: CaptchaCallbackTokenResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates a login result has been received.
|
||||
*/
|
||||
data class ReceiveLoginResult(
|
||||
val loginResult: LoginResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user