From 3de3c8f0eda50dcf68a74129d2048101f647a05a Mon Sep 17 00:00:00 2001 From: Shannon Draeker <125921730+shannon-livefront@users.noreply.github.com> Date: Thu, 25 Jan 2024 14:32:09 -0700 Subject: [PATCH] BIT-1490: Two factor login (#775) --- .../auth/datasource/disk/AuthDiskSource.kt | 10 + .../datasource/disk/AuthDiskSourceImpl.kt | 11 + .../network/model/GetTokenResponseJson.kt | 5 + .../network/model/TwoFactorAuthMethod.kt | 29 ++- .../data/auth/repository/AuthRepository.kt | 18 +- .../auth/repository/AuthRepositoryImpl.kt | 115 +++++++-- .../ui/auth/feature/auth/AuthNavigation.kt | 7 +- .../ui/auth/feature/login/LoginNavigation.kt | 2 +- .../ui/auth/feature/login/LoginScreen.kt | 7 +- .../ui/auth/feature/login/LoginViewModel.kt | 12 +- .../TwoFactorLoginNavigation.kt | 27 ++- .../twofactorlogin/TwoFactorLoginScreen.kt | 34 +++ .../twofactorlogin/TwoFactorLoginViewModel.kt | 202 +++++++++++++++- .../datasource/disk/AuthDiskSourceTest.kt | 38 +++ .../disk/util/FakeAuthDiskSource.kt | 15 ++ .../network/service/IdentityServiceTest.kt | 1 + .../auth/repository/AuthRepositoryTest.kt | 190 ++++++++++++++- .../util/GetTokenResponseExtensionsTest.kt | 1 + .../ui/auth/feature/login/LoginScreenTest.kt | 2 +- .../TwoFactorLoginScreenTest.kt | 67 +++++- .../TwoFactorLoginViewModelTest.kt | 220 +++++++++++++++++- 21 files changed, 949 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index 6c3580132e..32ae2001ed 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -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]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index e8a58661da..4297017922 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -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") diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt index a03e1f7ef0..dba39b083e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt @@ -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?, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/TwoFactorAuthMethod.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/TwoFactorAuthMethod.kt index bd986aa160..06bba48481 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/TwoFactorAuthMethod.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/TwoFactorAuthMethod.kt @@ -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.values()) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 8e76a84f96..082fafdfcd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -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 /** - * 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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index b0962e35e1..d28c24a85b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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(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 = diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 5b80801b5e..2f73e4ce46 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -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() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt index 675263851c..44d131856b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt index c86d3bd5e1..d79e4f57bc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt @@ -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() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index 27df06e83a..62f028dd15 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -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]. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginNavigation.kt index eb1631fc2c..3b92650b3c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginNavigation.kt @@ -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) } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt index bbae2fe0ca..1c8faefa1c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt index f5e9e0a737..042b2589ef 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt @@ -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( 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, 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() + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index 3835529507..ffc40e1570 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -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" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 82149c6dab..660d480a29 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -23,6 +23,7 @@ class FakeAuthDiskSource : AuthDiskSource { private val storedInvalidUnlockAttempts = mutableMapOf() private val storedUserKeys = mutableMapOf() private val storedPrivateKeys = mutableMapOf() + private val storedTwoFactorTokens = mutableMapOf() private val storedUserAutoUnlockKeys = mutableMapOf() private val storedPinProtectedUserKeys = mutableMapOf>() private val storedEncryptedPins = mutableMapOf() @@ -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]. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt index 711614566e..9f7b873c3e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt @@ -254,6 +254,7 @@ private val LOGIN_SUCCESS = GetTokenResponseJson.Success( privateKey = "privateKey", shouldForcePasswordReset = true, shouldResetMasterPassword = true, + twoFactorToken = null, masterPasswordPolicyOptions = MasterPasswordPolicyOptionsJson( minimumComplexity = 10, minimumLength = 100, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 98d5100d12..f6b1ad64ea 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/GetTokenResponseExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/GetTokenResponseExtensionsTest.kt index 2e51698bd7..209dd40fca 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/GetTokenResponseExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/GetTokenResponseExtensionsTest.kt @@ -81,6 +81,7 @@ private val GET_TOKEN_RESPONSE_SUCCESS = GetTokenResponseJson.Success( privateKey = "privateKey", shouldForcePasswordReset = true, shouldResetMasterPassword = true, + twoFactorToken = null, masterPasswordPolicyOptions = null, userDecryptionOptions = null, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt index 098be1c55d..3e8eb13323 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt @@ -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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt index d40cb71534..af42d90b4d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt @@ -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() + 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", + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt index b8984d65c5..cb5a5131a1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt @@ -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() 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() + 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", ) } }