From f0d6599eb3cc2ce34040078c3a443b14b3dbf9f5 Mon Sep 17 00:00:00 2001 From: aj-rosado <109146700+aj-rosado@users.noreply.github.com> Date: Fri, 14 Feb 2025 14:03:15 +0000 Subject: [PATCH] [PM-8223] Add new device verification when no OTP (#4712) --- .../network/api/UnauthenticatedAccountsApi.kt | 6 + .../network/api/UnauthenticatedIdentityApi.kt | 1 + .../network/model/GetTokenResponseJson.kt | 22 +++ .../model/ResendNewDeviceOtpRequestJson.kt | 20 ++ .../network/service/AccountsService.kt | 6 + .../network/service/AccountsServiceImpl.kt | 6 + .../network/service/IdentityService.kt | 1 + .../network/service/IdentityServiceImpl.kt | 2 + .../data/auth/repository/AuthRepository.kt | 18 ++ .../auth/repository/AuthRepositoryImpl.kt | 76 +++++++- .../data/auth/repository/model/LoginResult.kt | 5 + .../ui/auth/feature/auth/AuthNavigation.kt | 3 +- .../EnterpriseSignOnViewModel.kt | 17 +- .../ui/auth/feature/login/LoginNavigation.kt | 6 +- .../ui/auth/feature/login/LoginScreen.kt | 8 +- .../ui/auth/feature/login/LoginViewModel.kt | 14 +- .../LoginWithDeviceViewModel.kt | 16 +- .../TwoFactorLoginNavigation.kt | 13 +- .../twofactorlogin/TwoFactorLoginScreen.kt | 115 +++++++---- .../twofactorlogin/TwoFactorLoginViewModel.kt | 55 ++++-- .../util/TwoFactorAuthMethodExtensions.kt | 1 + .../drawable/img_new_device_verification.xml | 49 +++++ app/src/main/res/values/strings.xml | 4 +- .../network/service/AccountsServiceTest.kt | 14 ++ .../auth/repository/AuthRepositoryTest.kt | 34 ++++ .../EnterpriseSignOnViewModelTest.kt | 8 +- .../ui/auth/feature/login/LoginScreenTest.kt | 2 +- .../auth/feature/login/LoginViewModelTest.kt | 38 ++++ .../LoginWithDeviceViewModelTest.kt | 67 +++++++ .../TwoFactorLoginScreenTest.kt | 59 +++++- .../TwoFactorLoginViewModelTest.kt | 178 +++++++++++++++++- .../util/TwoFactorAuthMethodExtensionTest.kt | 2 +- 32 files changed, 786 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResendNewDeviceOtpRequestJson.kt create mode 100644 app/src/main/res/drawable/img_new_device_verification.xml diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedAccountsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedAccountsApi.kt index 3015d4f87b..8765a8d6b3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedAccountsApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedAccountsApi.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendNewDeviceOtpRequestJson import com.x8bit.bitwarden.data.platform.datasource.network.model.NetworkResult import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_KEY_AUTHORIZATION import retrofit2.http.Body @@ -28,4 +29,9 @@ interface UnauthenticatedAccountsApi { @Body body: KeyConnectorKeyRequestJson, @Header(HEADER_KEY_AUTHORIZATION) bearerToken: String, ): NetworkResult + + @POST("/accounts/resend-new-device-otp") + suspend fun resendNewDeviceOtp( + @Body body: ResendNewDeviceOtpRequestJson, + ): NetworkResult } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt index 1be39c3fe1..0e98230c8a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt @@ -47,6 +47,7 @@ interface UnauthenticatedIdentityApi { @Field(value = "twoFactorProvider") twoFactorMethod: String?, @Field(value = "twoFactorRemember") twoFactorRemember: String?, @Field(value = "authRequest") authRequestId: String?, + @Field(value = "newDeviceOtp") newDeviceOtp: String?, ): NetworkResult @GET("/sso/prevalidate") 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 ee25582aff..537483d138 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 @@ -107,6 +107,28 @@ sealed class GetTokenResponseJson { val errorMessage: String? get() = errorModel?.errorMessage ?: legacyErrorModel?.errorMessage + /** + * The type of invalid responses that can be received. + */ + sealed class InvalidType { + /** + * Represents an invalid response indicating that a new device verification is required. + */ + data object NewDeviceVerification : InvalidType() + + /** + * Represents generic invalid response + */ + data object GenericInvalid : InvalidType() + } + + val invalidType: InvalidType + get() = if (errorMessage?.lowercase() == "new device verification required") { + InvalidType.NewDeviceVerification + } else { + InvalidType.GenericInvalid + } + /** * The error body of an invalid request containing a message. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResendNewDeviceOtpRequestJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResendNewDeviceOtpRequestJson.kt new file mode 100644 index 0000000000..f954b3c530 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResendNewDeviceOtpRequestJson.kt @@ -0,0 +1,20 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Hold the information necessary to resend the email with the + * new device verification code. + * + * @property email The user's email address. + * @property passwordHash The master password hash + */ +@Serializable +data class ResendNewDeviceOtpRequestJson( + @SerialName("Email") + val email: String, + + @SerialName("MasterPasswordHash") + val passwordHash: String?, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt index f3a23f1272..2a3f0cddfe 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyReq import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendNewDeviceOtpRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson @@ -52,6 +53,11 @@ interface AccountsService { */ suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result + /** + * Resend the email with the verification code for new devices + */ + suspend fun resendNewDeviceOtp(body: ResendNewDeviceOtpRequestJson): Result + /** * Reset the password. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt index 6e411d7767..35c44c52c1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt @@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMaster import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendNewDeviceOtpRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyOtpRequestJson @@ -114,6 +115,11 @@ class AccountsServiceImpl( .resendVerificationCodeEmail(body = body) .toResult() + override suspend fun resendNewDeviceOtp(body: ResendNewDeviceOtpRequestJson): Result = + unauthenticatedAccountsApi + .resendNewDeviceOtp(body = body) + .toResult() + override suspend fun resetPassword(body: ResetPasswordRequestJson): Result = if (body.currentPasswordHash == null) { authenticatedAccountsApi diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt index 5d73cace01..bbb7f44c8b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt @@ -46,6 +46,7 @@ interface IdentityService { authModel: IdentityTokenAuthModel, captchaToken: String?, twoFactorData: TwoFactorDataModel? = null, + newDeviceOtp: String? = null, ): Result /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt index cf0efd887b..9ebc2e97e5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt @@ -60,6 +60,7 @@ class IdentityServiceImpl( authModel: IdentityTokenAuthModel, captchaToken: String?, twoFactorData: TwoFactorDataModel?, + newDeviceOtp: String?, ): Result = unauthenticatedIdentityApi .getToken( scope = "api offline_access", @@ -79,6 +80,7 @@ class IdentityServiceImpl( twoFactorRemember = twoFactorData?.remember?.let { if (it) "1" else "0 " }, captchaResponse = captchaToken, authRequestId = authModel.authRequestId, + newDeviceOtp = newDeviceOtp, ) .toResult() .recoverCatching { throwable -> 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 163217efd6..839b874579 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 @@ -230,6 +230,19 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { organizationIdentifier: String, ): LoginResult + /** + * Repeat the previous login attempt but this time with New Device OTP + * 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?, + newDeviceOtp: String, + captchaToken: String?, + orgIdentifier: String?, + ): LoginResult + /** * Log out the current user. */ @@ -252,6 +265,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { */ suspend fun resendVerificationCodeEmail(): ResendEmailResult + /** + * Resend the email with the new device verification code. + */ + suspend fun resendNewDeviceOtp(): ResendEmailResult + /** * Switches to the account corresponding to the given [userId] if possible. */ 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 3f25414c45..674313d6ca 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 @@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequ 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.ResendEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendNewDeviceOtpRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailResponseJson @@ -224,6 +225,11 @@ class AuthRepositoryImpl( */ private var resendEmailRequestJson: ResendEmailRequestJson? = null + /** + * The information necessary to resend the verification code email for new devices. + */ + private var resendNewDeviceOtpRequestJson: ResendNewDeviceOtpRequestJson? = null + private var organizationIdentifier: String? = null /** @@ -683,6 +689,26 @@ class AuthRepositoryImpl( } ?: LoginResult.Error(errorMessage = null) + override suspend fun login( + email: String, + password: String?, + newDeviceOtp: String, + captchaToken: String?, + orgIdentifier: String?, + ): LoginResult = identityTokenAuthModel + ?.let { + loginCommon( + email = email, + password = password, + authModel = it, + newDeviceOtp = newDeviceOtp, + captchaToken = captchaToken ?: twoFactorResponse?.captchaToken, + deviceData = twoFactorDeviceData, + orgIdentifier = orgIdentifier, + ) + } + ?: LoginResult.Error(errorMessage = null) + override suspend fun login( email: String, ssoCode: String, @@ -764,6 +790,16 @@ class AuthRepositoryImpl( } ?: ResendEmailResult.Error(message = null) + override suspend fun resendNewDeviceOtp(): ResendEmailResult = + resendNewDeviceOtpRequestJson + ?.let { jsonRequest -> + accountsService.resendNewDeviceOtp(body = jsonRequest).fold( + onFailure = { ResendEmailResult.Error(message = it.message) }, + onSuccess = { ResendEmailResult.Success }, + ) + } + ?: ResendEmailResult.Error(message = null) + override fun switchAccount(userId: String): SwitchAccountResult { val currentUserState = authDiskSource.userState ?: return SwitchAccountResult.NoChange val previousActiveUserId = currentUserState.activeUserId @@ -1584,6 +1620,7 @@ class AuthRepositoryImpl( deviceData: DeviceDataModel? = null, orgIdentifier: String? = null, captchaToken: String?, + newDeviceOtp: String? = null, ): LoginResult = identityService .getToken( uniqueAppId = authDiskSource.uniqueAppId, @@ -1591,6 +1628,7 @@ class AuthRepositoryImpl( authModel = authModel, twoFactorData = twoFactorData ?: getRememberedTwoFactorData(email), captchaToken = captchaToken, + newDeviceOtp = newDeviceOtp, ) .fold( onFailure = { throwable -> @@ -1624,9 +1662,22 @@ class AuthRepositoryImpl( orgIdentifier = orgIdentifier, ) - is GetTokenResponseJson.Invalid -> LoginResult.Error( - errorMessage = loginResponse.errorMessage, - ) + is GetTokenResponseJson.Invalid -> { + when (loginResponse.invalidType) { + is GetTokenResponseJson.Invalid.InvalidType.NewDeviceVerification -> + handleLoginCommonNewDeviceVerification( + email = email, + authModel = authModel, + error = loginResponse.errorMessage, + ) + + is GetTokenResponseJson.Invalid.InvalidType.GenericInvalid -> { + LoginResult.Error( + errorMessage = loginResponse.errorMessage, + ) + } + } + } } }, ) @@ -1751,6 +1802,7 @@ class AuthRepositoryImpl( twoFactorResponse = null resendEmailRequestJson = null twoFactorDeviceData = null + resendNewDeviceOtpRequestJson = null settingsRepository.setDefaultsIfNecessary(userId = userId) settingsRepository.storeUserHasLoggedInValue(userId) vaultRepository.syncIfNecessary() @@ -1783,6 +1835,24 @@ class AuthRepositoryImpl( return LoginResult.TwoFactorRequired } + /** + * A helper method that processes the + * [GetTokenResponseJson.Invalid.InvalidType.NewDeviceVerification] when logging in. + */ + private fun handleLoginCommonNewDeviceVerification( + email: String, + authModel: IdentityTokenAuthModel, + error: String?, + ): LoginResult { + identityTokenAuthModel = authModel + resendNewDeviceOtpRequestJson = ResendNewDeviceOtpRequestJson( + email = email, + passwordHash = authModel.password, + ) + + return LoginResult.NewDeviceVerification(error) + } + /** * Attempt to unlock the current user's vault with key connector data. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt index 45042e2ab0..0bcd46c64a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt @@ -33,4 +33,9 @@ sealed class LoginResult { * There was an error in validating the certificate chain for the server */ data object CertificateError : LoginResult() + + /** + * New device verification is required + */ + data class NewDeviceVerification(val errorMessage: String?) : LoginResult() } 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 54c91e01cd..2295f79f16 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 @@ -150,11 +150,12 @@ fun NavGraphBuilder.authGraph( loginType = LoginWithDeviceType.OTHER_DEVICE, ) }, - onNavigateToTwoFactorLogin = { emailAddress, password -> + onNavigateToTwoFactorLogin = { emailAddress, password, isNewDeviceVerification -> navController.navigateToTwoFactorLogin( emailAddress = emailAddress, password = password, orgIdentifier = null, + isNewDeviceVerification = isNewDeviceVerification, ) }, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt index 977ec9d17d..553efacfbc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt @@ -146,7 +146,7 @@ class EnterpriseSignOnViewModel @Inject constructor( prevalidateSso() } - @Suppress("MaxLineLength") + @Suppress("MaxLineLength", "LongMethod") private fun handleOnLoginResult(action: EnterpriseSignOnAction.Internal.OnLoginResult) { when (val loginResult = action.loginResult) { is LoginResult.CaptchaRequired -> { @@ -162,7 +162,9 @@ class EnterpriseSignOnViewModel @Inject constructor( mutableStateFlow.update { it.copy( dialogState = EnterpriseSignOnState.DialogState.Error( - message = loginResult.errorMessage?.asText() + message = loginResult + .errorMessage + ?.asText() ?: R.string.login_sso_error.asText(), ), ) @@ -205,6 +207,17 @@ class EnterpriseSignOnViewModel @Inject constructor( ) } } + + is LoginResult.NewDeviceVerification -> { + mutableStateFlow.update { + it.copy( + dialogState = EnterpriseSignOnState.DialogState.Error( + message = loginResult.errorMessage?.asText() + ?: R.string.login_sso_error.asText(), + ), + ) + } + } } } 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 770df6ba16..3bd355c598 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,11 @@ fun NavGraphBuilder.loginDestination( onNavigateToMasterPasswordHint: (emailAddress: String) -> Unit, onNavigateToEnterpriseSignOn: (emailAddress: String) -> Unit, onNavigateToLoginWithDevice: (emailAddress: String) -> Unit, - onNavigateToTwoFactorLogin: (emailAddress: String, password: String?) -> Unit, + onNavigateToTwoFactorLogin: ( + emailAddress: String, + password: String?, + isNewDeviceVerification: Boolean, + ) -> 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 fe1760ef53..5b801c5913 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: (String) -> Unit, onNavigateToLoginWithDevice: (emailAddress: String) -> Unit, - onNavigateToTwoFactorLogin: (String, String?) -> Unit, + onNavigateToTwoFactorLogin: (String, String?, Boolean) -> Unit, viewModel: LoginViewModel = hiltViewModel(), intentManager: IntentManager = LocalIntentManager.current, keyboardController: SoftwareKeyboardController? = LocalSoftwareKeyboardController.current, @@ -94,7 +94,11 @@ fun LoginScreen( } is LoginEvent.NavigateToTwoFactorLogin -> { - onNavigateToTwoFactorLogin(event.emailAddress, event.password) + onNavigateToTwoFactorLogin( + event.emailAddress, + event.password, + event.isNewDeviceVerification, + ) } is LoginEvent.ShowToast -> { 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 e1b7f82118..56e911f3c3 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 @@ -143,7 +143,7 @@ class LoginViewModel @Inject constructor( } } - @Suppress("MaxLineLength") + @Suppress("MaxLineLength", "LongMethod") private fun handleReceiveLoginResult(action: LoginAction.Internal.ReceiveLoginResult) { when (val loginResult = action.loginResult) { is LoginResult.CaptchaRequired -> { @@ -202,6 +202,17 @@ class LoginViewModel @Inject constructor( ) } } + + is LoginResult.NewDeviceVerification -> { + mutableStateFlow.update { it.copy(dialogState = null) } + sendEvent( + LoginEvent.NavigateToTwoFactorLogin( + emailAddress = state.emailAddress, + password = state.passwordInput, + isNewDeviceVerification = true, + ), + ) + } } } @@ -366,6 +377,7 @@ sealed class LoginEvent { data class NavigateToTwoFactorLogin( val emailAddress: String, val password: String?, + val isNewDeviceVerification: Boolean = false, ) : LoginEvent() /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt index cf6d27c797..39dfdf5c5f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt @@ -203,7 +203,7 @@ class LoginWithDeviceViewModel @Inject constructor( } } - @Suppress("MaxLineLength") + @Suppress("MaxLineLength", "LongMethod") private fun handleReceiveLoginResult( action: LoginWithDeviceAction.Internal.ReceiveLoginResult, ) { @@ -267,6 +267,20 @@ class LoginWithDeviceViewModel @Inject constructor( ) } } + + is LoginResult.NewDeviceVerification -> { + mutableStateFlow.update { + it.copy( + dialogState = LoginWithDeviceState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = loginResult + .errorMessage + ?.asText() + ?: R.string.generic_error_message.asText(), + ), + ) + } + } } } 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 626d5f7289..03e7e7c954 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 @@ -15,10 +15,12 @@ private const val EMAIL_ADDRESS = "email_address" private const val PASSWORD = "password" private const val ORG_IDENTIFIER = "org_identifier" private const val TWO_FACTOR_LOGIN_PREFIX = "two_factor_login" +private const val NEW_DEVICE_VERIFICATION = "new_device_verification" private const val TWO_FACTOR_LOGIN_ROUTE = "$TWO_FACTOR_LOGIN_PREFIX/{$EMAIL_ADDRESS}?" + "$PASSWORD={$PASSWORD}&" + - "$ORG_IDENTIFIER={$ORG_IDENTIFIER}" + "$ORG_IDENTIFIER={$ORG_IDENTIFIER}&" + + "$NEW_DEVICE_VERIFICATION={$NEW_DEVICE_VERIFICATION}" /** * Class to retrieve Two-Factor Login arguments from the [SavedStateHandle]. @@ -28,11 +30,13 @@ data class TwoFactorLoginArgs( val emailAddress: String, val password: String?, val orgIdentifier: String?, + val isNewDeviceVerification: Boolean, ) { constructor(savedStateHandle: SavedStateHandle) : this( emailAddress = checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String, password = savedStateHandle.get(PASSWORD)?.base64UrlDecodeOrNull(), orgIdentifier = savedStateHandle.get(ORG_IDENTIFIER)?.base64UrlDecodeOrNull(), + isNewDeviceVerification = savedStateHandle.get(NEW_DEVICE_VERIFICATION) ?: false, ) } @@ -44,11 +48,13 @@ fun NavController.navigateToTwoFactorLogin( password: String?, orgIdentifier: String?, navOptions: NavOptions? = null, + isNewDeviceVerification: Boolean = false, ) { this.navigate( route = "$TWO_FACTOR_LOGIN_PREFIX/$emailAddress?" + "$PASSWORD=${password?.base64UrlEncode()}&" + - "$ORG_IDENTIFIER=${orgIdentifier?.base64UrlEncode()}", + "$ORG_IDENTIFIER=${orgIdentifier?.base64UrlEncode()}&" + + "$NEW_DEVICE_VERIFICATION=$isNewDeviceVerification", navOptions = navOptions, ) } @@ -71,6 +77,9 @@ fun NavGraphBuilder.twoFactorLoginDestination( type = NavType.StringType nullable = true }, + navArgument(NEW_DEVICE_VERIFICATION) { + type = NavType.BoolType + }, ), ) { TwoFactorLoginScreen( 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 4eb5b4cbce..3e44ce9c82 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions @@ -43,6 +44,7 @@ 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.LivecycleEventEffect +import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem @@ -128,13 +130,18 @@ fun TwoFactorLoginScreen( ) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val title = if (state.isNewDeviceVerification) { + R.string.verify_your_identity.asText() + } else { + state.authMethod.title + } BitwardenScaffold( modifier = Modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { BitwardenTopAppBar( - title = state.authMethod.title(), + title = title(), scrollBehavior = scrollBehavior, navigationIcon = rememberVectorPainter(id = R.drawable.ic_close), navigationIconContentDescription = stringResource(id = R.string.close), @@ -142,22 +149,24 @@ fun TwoFactorLoginScreen( { viewModel.trySendAction(TwoFactorLoginAction.CloseButtonClick) } }, actions = { - BitwardenOverflowActionItem( - menuItemDataList = state.availableAuthMethods - .map { - OverflowMenuItemData( - text = it.title(), - onClick = remember(viewModel) { - { - viewModel.trySendAction( - TwoFactorLoginAction.SelectAuthMethod(it), - ) - } - }, - ) - } - .toPersistentList(), - ) + if (!state.isNewDeviceVerification) { + BitwardenOverflowActionItem( + menuItemDataList = state.availableAuthMethods + .map { + OverflowMenuItemData( + text = it.title(), + onClick = remember(viewModel) { + { + viewModel.trySendAction( + TwoFactorLoginAction.SelectAuthMethod(it), + ) + } + }, + ) + } + .toPersistentList(), + ) + } }, ) }, @@ -220,10 +229,29 @@ private fun TwoFactorLoginScreenContent( .imePadding() .verticalScroll(rememberScrollState()), ) { + if (state.authMethod == TwoFactorAuthMethod.EMAIL) { + state.imageRes?.let { + Spacer(modifier = Modifier.height(12.dp)) + Image( + painter = painterResource(id = it), + contentDescription = null, + modifier = Modifier + .standardHorizontalMargin() + .size(100.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } Spacer(modifier = Modifier.height(height = 12.dp)) Text( - text = state.authMethod.description(state.displayEmail)(), - textAlign = TextAlign.Start, + text = if (state.isNewDeviceVerification) { + R.string.enter_verification_code_new_device.asText(state.displayEmail).invoke() + } else { + state.authMethod.description( + state.displayEmail, + ).invoke() + }, + textAlign = TextAlign.Center, style = BitwardenTheme.typography.bodyMedium, color = BitwardenTheme.colorScheme.text.primary, modifier = Modifier @@ -233,19 +261,21 @@ private fun TwoFactorLoginScreenContent( Spacer(modifier = Modifier.height(12.dp)) - state.imageRes?.let { - Spacer(modifier = Modifier.height(12.dp)) - Image( - painter = painterResource(id = it), - contentDescription = null, - alignment = Alignment.Center, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .padding(horizontal = 24.dp) - .clip(RoundedCornerShape(4.dp)) - .fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(24.dp)) + if (state.authMethod != TwoFactorAuthMethod.EMAIL) { + state.imageRes?.let { + Spacer(modifier = Modifier.height(12.dp)) + Image( + painter = painterResource(id = it), + contentDescription = null, + alignment = Alignment.Center, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(horizontal = 24.dp) + .clip(RoundedCornerShape(4.dp)) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(24.dp)) + } } if (state.shouldShowCodeInput) { @@ -268,15 +298,17 @@ private fun TwoFactorLoginScreenContent( Spacer(modifier = Modifier.height(height = 8.dp)) } - BitwardenSwitch( - label = stringResource(id = R.string.remember), - isChecked = state.isRememberEnabled, - onCheckedChange = onRememberMeToggle, - cardStyle = CardStyle.Full, - modifier = Modifier - .standardHorizontalMargin() - .fillMaxWidth(), - ) + if (!state.isNewDeviceVerification) { + BitwardenSwitch( + label = stringResource(id = R.string.remember), + isChecked = state.isRememberEnabled, + onCheckedChange = onRememberMeToggle, + cardStyle = CardStyle.Full, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + } Spacer(modifier = Modifier.height(height = 24.dp)) @@ -322,6 +354,7 @@ private fun TwoFactorLoginScreenContentPreview() { email = "", password = "", orgIdentifier = null, + isNewDeviceVerification = true, ), onCodeInputChange = {}, onContinueButtonClick = {}, 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 15c7e8ae2f..e582738929 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 @@ -62,7 +62,8 @@ class TwoFactorLoginViewModel @Inject constructor( authMethod = authRepository.twoFactorResponse.preferredAuthMethod, availableAuthMethods = authRepository.twoFactorResponse.availableAuthMethods, codeInput = "", - displayEmail = authRepository.twoFactorResponse.twoFactorDisplayEmail, + displayEmail = authRepository.twoFactorResponse?.twoFactorDisplayEmail + ?: args.emailAddress, dialogState = null, isContinueButtonEnabled = authRepository .twoFactorResponse @@ -73,6 +74,7 @@ class TwoFactorLoginViewModel @Inject constructor( email = args.emailAddress, password = args.password, orgIdentifier = args.orgIdentifier, + isNewDeviceVerification = args.isNewDeviceVerification, ) }, ) { @@ -321,6 +323,18 @@ class TwoFactorLoginViewModel @Inject constructor( } } + is LoginResult.NewDeviceVerification -> { + mutableStateFlow.update { + it.copy( + dialogState = TwoFactorLoginState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = loginResult.errorMessage?.asText() + ?: R.string.invalid_verification_code.asText(), + ), + ) + } + } + // NO-OP: Let the auth flow handle navigation after this. is LoginResult.Success -> Unit LoginResult.CertificateError -> { @@ -470,7 +484,11 @@ class TwoFactorLoginViewModel @Inject constructor( // Resend the email notification. viewModelScope.launch { - val result = authRepository.resendVerificationCodeEmail() + val result = if (!state.isNewDeviceVerification) { + authRepository.resendVerificationCodeEmail() + } else { + authRepository.resendNewDeviceOtp() + } sendAction( TwoFactorLoginAction.Internal.ReceiveResendEmailResult( resendEmailResult = result, @@ -555,17 +573,27 @@ class TwoFactorLoginViewModel @Inject constructor( } viewModelScope.launch { - val result = authRepository.login( - email = state.email, - password = state.password, - twoFactorData = TwoFactorDataModel( - code = code, - method = state.authMethod.value.toString(), - remember = state.isRememberEnabled, - ), - captchaToken = state.captchaToken, - orgIdentifier = state.orgIdentifier, - ) + val result = if (state.isNewDeviceVerification) { + authRepository.login( + email = state.email, + password = state.password, + newDeviceOtp = code, + captchaToken = state.captchaToken, + orgIdentifier = state.orgIdentifier, + ) + } else { + authRepository.login( + email = state.email, + password = state.password, + twoFactorData = TwoFactorDataModel( + code = code, + method = state.authMethod.value.toString(), + remember = state.isRememberEnabled, + ), + captchaToken = state.captchaToken, + orgIdentifier = state.orgIdentifier, + ) + } sendAction( TwoFactorLoginAction.Internal.ReceiveLoginResult( loginResult = result, @@ -587,6 +615,7 @@ data class TwoFactorLoginState( val displayEmail: String, val isContinueButtonEnabled: Boolean, val isRememberEnabled: Boolean, + val isNewDeviceVerification: Boolean, // Internal val captchaToken: String?, val email: String, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt index be3e83fb0a..8d3abba9a1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt @@ -118,5 +118,6 @@ val TwoFactorAuthMethod.shouldUseNfc: Boolean val TwoFactorAuthMethod.imageRes: Int? get() = when (this) { TwoFactorAuthMethod.YUBI_KEY -> R.drawable.yubi_key + TwoFactorAuthMethod.EMAIL -> R.drawable.img_new_device_verification else -> null } diff --git a/app/src/main/res/drawable/img_new_device_verification.xml b/app/src/main/res/drawable/img_new_device_verification.xml new file mode 100644 index 0000000000..872f7fb081 --- /dev/null +++ b/app/src/main/res/drawable/img_new_device_verification.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 35ecf8fdde..6c4894dc8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,12 +242,13 @@ Authenticator app Enter the 6 digit verification code from your authenticator app. Enter the 6 digit verification code that was emailed to %1$s. + We don\'t recognize this device. Enter the 8 digit verification code that was emailed to %1$s. Login unavailable This account has two-step login set up, however, none of the configured two-step providers are supported on this device. Please use a supported device and/or add additional providers that are better supported across devices (such as an authenticator app). Recovery code Remember Remember email - Send verification code email again + Resend code Two-step login options Use another two-step login method Could not send verification email. Try again. @@ -1178,6 +1179,7 @@ Do you want to switch to this account? 6 OF 6 Use these options to adjust your password to meet your account website\'s requirements. "After you save your new password to Bitwarden, don’t forget to update it on your account website. " + Verify your identity Error loading certificate Certificate alias Import certificate diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt index 9e50f90e08..3618db7801 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt @@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMaster import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendNewDeviceOtpRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson import com.x8bit.bitwarden.data.platform.base.BaseServiceTest @@ -250,4 +251,17 @@ class AccountsServiceTest : BaseServiceTest() { ) assertEquals(Unit.asSuccess(), result) } + + @Test + fun `resendNewDeviceOtp with empty response is success`() = runTest { + val response = MockResponse().setBody("") + server.enqueue(response) + val result = service.resendNewDeviceOtp( + body = ResendNewDeviceOtpRequestJson( + email = "example@email.com", + passwordHash = "37y4d8r379r4789nt387r39k3dr87nr93", + ), + ) + assertTrue(result.isSuccess) + } } 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 1816b1dcaa..27a06169cf 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 @@ -1595,6 +1595,40 @@ class AuthRepositoryTest { } } + @Test + @Suppress("MaxLineLength") + fun `login get token should return InvalidType NewDeviceVerification when message is new device verification needed`() = + runTest { + coEvery { + identityService.preLogin(email = EMAIL) + } returns PRE_LOGIN_SUCCESS.asSuccess() + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.MasterPassword( + username = EMAIL, + password = PASSWORD_HASH, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } returns GetTokenResponseJson + .Invalid( + errorModel = GetTokenResponseJson.Invalid.ErrorModel( + errorMessage = "new device verification required", + ), + legacyErrorModel = null, + ) + .asSuccess() + + val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null) + assertEquals( + LoginResult.NewDeviceVerification(errorMessage = "new device verification required"), + result, + ) + assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) + } + @Test @Suppress("MaxLineLength") fun `login get token succeeds should return Success, unlockVault, update AuthState, update stored keys, and sync`() = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt index 7caff89606..3d58e4b522 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt @@ -378,14 +378,14 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") @Test - fun `ssoCallbackResultFlow Success with same state with login UnofficialServerError should show loading dialog then show unofficial error when Bitwarden server is unofficial`() = + @Suppress("MaxLineLength") + fun `ssoCallbackResultFlow Success with same state with login NewDeviceVerification with message should update dialogState`() = runTest { val orgIdentifier = "Bitwarden" coEvery { authRepository.login(any(), any(), any(), any(), any(), any()) - } returns LoginResult.UnofficialServerError + } returns LoginResult.NewDeviceVerification(errorMessage = "new device verification required") val viewModel = createViewModel( ssoData = DEFAULT_SSO_DATA, @@ -424,7 +424,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( dialogState = EnterpriseSignOnState.DialogState.Error( - message = R.string.this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server.asText(), + message = "new device verification required".asText(), ), orgIdentifierInput = orgIdentifier, ), 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 af39f683f4..9ccbd8fb26 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 @@ -70,7 +70,7 @@ class LoginScreenTest : BaseComposeTest() { onNavigateToMasterPasswordHint = { onNavigateToMasterPasswordHintCalled = true }, onNavigateToEnterpriseSignOn = { onNavigateToEnterpriseSignOnCalled = true }, onNavigateToLoginWithDevice = { onNavigateToLoginWithDeviceCalled = true }, - onNavigateToTwoFactorLogin = { _, _ -> onNavigateToTwoFactorLoginCalled = true }, + onNavigateToTwoFactorLogin = { _, _, _ -> onNavigateToTwoFactorLoginCalled = true }, viewModel = viewModel, intentManager = intentManager, keyboardController = keyboardController, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index 5dc89ef94a..647e5d154b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -454,6 +454,44 @@ class LoginViewModelTest : BaseViewModelTest() { } } + @Test + @Suppress("MaxLineLength") + fun `LoginButtonClick login returns NewDeviceVerification should emit NavigateToTwoFactorLogin`() = + runTest { + val password = "password" + coEvery { + authRepository.login( + email = EMAIL, + password = password, + captchaToken = null, + ) + } returns LoginResult.NewDeviceVerification(errorMessage = "new device verification needed") + + val viewModel = createViewModel( + state = DEFAULT_STATE.copy( + passwordInput = password, + ), + ) + viewModel.eventFlow.test { + viewModel.trySendAction(LoginAction.LoginButtonClick) + assertEquals( + DEFAULT_STATE.copy(passwordInput = password), + viewModel.stateFlow.value, + ) + assertEquals( + LoginEvent.NavigateToTwoFactorLogin( + emailAddress = EMAIL, + password = password, + isNewDeviceVerification = true, + ), + awaitItem(), + ) + } + coVerify { + authRepository.login(email = EMAIL, password = password, captchaToken = null) + } + } + @Test fun `MasterPasswordHintClick should emit NavigateToMasterPasswordHint`() = runTest { val viewModel = createViewModel() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt index e940e11271..42e61d77d6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt @@ -520,6 +520,73 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { } } + @Test + @Suppress("MaxLineLength") + fun `on createAuthRequestWithUpdates Success and NewDeviceVerification with message should update dialogState`() = + runTest { + coEvery { + authRepository.login( + email = EMAIL, + requestId = DEFAULT_LOGIN_DATA.requestId, + accessCode = DEFAULT_LOGIN_DATA.accessCode, + asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey, + requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey, + masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash, + captchaToken = null, + ) + } returns LoginResult.NewDeviceVerification(errorMessage = "new device verification required") + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + mutableCreateAuthRequestWithUpdatesFlow.tryEmit( + CreateAuthRequestResult.Success( + authRequest = AUTH_REQUEST, + privateKey = AUTH_REQUEST_PRIVATE_KEY, + accessCode = AUTH_REQUEST_ACCESS_CODE, + ), + ) + assertEquals( + DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + fingerprintPhrase = "", + ), + loginData = DEFAULT_LOGIN_DATA, + dialogState = LoginWithDeviceState.DialogState.Loading( + message = R.string.logging_in.asText(), + ), + ), + awaitItem(), + ) + assertEquals( + DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + fingerprintPhrase = "", + ), + dialogState = LoginWithDeviceState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = "new device verification required".asText(), + ), + loginData = DEFAULT_LOGIN_DATA, + ), + awaitItem(), + ) + } + } + + coVerify(exactly = 1) { + authRepository.login( + email = EMAIL, + requestId = AUTH_REQUEST.id, + accessCode = AUTH_REQUEST_ACCESS_CODE, + asymmetricalKey = requireNotNull(AUTH_REQUEST.key), + requestPrivateKey = AUTH_REQUEST_PRIVATE_KEY, + masterPasswordHash = AUTH_REQUEST.masterPasswordHash, + captchaToken = null, + ) + } + } + @Test fun `on captchaTokenResultFlow missing token should should display error dialog`() = runTest { val viewModel = createViewModel() 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 eba7820565..5967bd9219 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 @@ -7,10 +7,12 @@ import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo 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 @@ -95,7 +97,9 @@ class TwoFactorLoginScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy(isContinueButtonEnabled = true) } - composeTestRule.onNodeWithText("Continue").performClick() + composeTestRule.onNodeWithText("Continue") + .performScrollTo() + .performClick() verify { viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick) } @@ -114,7 +118,9 @@ class TwoFactorLoginScreenTest : BaseComposeTest() { @Test fun `continue button text should update according to the state`() { - composeTestRule.onNodeWithText("Continue").assertIsDisplayed() + composeTestRule.onNodeWithText("Continue") + .performScrollTo() + .assertIsDisplayed() mutableStateFlow.update { it.copy(authMethod = TwoFactorAuthMethod.DUO) @@ -175,7 +181,9 @@ class TwoFactorLoginScreenTest : BaseComposeTest() { mutableStateFlow.update { it.copy(authMethod = TwoFactorAuthMethod.EMAIL) } - composeTestRule.onNodeWithText("Send verification code email again").performClick() + composeTestRule.onNodeWithText("Resend code") + .performScrollTo() + .performClick() verify { viewModel.trySendAction(TwoFactorLoginAction.ResendEmailClick) } @@ -183,8 +191,10 @@ class TwoFactorLoginScreenTest : BaseComposeTest() { @Test fun `resend email button visibility should update according to state`() { - val buttonText = "Send verification code email again" - composeTestRule.onNodeWithText(buttonText).assertIsDisplayed() + val buttonText = "Resend code" + composeTestRule.onNodeWithText(buttonText) + .performScrollTo() + .assertIsDisplayed() mutableStateFlow.update { it.copy(authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP) @@ -278,6 +288,44 @@ class TwoFactorLoginScreenTest : BaseComposeTest() { intentManager.launchUri(mockUri) } } + + @Test + fun `remember me should not be visible if isNewDeviceVerification is true`() { + mutableStateFlow.update { + it.copy(isNewDeviceVerification = true) + } + composeTestRule.onNodeWithText("Remember").assertIsNotDisplayed() + } + + @Test + @Suppress("MaxLineLength") + fun `if isNewDeviceVerification is true description should contain We don't recognize this device string`() { + mutableStateFlow.update { + it.copy(authMethod = TwoFactorAuthMethod.EMAIL, isNewDeviceVerification = true) + } + composeTestRule.onNode( + hasText( + text = "We don't recognize this device", + substring = true, + ignoreCase = true, + ), + ).assertIsDisplayed() + } + + @Test + @Suppress("MaxLineLength") + fun `if isNewDeviceVerification is false description should not contain We don't recognize this device string`() { + mutableStateFlow.update { + it.copy(isNewDeviceVerification = false) + } + composeTestRule.onNode( + hasText( + text = "We don't recognize this device", + substring = true, + ignoreCase = true, + ), + ).assertIsNotDisplayed() + } } private val DEFAULT_STATE = TwoFactorLoginState( @@ -291,6 +339,7 @@ private val DEFAULT_STATE = TwoFactorLoginState( dialogState = null, isContinueButtonEnabled = false, isRememberEnabled = false, + isNewDeviceVerification = 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 b4bc1cce91..fc95e971ff 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 @@ -54,7 +54,16 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { every { duoTokenResultFlow } returns mutableDuoTokenResultFlow every { yubiKeyResultFlow } returns mutableYubiKeyResultFlow every { webAuthResultFlow } returns mutableWebAuthResultFlow - coEvery { login(any(), any(), any(), any(), any()) } returns LoginResult.Success + coEvery { resendNewDeviceOtp() } returns ResendEmailResult.Success + coEvery { + login( + email = any(), + password = any(), + twoFactorData = any(), + captchaToken = any(), + orgIdentifier = any(), + ) + } returns LoginResult.Success } private val environmentRepository: EnvironmentRepository = mockk { every { environment } returns Environment.Us @@ -780,6 +789,66 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { } } + @Test + @Suppress("MaxLineLength") + fun `ContinueButtonClick login returns NewDeviceVerification with message should update dialogState`() = + runTest { + coEvery { + authRepository.login( + email = DEFAULT_EMAIL_ADDRESS, + password = DEFAULT_PASSWORD, + twoFactorData = TwoFactorDataModel( + code = "", + method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(), + remember = false, + ), + captchaToken = null, + orgIdentifier = DEFAULT_ORG_IDENTIFIER, + ) + } returns LoginResult.NewDeviceVerification(errorMessage = "new device verification required") + + 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 = "new device verification required".asText(), + ), + ), + awaitItem(), + ) + + viewModel.trySendAction(TwoFactorLoginAction.DialogDismiss) + assertEquals(DEFAULT_STATE, awaitItem()) + } + coVerify { + authRepository.login( + email = DEFAULT_EMAIL_ADDRESS, + password = DEFAULT_PASSWORD, + twoFactorData = TwoFactorDataModel( + code = "", + method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(), + remember = false, + ), + captchaToken = null, + orgIdentifier = DEFAULT_ORG_IDENTIFIER, + ) + } + } + @Test fun `RememberMeToggle should update the state`() { val viewModel = createViewModel() @@ -1000,6 +1069,111 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { } } + @Test + @Suppress("MaxLineLength") + fun `when AuthRepository newDeviceOtp is true ContinueButtonClick should call the login method with newDeviceOtp`() { + val code = " code" + val initialState = DEFAULT_STATE.copy( + authMethod = TwoFactorAuthMethod.EMAIL, + codeInput = code, + isNewDeviceVerification = true, + ) + val localAuthRepository: AuthRepository = mockk { + every { twoFactorResponse } returns TWO_FACTOR_RESPONSE + every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow + every { duoTokenResultFlow } returns mutableDuoTokenResultFlow + every { yubiKeyResultFlow } returns mutableYubiKeyResultFlow + every { webAuthResultFlow } returns mutableWebAuthResultFlow + coEvery { + login( + email = any(), + password = any(), + newDeviceOtp = any(), + captchaToken = any(), + orgIdentifier = any(), + ) + } returns LoginResult.Success + } + + val localViewModel = + TwoFactorLoginViewModel( + authRepository = localAuthRepository, + environmentRepository = environmentRepository, + resourceManager = resourceManager, + savedStateHandle = SavedStateHandle().also { + it["state"] = initialState + }, + ) + + localViewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick) + assertEquals( + initialState.copy( + password = DEFAULT_PASSWORD, + email = DEFAULT_EMAIL_ADDRESS, + isContinueButtonEnabled = false, + ), + localViewModel.stateFlow.value, + ) + coVerify(exactly = 1) { + localAuthRepository.login( + email = DEFAULT_STATE.email, + password = DEFAULT_STATE.password, + newDeviceOtp = code.trim(), + captchaToken = DEFAULT_STATE.captchaToken, + orgIdentifier = DEFAULT_STATE.orgIdentifier, + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `ReceiveResendEmailResult with newDeviceVerification true should call ResendNewDeviceOtp`() = + runTest { + val initialState = DEFAULT_STATE.copy( + authMethod = TwoFactorAuthMethod.EMAIL, + isNewDeviceVerification = true, + ) + val viewModel = createViewModel(state = initialState) + viewModel.eventFlow.test { + viewModel.trySendAction( + TwoFactorLoginAction.ResendEmailClick, + ) + awaitItem() + + coVerify(exactly = 1) { + authRepository.resendNewDeviceOtp() + } + coVerify(exactly = 0) { + authRepository.resendVerificationCodeEmail() + } + } + } + + @Test + @Suppress("MaxLineLength") + fun `ReceiveResendEmailResult with ResendEmailResult Success should ShowToast`() = + runTest { + val initialState = DEFAULT_STATE.copy( + authMethod = TwoFactorAuthMethod.EMAIL, + isNewDeviceVerification = true, + ) + val viewModel = createViewModel(state = initialState) + viewModel.eventFlow.test { + viewModel.trySendAction( + TwoFactorLoginAction.Internal.ReceiveResendEmailResult( + resendEmailResult = ResendEmailResult.Success, + isUserInitiated = true, + ), + ) + assertEquals( + TwoFactorLoginEvent.ShowToast( + message = R.string.verification_email_sent.asText(), + ), + awaitItem(), + ) + } + } + private fun createViewModel( state: TwoFactorLoginState? = null, ): TwoFactorLoginViewModel = @@ -1012,6 +1186,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { it["email_address"] = DEFAULT_EMAIL_ADDRESS it["password"] = DEFAULT_ENCODED_PASSWORD it["org_identifier"] = DEFAULT_ENCODED_ORG_IDENTIFIER + it["new_device_verification"] = false }, ) } @@ -1050,4 +1225,5 @@ private val DEFAULT_STATE = TwoFactorLoginState( email = DEFAULT_EMAIL_ADDRESS, password = DEFAULT_PASSWORD, orgIdentifier = DEFAULT_ORG_IDENTIFIER, + isNewDeviceVerification = false, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt index a6cd89b581..ffe43b43b0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt @@ -136,7 +136,7 @@ class TwoFactorAuthMethodExtensionTest { fun `imageRes returns the expected value`() { mapOf( TwoFactorAuthMethod.AUTHENTICATOR_APP to null, - TwoFactorAuthMethod.EMAIL to null, + TwoFactorAuthMethod.EMAIL to R.drawable.img_new_device_verification, TwoFactorAuthMethod.DUO to null, TwoFactorAuthMethod.YUBI_KEY to R.drawable.yubi_key, TwoFactorAuthMethod.U2F to null,