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

View file

@ -362,6 +362,44 @@ class AuthDiskSourceTest {
)
}
@Test
fun `getTwoFactorToken should pull from SharedPreferences`() {
val twoFactorTokenBaseKey = "bwPreferencesStorage:twoFactorToken"
val mockEmail = "mockUserId"
val mockTwoFactorToken = "immaLilToken123"
fakeSharedPreferences
.edit {
putString(
"${twoFactorTokenBaseKey}_$mockEmail",
mockTwoFactorToken,
)
}
val actual = authDiskSource.getTwoFactorToken(email = mockEmail)
assertEquals(
mockTwoFactorToken,
actual,
)
}
@Test
fun `storeTwoFactorToken should update SharedPreferences`() {
val twoFactorTokenBaseKey = "bwPreferencesStorage:twoFactorToken"
val mockEmail = "mockUserId"
val mockTwoFactorToken = "immaLilToken123"
authDiskSource.storeTwoFactorToken(
email = mockEmail,
twoFactorToken = mockTwoFactorToken,
)
val actual = fakeSharedPreferences.getString(
"${twoFactorTokenBaseKey}_$mockEmail",
null,
)
assertEquals(
mockTwoFactorToken,
actual,
)
}
@Test
fun `getUserAutoUnlockKey should pull from SharedPreferences`() {
val userAutoUnlockKeyBaseKey = "bwSecureStorage:userKeyAutoUnlock"

View file

@ -23,6 +23,7 @@ class FakeAuthDiskSource : AuthDiskSource {
private val storedInvalidUnlockAttempts = mutableMapOf<String, Int?>()
private val storedUserKeys = mutableMapOf<String, String?>()
private val storedPrivateKeys = mutableMapOf<String, String?>()
private val storedTwoFactorTokens = mutableMapOf<String, String?>()
private val storedUserAutoUnlockKeys = mutableMapOf<String, String?>()
private val storedPinProtectedUserKeys = mutableMapOf<String, Pair<String?, Boolean>>()
private val storedEncryptedPins = mutableMapOf<String, String?>()
@ -44,6 +45,7 @@ class FakeAuthDiskSource : AuthDiskSource {
storedInvalidUnlockAttempts.remove(userId)
storedUserKeys.remove(userId)
storedPrivateKeys.remove(userId)
storedTwoFactorTokens.clear()
storedUserAutoUnlockKeys.remove(userId)
storedPinProtectedUserKeys.remove(userId)
storedEncryptedPins.remove(userId)
@ -85,6 +87,12 @@ class FakeAuthDiskSource : AuthDiskSource {
storedPrivateKeys[userId] = privateKey
}
override fun getTwoFactorToken(email: String): String? = storedTwoFactorTokens[email]
override fun storeTwoFactorToken(email: String, twoFactorToken: String?) {
storedTwoFactorTokens[email] = twoFactorToken
}
override fun getUserAutoUnlockKey(userId: String): String? =
storedUserAutoUnlockKeys[userId]
@ -173,6 +181,13 @@ class FakeAuthDiskSource : AuthDiskSource {
assertEquals(privateKey, storedPrivateKeys[userId])
}
/**
* Assert that the [twoFactorToken] was stored successfully using the [email].
*/
fun assertTwoFactorToken(email: String, twoFactorToken: String?) {
assertEquals(twoFactorToken, storedTwoFactorTokens[email])
}
/**
* Assert that the [userAutoUnlockKey] was stored successfully using the [userId].
*/

View file

@ -254,6 +254,7 @@ private val LOGIN_SUCCESS = GetTokenResponseJson.Success(
privateKey = "privateKey",
shouldForcePasswordReset = true,
shouldResetMasterPassword = true,
twoFactorToken = null,
masterPasswordPolicyOptions = MasterPasswordPolicyOptionsJson(
minimumComplexity = 10,
minimumLength = 100,

View file

@ -21,6 +21,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenRespon
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
@ -736,7 +737,7 @@ class AuthRepositoryTest {
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.TwoFactorRequired, result)
assertEquals(
repository.twoFactorData,
repository.twoFactorResponse,
GetTokenResponseJson.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null),
)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
@ -754,6 +755,184 @@ class AuthRepositoryTest {
}
}
@Test
fun `login two factor with remember saves two factor auth token`() = runTest {
// Attempt a normal login with a two factor error first, so that the auth
// data will be cached.
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns Result.success(
GetTokenResponseJson.TwoFactorRequired(
TWO_FACTOR_AUTH_METHODS_DATA, null, null,
),
)
val firstResult = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.TwoFactorRequired, firstResult)
coVerify { accountsService.preLogin(email = EMAIL) }
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
}
// Login with two factor data.
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
twoFactorToken = "twoFactorTokenToStore",
)
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
twoFactorData = TWO_FACTOR_DATA,
)
} returns Result.success(successResponse)
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
organizationKeys = null,
masterPassword = PASSWORD,
)
} returns VaultUnlockResult.Success
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
successResponse.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val finalResult = repository.login(
email = EMAIL,
password = PASSWORD,
twoFactorData = TWO_FACTOR_DATA,
captchaToken = null,
)
assertEquals(LoginResult.Success, finalResult)
assertNull(repository.twoFactorResponse)
fakeAuthDiskSource.assertTwoFactorToken(
email = EMAIL,
twoFactorToken = "twoFactorTokenToStore",
)
}
@Test
fun `login uses remembered two factor tokens`() = runTest {
fakeAuthDiskSource.storeTwoFactorToken(EMAIL, "storedTwoFactorToken")
val rememberedTwoFactorData = TwoFactorDataModel(
code = "storedTwoFactorToken",
method = TwoFactorAuthMethod.REMEMBER.value.toString(),
remember = false,
)
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
accountsService.preLogin(email = EMAIL)
} returns Result.success(PRE_LOGIN_SUCCESS)
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
twoFactorData = rememberedTwoFactorData,
)
} returns Result.success(successResponse)
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
organizationKeys = null,
masterPassword = PASSWORD,
)
} returns VaultUnlockResult.Success
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
coVerify { accountsService.preLogin(email = EMAIL) }
fakeAuthDiskSource.assertPrivateKey(
userId = USER_ID_1,
privateKey = "privateKey",
)
fakeAuthDiskSource.assertUserKey(
userId = USER_ID_1,
userKey = "key",
)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
twoFactorData = rememberedTwoFactorData,
)
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
organizationKeys = null,
masterPassword = PASSWORD,
)
vaultRepository.syncIfNecessary()
}
assertEquals(
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify { vaultRepository.clearUnlockedData() }
}
@Test
fun `login two factor returns error if no cached auth data`() = runTest {
val result = repository.login(
email = EMAIL,
password = PASSWORD,
twoFactorData = TWO_FACTOR_DATA,
captchaToken = null,
)
assertEquals(LoginResult.Error(errorMessage = null), result)
}
@Test
fun `register check data breaches error should still return register success`() = runTest {
coEvery {
@ -1557,6 +1736,14 @@ class AuthRepositoryTest {
private const val REFRESH_TOKEN = "refreshToken"
private const val REFRESH_TOKEN_2 = "refreshToken2"
private const val CAPTCHA_KEY = "captcha"
private const val TWO_FACTOR_CODE = "123456"
private val TWO_FACTOR_METHOD = TwoFactorAuthMethod.EMAIL
private const val TWO_FACTOR_REMEMBER = true
private val TWO_FACTOR_DATA = TwoFactorDataModel(
code = TWO_FACTOR_CODE,
method = TWO_FACTOR_METHOD.value.toString(),
remember = TWO_FACTOR_REMEMBER,
)
private const val DEFAULT_KDF_ITERATIONS = 600000
private const val ENCRYPTED_USER_KEY = "encryptedUserKey"
@ -1591,6 +1778,7 @@ class AuthRepositoryTest {
privateKey = "privateKey",
shouldForcePasswordReset = true,
shouldResetMasterPassword = true,
twoFactorToken = null,
masterPasswordPolicyOptions = null,
userDecryptionOptions = null,
)

View file

@ -81,6 +81,7 @@ private val GET_TOKEN_RESPONSE_SUCCESS = GetTokenResponseJson.Success(
privateKey = "privateKey",
shouldForcePasswordReset = true,
shouldResetMasterPassword = true,
twoFactorToken = null,
masterPasswordPolicyOptions = null,
userDecryptionOptions = null,
)

View file

@ -64,7 +64,7 @@ class LoginScreenTest : BaseComposeTest() {
onNavigateToMasterPasswordHint = { onNavigateToMasterPasswordHintCalled = true },
onNavigateToEnterpriseSignOn = { onNavigateToEnterpriseSignOnCalled = true },
onNavigateToLoginWithDevice = { onNavigateToLoginWithDeviceCalled = true },
onNavigateToTwoFactorLogin = { onNavigateToTwoFactorLoginCalled = true },
onNavigateToTwoFactorLogin = { _, _ -> onNavigateToTwoFactorLoginCalled = true },
viewModel = viewModel,
intentManager = intentManager,
)

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin
import android.net.Uri
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotDisplayed
@ -14,6 +15,7 @@ import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import io.mockk.every
import io.mockk.mockk
@ -47,6 +49,22 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
}
}
@Test
fun `basicDialog should update according to state`() {
composeTestRule.onNodeWithText("Error message").assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
title = null,
message = "Error message".asText(),
),
)
}
composeTestRule.onNodeWithText("Error message").isDisplayed()
}
@Test
fun `close button click should send CloseButtonClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
@ -102,6 +120,19 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithText(authAppDetails).isDisplayed()
}
@Test
fun `loadingOverlay should update according to state`() {
composeTestRule.onNodeWithText("Loading...").assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = TwoFactorLoginState.DialogState.Loading("Loading...".asText()),
)
}
composeTestRule.onNodeWithText("Loading...").isDisplayed()
}
@Test
fun `remember me click should send RememberMeToggle action`() {
composeTestRule.onNodeWithText("Remember me").performClick()
@ -131,7 +162,7 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
}
@Test
fun `resend email button visibility should should update according to state`() {
fun `resend email button visibility should update according to state`() {
val buttonText = "Send verification code email again"
composeTestRule.onNodeWithText(buttonText).assertIsDisplayed()
@ -178,6 +209,13 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
TestCase.assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToCaptcha should call intentManager startCustomTabsActivity`() {
val mockUri = mockk<Uri>()
mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToCaptcha(mockUri))
verify { intentManager.startCustomTabsActivity(mockUri) }
}
@Test
fun `NavigateToRecoveryCode should launch the recovery code uri`() {
mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToRecoveryCode)
@ -185,13 +223,22 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
intentManager.launchUri(any())
}
}
}
private val DEFAULT_STATE = TwoFactorLoginState(
authMethod = TwoFactorAuthMethod.EMAIL,
availableAuthMethods = listOf(TwoFactorAuthMethod.EMAIL, TwoFactorAuthMethod.RECOVERY_CODE),
codeInput = "",
displayEmail = "ex***@email.com",
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
)
companion object {
private val DEFAULT_STATE = TwoFactorLoginState(
authMethod = TwoFactorAuthMethod.EMAIL,
availableAuthMethods = listOf(
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.RECOVERY_CODE,
),
codeInput = "",
displayEmail = "ex***@email.com",
dialogState = null,
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
captchaToken = null,
email = "example@email.com",
password = "password123",
)
}
}

View file

@ -1,24 +1,52 @@
package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
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.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.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class TwoFactorLoginViewModelTest : BaseViewModelTest() {
private val mutableCaptchaTokenResultFlow =
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
private val authRepository: AuthRepository = mockk(relaxed = true) {
every { twoFactorData } returns TWO_FACTOR_DATA
every { twoFactorResponse } returns TWO_FACTOR_RESPONSE
every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
}
private val savedStateHandle = SavedStateHandle().also {
it["email_address"] = "test@gmail.com"
it["email_address"] = "example@email.com"
it["password"] = "password123"
}
@BeforeEach
fun setUp() {
mockkStatic(::generateUriForCaptcha)
}
@AfterEach
fun tearDown() {
unmockkStatic(::generateUriForCaptcha)
}
@Test
@ -29,6 +57,36 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `captchaTokenFlow success update should trigger a login`() = runTest {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
remember = false,
),
captchaToken = "token",
)
} returns LoginResult.Success
createViewModel()
mutableCaptchaTokenResultFlow.tryEmit(CaptchaCallbackTokenResult.Success("token"))
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
remember = false,
),
captchaToken = "token",
)
}
}
@Test
fun `CloseButtonClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
@ -86,6 +144,156 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ContinueButtonClick login returns success should update loadingDialogState`() = runTest {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
remember = false,
),
captchaToken = null,
)
} returns LoginResult.Success
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = TwoFactorLoginState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
}
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
remember = false,
),
captchaToken = null,
)
}
}
@Test
fun `ContinueButtonClick login returns CaptchaRequired should emit NavigateToCaptcha`() =
runTest {
val mockkUri = mockk<Uri>()
every {
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
remember = false,
),
captchaToken = null,
)
} returns LoginResult.CaptchaRequired(captchaId = "mock_captcha_id")
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(TwoFactorLoginAction.ContinueButtonClick)
assertEquals(
DEFAULT_STATE,
viewModel.stateFlow.value,
)
assertEquals(
TwoFactorLoginEvent.NavigateToCaptcha(uri = mockkUri),
awaitItem(),
)
}
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
remember = false,
),
captchaToken = null,
)
}
}
@Test
fun `ContinueButtonClick login returns Error should update errorStateDialog`() = runTest {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
remember = false,
),
captchaToken = null,
)
} returns LoginResult.Error(errorMessage = null)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = TwoFactorLoginState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_verification_code.asText(),
),
),
awaitItem(),
)
viewModel.trySendAction(TwoFactorLoginAction.DialogDismiss)
assertEquals(DEFAULT_STATE, awaitItem())
}
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
remember = false,
),
captchaToken = null,
)
}
}
@Test
fun `RememberMeToggle should update the state`() = runTest {
val viewModel = createViewModel()
@ -158,7 +366,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
TwoFactorAuthMethod.EMAIL to mapOf("Email" to "ex***@email.com"),
TwoFactorAuthMethod.AUTHENTICATOR_APP to mapOf("Email" to null),
)
private val TWO_FACTOR_DATA =
private val TWO_FACTOR_RESPONSE =
GetTokenResponseJson.TwoFactorRequired(
TWO_FACTOR_AUTH_METHODS_DATA,
null,
@ -174,8 +382,12 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
),
codeInput = "",
displayEmail = "ex***@email.com",
dialogState = null,
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
captchaToken = null,
email = "example@email.com",
password = "password123",
)
}
}