[PM-8223] Add new device verification when no OTP (#4712)

This commit is contained in:
aj-rosado authored and GitHub committed 2025-02-14 14:03:15 +00:00
1 parent d7a50b092b
commit f0d6599eb3
32 files changed
+786 -80

No files matched your search

@@ -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<Unit>
@POST("/accounts/resend-new-device-otp")
suspend fun resendNewDeviceOtp(
@Body body: ResendNewDeviceOtpRequestJson,
): NetworkResult<Unit>
}
@@ -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<GetTokenResponseJson.Success>
@GET("/sso/prevalidate")
@@ -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.
*/
@@ -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?,
)
@@ -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<Unit>
/**
* Resend the email with the verification code for new devices
*/
suspend fun resendNewDeviceOtp(body: ResendNewDeviceOtpRequestJson): Result<Unit>
/**
* Reset the password.
*/
@@ -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<Unit> =
unauthenticatedAccountsApi
.resendNewDeviceOtp(body = body)
.toResult()
override suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit> =
if (body.currentPasswordHash == null) {
authenticatedAccountsApi
@@ -46,6 +46,7 @@ interface IdentityService {
authModel: IdentityTokenAuthModel,
captchaToken: String?,
twoFactorData: TwoFactorDataModel? = null,
newDeviceOtp: String? = null,
): Result<GetTokenResponseJson>
/**
@@ -60,6 +60,7 @@ class IdentityServiceImpl(
authModel: IdentityTokenAuthModel,
captchaToken: String?,
twoFactorData: TwoFactorDataModel?,
newDeviceOtp: String?,
): Result<GetTokenResponseJson> = 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 ->
@@ -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.
*/
@@ -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.
*/
@@ -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()
}
@@ -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,
)
},
)
@@ -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(),
),
)
}
}
}
}
@@ -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,
@@ -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 -> {
@@ -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()
/**
@@ -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(),
),
)
}
}
}
}
@@ -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<String>(PASSWORD)?.base64UrlDecodeOrNull(),
orgIdentifier = savedStateHandle.get<String>(ORG_IDENTIFIER)?.base64UrlDecodeOrNull(),
isNewDeviceVerification = savedStateHandle.get<Boolean>(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(
@@ -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 = {},
@@ -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,
@@ -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
}
@@ -0,0 +1,49 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="97dp"
android:height="97dp"
android:viewportWidth="97"
android:viewportHeight="97">
<path
android:pathData="M4.5,39.758C4.5,38.007 5.265,36.344 6.593,35.204L45.244,2.043C47.117,0.436 49.883,0.436 51.756,2.043L90.407,35.204C91.735,36.344 92.5,38.007 92.5,39.758V89C92.5,91.209 90.709,93 88.5,93H8.5C6.291,93 4.5,91.209 4.5,89V39.758Z"
android:fillColor="#DBE5F6"/>
<path
android:pathData="M90.5,89V39.758C90.5,38.591 89.99,37.482 89.105,36.722L50.453,3.561C49.33,2.597 47.67,2.597 46.547,3.561L7.895,36.722C7.01,37.482 6.5,38.591 6.5,39.758V89C6.5,90.104 7.395,91 8.5,91H88.5C89.605,91 90.5,90.104 90.5,89ZM6.593,35.204C5.265,36.344 4.5,38.007 4.5,39.758V89C4.5,91.209 6.291,93 8.5,93H88.5C90.709,93 92.5,91.209 92.5,89V39.758C92.5,38.007 91.735,36.344 90.407,35.204L51.756,2.043C49.883,0.436 47.117,0.436 45.244,2.043L6.593,35.204Z"
android:fillColor="#0E3781"
android:fillType="evenOdd"/>
<path
android:pathData="M7.452,39.248C5.933,38.091 5.867,35.827 7.316,34.584L45.244,2.043C47.117,0.436 49.883,0.436 51.756,2.043L89.684,34.584C91.133,35.827 91.067,38.091 89.548,39.248L50.923,68.655C49.492,69.745 47.508,69.745 46.077,68.655L7.452,39.248Z"
android:fillColor="#99BAF4"/>
<path
android:pathData="M49.712,67.064L88.336,37.656C88.843,37.271 88.865,36.516 88.382,36.102L50.453,3.561C49.33,2.597 47.67,2.597 46.547,3.561L8.618,36.102C8.135,36.516 8.157,37.271 8.664,37.656L47.289,67.064C48.004,67.609 48.996,67.609 49.712,67.064ZM7.316,34.584C5.867,35.827 5.933,38.091 7.452,39.248L46.077,68.655C47.508,69.745 49.492,69.745 50.923,68.655L89.548,39.248C91.067,38.091 91.133,35.827 89.684,34.584L51.756,2.043C49.883,0.436 47.117,0.436 45.244,2.043L7.316,34.584Z"
android:fillColor="#0E3781"
android:fillType="evenOdd"/>
<path
android:pathData="M50.875,66.004C49.463,67.046 47.537,67.046 46.125,66.004L7.094,37.195C6.65,36.868 6.023,36.962 5.695,37.406C5.368,37.851 5.462,38.477 5.906,38.805L44.937,67.613C47.055,69.176 49.945,69.176 52.063,67.613L91.094,38.805C91.538,38.477 91.633,37.851 91.305,37.406C90.977,36.962 90.351,36.868 89.906,37.195L50.875,66.004Z"
android:fillColor="#0E3781"
android:fillType="evenOdd"/>
<path
android:pathData="M15.5,12H81.5V46L49.698,69.61C48.987,70.138 48.014,70.136 47.306,69.605L15.5,45.75V12Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M79.5,14H17.5V44.75L48.506,68.005L79.5,44.994V14ZM81.5,46L49.698,69.61C48.987,70.138 48.014,70.136 47.306,69.605L15.5,45.75V12H81.5V46Z"
android:fillColor="#0E3781"
android:fillType="evenOdd"/>
<path
android:pathData="M48.5,60L70.5,72.5L48.5,85L26.5,72.5L48.5,60Z"
android:fillColor="#DBE5F6"/>
<path
android:pathData="M50.924,62.104C49.492,61.013 47.508,61.013 46.076,62.104L7.106,91.796C6.667,92.13 6.039,92.046 5.705,91.606C5.37,91.167 5.455,90.539 5.894,90.205L44.864,60.514C47.012,58.877 49.988,58.877 52.136,60.514L51.544,61.291L52.136,60.514L91.106,90.205C91.545,90.539 91.63,91.167 91.296,91.606C90.961,92.046 90.333,92.13 89.894,91.796L50.924,62.104Z"
android:fillColor="#0E3781"
android:fillType="evenOdd"/>
<path
android:pathData="M26.5,36C26.5,32.134 29.634,29 33.5,29H63.5C67.366,29 70.5,32.134 70.5,36C70.5,39.866 67.366,43 63.5,43H33.5C29.634,43 26.5,39.866 26.5,36Z"
android:fillColor="#FFBF00"/>
<path
android:pathData="M63.5,31H33.5C30.739,31 28.5,33.239 28.5,36C28.5,38.761 30.739,41 33.5,41H63.5C66.261,41 68.5,38.761 68.5,36C68.5,33.239 66.261,31 63.5,31ZM33.5,29C29.634,29 26.5,32.134 26.5,36C26.5,39.866 29.634,43 33.5,43H63.5C67.366,43 70.5,39.866 70.5,36C70.5,32.134 67.366,29 63.5,29H33.5Z"
android:fillColor="#0E3781"
android:fillType="evenOdd"/>
<path
android:pathData="M33.5,38C34.605,38 35.5,37.105 35.5,36C35.5,34.895 34.605,34 33.5,34C32.395,34 31.5,34.895 31.5,36C31.5,37.105 32.395,38 33.5,38ZM39.5,38C40.605,38 41.5,37.105 41.5,36C41.5,34.895 40.605,34 39.5,34C38.395,34 37.5,34.895 37.5,36C37.5,37.105 38.395,38 39.5,38ZM47.5,36C47.5,37.105 46.605,38 45.5,38C44.395,38 43.5,37.105 43.5,36C43.5,34.895 44.395,34 45.5,34C46.605,34 47.5,34.895 47.5,36ZM51.5,38C52.605,38 53.5,37.105 53.5,36C53.5,34.895 52.605,34 51.5,34C50.395,34 49.5,34.895 49.5,36C49.5,37.105 50.395,38 51.5,38ZM59.5,36C59.5,37.105 58.605,38 57.5,38C56.395,38 55.5,37.105 55.5,36C55.5,34.895 56.395,34 57.5,34C58.605,34 59.5,34.895 59.5,36ZM63.5,38C64.605,38 65.5,37.105 65.5,36C65.5,34.895 64.605,34 63.5,34C62.395,34 61.5,34.895 61.5,36C61.5,37.105 62.395,38 63.5,38Z"
android:fillColor="#0E3781"
android:fillType="evenOdd"/>
</vector>
+3 -1
View File
@@ -242,12 +242,13 @@
<string name="authenticator_app_title">Authenticator app</string>
<string name="enter_verification_code_app">Enter the 6 digit verification code from your authenticator app.</string>
<string name="enter_verification_code_email">Enter the 6 digit verification code that was emailed to %1$s.</string>
<string name="enter_verification_code_new_device">We don\'t recognize this device. Enter the 8 digit verification code that was emailed to %1$s.</string>
<string name="login_unavailable">Login unavailable</string>
<string name="no_two_step_available">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).</string>
<string name="recovery_code_title">Recovery code</string>
<string name="remember">Remember</string>
<string name="remember_email">Remember email</string>
<string name="send_verification_code_again">Send verification code email again</string>
<string name="send_verification_code_again">Resend code</string>
<string name="two_step_login_options">Two-step login options</string>
<string name="use_another_two_step_method">Use another two-step login method</string>
<string name="verification_email_not_sent">Could not send verification email. Try again.</string>
@@ -1178,6 +1179,7 @@ Do you want to switch to this account?</string>
<string name="coachmark_6_of_6">6 OF 6</string>
<string name="use_these_options_to_adjust_your_password_to_your_account_requirements">Use these options to adjust your password to meet your account website\'s requirements.</string>
<string name="after_you_save_your_new_password_to_bitwarden_don_t_forget_to_update_it_on_your_account_website">"After you save your new password to Bitwarden, don’t forget to update it on your account website. "</string>
<string name="verify_your_identity">Verify your identity</string>
<string name="error_loading_certificate">Error loading certificate</string>
<string name="certificate_alias">Certificate alias</string>
<string name="import_certificate">Import certificate</string>
@@ -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)
}
}
@@ -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`() =
@@ -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,
),
@@ -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,
@@ -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()
@@ -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()
@@ -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",
@@ -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,
)
@@ -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,