BIT-1490: Two factor login (#775)

This commit is contained in:
Shannon Draeker
2024-01-25 14:32:09 -07:00
committed by Álison Fernandes
parent bc3a76260f
commit 3de3c8f0ed
21 changed files with 949 additions and 64 deletions

View File

@@ -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].
*/

View File

@@ -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")

View File

@@ -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?,

View File

@@ -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())

View File

@@ -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.
*/

View File

@@ -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 =

View File

@@ -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() },

View File

@@ -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,

View File

@@ -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()
}

View File

@@ -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].

View File

@@ -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)
}
/**

View File

@@ -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,

View File

@@ -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()
}
}