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 3e6232338d..a03e1f7ef0 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 @@ -98,4 +98,30 @@ sealed class GetTokenResponseJson { val errorMessage: String, ) } + + /** + * Models json body of a two-factor error. + * + * @property authMethodsData A blob of data formatted as: + * `{"1":{"Email":"sh*****@example.com"},"0":{"Email":null}}` + * The keys are the raw values of the [TwoFactorAuthMethod], + * and the map is any extra information for the method. + * @property captchaToken The captcha token used in the second + * login attempt if the user has already passed a captcha + * authentication in the first attempt. + * @property ssoToken If the user is logging on via Single + * Sign On, they'll need this value to complete authentication + * after entering their two-factor code. + */ + @Serializable + data class TwoFactorRequired( + @SerialName("TwoFactorProviders2") + val authMethodsData: Map?>, + + @SerialName("CaptchaBypassToken") + val captchaToken: String?, + + @SerialName("SsoEmail2faSessionToken") + val ssoToken: String?, + ) : GetTokenResponseJson() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/TwoFactorAuthMethod.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/TwoFactorAuthMethod.kt new file mode 100644 index 0000000000..bd986aa160 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/TwoFactorAuthMethod.kt @@ -0,0 +1,38 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents different providers that can be used for two-factor login. + */ +@Serializable +@Suppress("MagicNumber") +enum class TwoFactorAuthMethod { + @SerialName("0") + AUTHENTICATOR_APP, + + @SerialName("1") + EMAIL, + + @SerialName("2") + DUO, + + @SerialName("3") + YUBI_KEY, + + @SerialName("4") + U2F, + + @SerialName("5") + REMEMBER, + + @SerialName("6") + DUO_ORGANIZATION, + + @SerialName("7") + FIDO_2_WEB_APP, + + @SerialName("-1") + RECOVERY_CODE, +} 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 77d77bc1d7..39b1e519df 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 @@ -50,6 +50,9 @@ class IdentityServiceImpl constructor( bitwardenError.parseErrorBodyOrNull( code = 400, json = json, + ) ?: bitwardenError.parseErrorBodyOrNull( + code = 400, + json = json, ) ?: bitwardenError.parseErrorBodyOrNull( code = 400, json = json, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorAuthMethodExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorAuthMethodExtensions.kt new file mode 100644 index 0000000000..9c5d867539 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorAuthMethodExtensions.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.util + +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod + +/** + * The priority, used to determine the default method from a list of available methods. + * (Higher value = preference to use the method if it's available) + */ +@Suppress("MagicNumber") +val TwoFactorAuthMethod.priority: Int + get() = when (this) { + TwoFactorAuthMethod.AUTHENTICATOR_APP -> 1 + TwoFactorAuthMethod.EMAIL -> 0 + TwoFactorAuthMethod.DUO -> 2 + TwoFactorAuthMethod.YUBI_KEY -> 3 + TwoFactorAuthMethod.DUO_ORGANIZATION -> 20 + TwoFactorAuthMethod.FIDO_2_WEB_APP -> 4 + else -> -1 + } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensions.kt new file mode 100644 index 0000000000..f8bb3877a1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensions.kt @@ -0,0 +1,37 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.util + +import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod + +/** + * Return the list of two-factor auth methods available to the user. + */ +val GetTokenResponseJson.TwoFactorRequired?.availableAuthMethods: List + get() = ( + this + ?.authMethodsData + ?.keys + ?.toList() + ?: listOf(TwoFactorAuthMethod.EMAIL) + ) + .plus(TwoFactorAuthMethod.RECOVERY_CODE) + +/** + * The preferred two-factor auth method to be used as a default on the two-factor login screen. + */ +val GetTokenResponseJson.TwoFactorRequired?.preferredAuthMethod: TwoFactorAuthMethod + get() = this + ?.authMethodsData + ?.keys + ?.maxByOrNull { it.priority } + ?: TwoFactorAuthMethod.EMAIL + +/** + * If it exists, return the value to display for the email used with two-factor authentication. + */ +val GetTokenResponseJson.TwoFactorRequired?.twoFactorDisplayEmail: String + get() = this + ?.authMethodsData + ?.get(TwoFactorAuthMethod.EMAIL) + ?.get("Email") + ?: "" 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 1eecad9d8a..54839ea98f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.auth.repository +import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult import com.x8bit.bitwarden.data.auth.repository.model.AuthState @@ -46,6 +47,11 @@ interface AuthRepository : AuthenticatorProvider { */ val ssoCallbackResultFlow: Flow + /** + * The two-factor data necessary for login and also to populate the Two-Factor Login screen. + */ + var twoFactorData: GetTokenResponseJson.TwoFactorRequired? + /** * The currently persisted saved email address (or `null` if not set). */ 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 38272a8ad1..aabeb5f22a 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 @@ -6,6 +6,7 @@ import com.bitwarden.crypto.Kdf import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired +import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.TwoFactorRequired import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthModel import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson @@ -91,6 +92,8 @@ class AuthRepositoryImpl( */ private val collectionScope = CoroutineScope(dispatcherManager.unconfined) + override var twoFactorData: TwoFactorRequired? = null + override val activeUserId: String? get() = authDiskSource.userState?.activeUserId override val authStateFlow: StateFlow = authDiskSource @@ -207,6 +210,11 @@ class AuthRepositoryImpl( onSuccess = { loginResponse -> when (loginResponse) { is CaptchaRequired -> LoginResult.CaptchaRequired(loginResponse.captchaKey) + is TwoFactorRequired -> { + twoFactorData = loginResponse + LoginResult.TwoFactorRequired + } + is Success -> { val userStateJson = loginResponse.toUserState( previousUserState = authDiskSource.userState, 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 37c5102689..f3d3bbe5e5 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 @@ -4,7 +4,6 @@ package com.x8bit.bitwarden.data.auth.repository.model * Models result of logging in. */ sealed class LoginResult { - /** * Login succeeded. */ @@ -15,6 +14,11 @@ sealed class LoginResult { */ data class CaptchaRequired(val captchaId: String) : LoginResult() + /** + * Two-factor verification is required. + */ + data object TwoFactorRequired : LoginResult() + /** * There was an error logging in. */ 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 5832e28b1f..7fc87b37d6 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 @@ -20,12 +20,15 @@ import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestin import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHintDestination import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint +import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin +import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination const val AUTH_GRAPH_ROUTE: String = "auth_graph" /** * Add auth destinations to the nav graph. */ +@Suppress("LongMethod") fun NavGraphBuilder.authGraph(navController: NavHostController) { navigation( startDestination = LANDING_ROUTE, @@ -67,6 +70,7 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { }, onNavigateToEnterpriseSignOn = { navController.navigateToEnterpriseSignOn() }, onNavigateToLoginWithDevice = { navController.navigateToLoginWithDevice() }, + onNavigateToTwoFactorLogin = { navController.navigateToTwoFactorLogin() }, ) loginWithDeviceDestination( onNavigateBack = { navController.popBackStack() }, @@ -77,6 +81,9 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { masterPasswordHintDestination( onNavigateBack = { navController.popBackStack() }, ) + twoFactorLoginDestination( + onNavigateBack = { navController.popBackStack() }, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt index 565530b1fc..78ded61a91 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,6 +46,7 @@ fun NavGraphBuilder.loginDestination( onNavigateToMasterPasswordHint: (emailAddress: String) -> Unit, onNavigateToEnterpriseSignOn: () -> Unit, onNavigateToLoginWithDevice: () -> Unit, + onNavigateToTwoFactorLogin: () -> Unit, ) { composableWithSlideTransitions( route = LOGIN_ROUTE, @@ -62,6 +63,7 @@ fun NavGraphBuilder.loginDestination( onNavigateToMasterPasswordHint = onNavigateToMasterPasswordHint, onNavigateToEnterpriseSignOn = onNavigateToEnterpriseSignOn, onNavigateToLoginWithDevice = onNavigateToLoginWithDevice, + onNavigateToTwoFactorLogin = onNavigateToTwoFactorLogin, ) } } 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 7c38c8cdf1..e61fcfeeb6 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,6 +67,7 @@ fun LoginScreen( onNavigateToMasterPasswordHint: (String) -> Unit, onNavigateToEnterpriseSignOn: () -> Unit, onNavigateToLoginWithDevice: () -> Unit, + onNavigateToTwoFactorLogin: () -> Unit, viewModel: LoginViewModel = hiltViewModel(), intentManager: IntentManager = LocalIntentManager.current, ) { @@ -78,12 +79,14 @@ fun LoginScreen( is LoginEvent.NavigateToMasterPasswordHint -> { onNavigateToMasterPasswordHint(event.emailAddress) } + is LoginEvent.NavigateToCaptcha -> { intentManager.startCustomTabsActivity(uri = event.uri) } LoginEvent.NavigateToEnterpriseSignOn -> onNavigateToEnterpriseSignOn() LoginEvent.NavigateToLoginWithDevice -> onNavigateToLoginWithDevice() + LoginEvent.NavigateToTwoFactorLogin -> onNavigateToTwoFactorLogin() is LoginEvent.ShowToast -> { Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index 87b20bc224..aa106fd980 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 @@ -158,6 +158,11 @@ class LoginViewModel @Inject constructor( ) } + is LoginResult.TwoFactorRequired -> { + mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) } + sendEvent(LoginEvent.NavigateToTwoFactorLogin) + } + is LoginResult.Error -> { mutableStateFlow.update { it.copy( @@ -307,6 +312,11 @@ sealed class LoginEvent { */ data object NavigateToLoginWithDevice : LoginEvent() + /** + * Navigates to the two-factor login screen. + */ + data object NavigateToTwoFactorLogin : LoginEvent() + /** * Shows a toast with the given [message]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginNavigation.kt new file mode 100644 index 0000000000..eb1631fc2c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginNavigation.kt @@ -0,0 +1,30 @@ +package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +private const val TWO_FACTOR_LOGIN_ROUTE = "two_factor_login" + +/** + * Navigate to the Two-Factor Login screen. + */ +fun NavController.navigateToTwoFactorLogin(navOptions: NavOptions? = null) { + this.navigate(TWO_FACTOR_LOGIN_ROUTE, navOptions) +} + +/** + * Add the Two-Factor Login screen to the nav graph. + */ +fun NavGraphBuilder.twoFactorLoginDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions( + route = TWO_FACTOR_LOGIN_ROUTE, + ) { + TwoFactorLoginScreen( + onNavigateBack = onNavigateBack, + ) + } +} 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 new file mode 100644 index 0000000000..bbae2fe0ca --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt @@ -0,0 +1,209 @@ +package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod +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.components.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton +import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem +import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager +import kotlinx.collections.immutable.toPersistentList + +/** + * The top level composable for the Login with Device screen. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TwoFactorLoginScreen( + onNavigateBack: () -> Unit, + viewModel: TwoFactorLoginViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + EventsEffect(viewModel = viewModel) { event -> + when (event) { + TwoFactorLoginEvent.NavigateBack -> onNavigateBack() + + TwoFactorLoginEvent.NavigateToRecoveryCode -> { + intentManager.launchUri("https://bitwarden.com/help/lost-two-step-device".toUri()) + } + + is TwoFactorLoginEvent.ShowToast -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + } + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = state.authMethod.title(), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(TwoFactorLoginAction.CloseButtonClick) } + }, + actions = { + BitwardenOverflowActionItem( + menuItemDataList = state.availableAuthMethods + .map { + OverflowMenuItemData( + text = it.title(), + onClick = remember(viewModel) { + { + viewModel.trySendAction( + TwoFactorLoginAction.SelectAuthMethod(it), + ) + } + }, + ) + } + .toPersistentList(), + ) + }, + ) + }, + ) { innerPadding -> + TwoFactorLoginScreenContent( + state = state, + onCodeInputChange = remember(viewModel) { + { viewModel.trySendAction(TwoFactorLoginAction.CodeInputChanged(it)) } + }, + onContinueButtonClick = remember(viewModel) { + { viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick) } + }, + onRememberMeToggle = remember(viewModel) { + { viewModel.trySendAction(TwoFactorLoginAction.RememberMeToggle(it)) } + }, + onResendEmailButtonClick = remember(viewModel) { + { viewModel.trySendAction(TwoFactorLoginAction.ResendEmailClick) } + }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun TwoFactorLoginScreenContent( + state: TwoFactorLoginState, + onCodeInputChange: (String) -> Unit, + onContinueButtonClick: () -> Unit, + onRememberMeToggle: (Boolean) -> Unit, + onResendEmailButtonClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .semantics { testTagsAsResourceId = true } + .imePadding() + .verticalScroll(rememberScrollState()), + ) { + Text( + text = state.authMethod.description(state.displayEmail)(), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + BitwardenTextField( + value = state.codeInput, + onValueChange = onCodeInputChange, + label = stringResource(id = R.string.verification_code), + keyboardType = KeyboardType.Number, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + BitwardenSwitch( + label = stringResource(id = R.string.remember_me), + isChecked = state.isRememberMeEnabled, + onCheckedChange = onRememberMeToggle, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + BitwardenFilledButton( + label = stringResource(id = R.string.continue_text), + onClick = onContinueButtonClick, + isEnabled = state.isContinueButtonEnabled, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + if (state.authMethod == TwoFactorAuthMethod.EMAIL) { + Spacer(modifier = Modifier.height(12.dp)) + + BitwardenFilledTonalButton( + label = stringResource(id = R.string.send_verification_code_again), + onClick = onResendEmailButtonClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + } + } +} 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 new file mode 100644 index 0000000000..f5e9e0a737 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt @@ -0,0 +1,194 @@ +package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod +import com.x8bit.bitwarden.data.auth.datasource.network.util.availableAuthMethods +import com.x8bit.bitwarden.data.auth.datasource.network.util.preferredAuthMethod +import com.x8bit.bitwarden.data.auth.datasource.network.util.twoFactorDisplayEmail +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * Manages application state for the Two-Factor Login screen. + */ +@HiltViewModel +class TwoFactorLoginViewModel @Inject constructor( + private val authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: TwoFactorLoginState( + authMethod = authRepository.twoFactorData.preferredAuthMethod, + availableAuthMethods = authRepository.twoFactorData.availableAuthMethods, + codeInput = "", + displayEmail = authRepository.twoFactorData.twoFactorDisplayEmail, + isContinueButtonEnabled = false, + isRememberMeEnabled = false, + ), +) { + init { + // As state updates, write to saved state handle. + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } + + override fun handleAction(action: TwoFactorLoginAction) { + when (action) { + TwoFactorLoginAction.CloseButtonClick -> handleCloseButtonClicked() + is TwoFactorLoginAction.CodeInputChanged -> handleCodeInputChanged(action) + TwoFactorLoginAction.ContinueButtonClick -> handleContinueButtonClick() + is TwoFactorLoginAction.RememberMeToggle -> handleRememberMeToggle(action) + TwoFactorLoginAction.ResendEmailClick -> handleResendEmailClick() + is TwoFactorLoginAction.SelectAuthMethod -> handleSelectAuthMethod(action) + } + } + + /** + * Update the state with the new text and enable or disable the continue button. + */ + private fun handleCodeInputChanged(action: TwoFactorLoginAction.CodeInputChanged) { + mutableStateFlow.update { + it.copy( + codeInput = action.input, + isContinueButtonEnabled = action.input.length >= 6, + ) + } + } + + /** + * Verify the input and attempt to authenticate with the code. + */ + private fun handleContinueButtonClick() { + // TODO: Finish implementation (BIT-918) + sendEvent(TwoFactorLoginEvent.ShowToast("Not yet implemented")) + } + + /** + * Dismiss the view. + */ + private fun handleCloseButtonClicked() { + sendEvent(TwoFactorLoginEvent.NavigateBack) + } + + /** + * Update the state with the new toggle value. + */ + private fun handleRememberMeToggle(action: TwoFactorLoginAction.RememberMeToggle) { + mutableStateFlow.update { + it.copy( + isRememberMeEnabled = action.isChecked, + ) + } + } + + /** + * Resend the verification code email. + */ + private fun handleResendEmailClick() { + // TODO: Finish implementation (BIT-918) + sendEvent(TwoFactorLoginEvent.ShowToast("Not yet implemented")) + } + + /** + * Update the state with the auth method or opens the url for the recovery code. + */ + private fun handleSelectAuthMethod(action: TwoFactorLoginAction.SelectAuthMethod) { + if (action.authMethod == TwoFactorAuthMethod.RECOVERY_CODE) { + sendEvent(TwoFactorLoginEvent.NavigateToRecoveryCode) + } else { + mutableStateFlow.update { + it.copy( + authMethod = action.authMethod, + ) + } + } + } +} + +/** + * Models state of the Two-Factor Login screen. + */ +@Parcelize +data class TwoFactorLoginState( + val authMethod: TwoFactorAuthMethod, + val availableAuthMethods: List, + val codeInput: String, + val displayEmail: String, + val isContinueButtonEnabled: Boolean, + val isRememberMeEnabled: Boolean, +) : Parcelable + +/** + * Models events for the Two-Factor Login screen. + */ +sealed class TwoFactorLoginEvent { + /** + * Navigates back to the previous screen. + */ + data object NavigateBack : TwoFactorLoginEvent() + + /** + * Navigates to the recovery code help page. + */ + data object NavigateToRecoveryCode : TwoFactorLoginEvent() + + /** + * Shows a toast with the given [message]. + */ + data class ShowToast( + val message: String, + ) : TwoFactorLoginEvent() +} + +/** + * Models actions for the Two-Factor Login screen. + */ +sealed class TwoFactorLoginAction { + + /** + * Indicates that the top-bar close button was clicked. + */ + data object CloseButtonClick : TwoFactorLoginAction() + + /** + * Indicates that the input on the verification code field changed. + */ + data class CodeInputChanged( + val input: String, + ) : TwoFactorLoginAction() + + /** + * Indicates that the Continue button was clicked. + */ + data object ContinueButtonClick : TwoFactorLoginAction() + + /** + * Indicates that the Remember Me switch toggled. + */ + data class RememberMeToggle( + val isChecked: Boolean, + ) : TwoFactorLoginAction() + + /** + * Indicates that the Resend Email button was clicked. + */ + data object ResendEmailClick : TwoFactorLoginAction() + + /** + * Indicates an auth method was selected from the menu dropdown. + */ + data class SelectAuthMethod( + val authMethod: TwoFactorAuthMethod, + ) : TwoFactorLoginAction() +} 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 new file mode 100644 index 0000000000..fc3731926a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText + +/** + * Get the title for the given auth method. + */ +val TwoFactorAuthMethod.title: Text + get() = when (this) { + TwoFactorAuthMethod.AUTHENTICATOR_APP -> R.string.authenticator_app_title.asText() + TwoFactorAuthMethod.EMAIL -> R.string.email.asText() + TwoFactorAuthMethod.RECOVERY_CODE -> R.string.recovery_code_title.asText() + else -> "".asText() + } + +/** + * Get the description for the given auth method. + */ +fun TwoFactorAuthMethod.description(email: String): Text = when (this) { + TwoFactorAuthMethod.AUTHENTICATOR_APP -> R.string.enter_verification_code_app.asText() + TwoFactorAuthMethod.EMAIL -> R.string.enter_verification_code_email.asText(email) + else -> "".asText() +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt index be56902ad7..711614566e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt @@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.MasterPasswordPoli import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson import com.x8bit.bitwarden.data.platform.base.BaseServiceTest import com.x8bit.bitwarden.data.platform.util.DeviceModelProvider @@ -81,6 +82,21 @@ class IdentityServiceTest : BaseServiceTest() { assertEquals(Result.success(CAPTCHA_BODY), result) } + @Test + fun `getToken when response is TwoFactorRequired should return TwoFactorRequired`() = runTest { + server.enqueue(MockResponse().setResponseCode(400).setBody(TWO_FACTOR_BODY_JSON)) + val result = identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.MasterPassword( + username = EMAIL, + password = PASSWORD_HASH, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + assertEquals(Result.success(TWO_FACTOR_BODY), result) + } + @Test fun `getToken when response is a 400 with an error body should return Invalid`() = runTest { server.enqueue(MockResponse().setResponseCode(400).setBody(INVALID_LOGIN_JSON)) @@ -170,6 +186,22 @@ private const val CAPTCHA_BODY_JSON = """ """ private val CAPTCHA_BODY = GetTokenResponseJson.CaptchaRequired("123") +private const val TWO_FACTOR_BODY_JSON = """ +{ + "TwoFactorProviders2": {"1": {"Email": "ex***@email.com"}, "0": {"Email": null}}, + "SsoEmail2faSessionToken": "exampleToken", + "CaptchaBypassToken": "BWCaptchaBypass_ABCXYZ" +} +""" +private val TWO_FACTOR_BODY = GetTokenResponseJson.TwoFactorRequired( + authMethodsData = mapOf( + TwoFactorAuthMethod.EMAIL to mapOf("Email" to "ex***@email.com"), + TwoFactorAuthMethod.AUTHENTICATOR_APP to mapOf("Email" to null), + ), + ssoToken = "exampleToken", + captchaToken = "BWCaptchaBypass_ABCXYZ", +) + private const val LOGIN_SUCCESS_JSON = """ { "access_token": "accessToken", diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorAuthMethodExtensionTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorAuthMethodExtensionTest.kt new file mode 100644 index 0000000000..4f7d1bc0d2 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorAuthMethodExtensionTest.kt @@ -0,0 +1,27 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.util + +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TwoFactorAuthMethodExtensionTest { + @Test + fun `priority returns the expected value`() { + mapOf( + TwoFactorAuthMethod.AUTHENTICATOR_APP to 1, + TwoFactorAuthMethod.EMAIL to 0, + TwoFactorAuthMethod.YUBI_KEY to 3, + TwoFactorAuthMethod.U2F to -1, + TwoFactorAuthMethod.REMEMBER to -1, + TwoFactorAuthMethod.DUO_ORGANIZATION to 20, + TwoFactorAuthMethod.FIDO_2_WEB_APP to 4, + TwoFactorAuthMethod.RECOVERY_CODE to -1, + ) + .forEach { (type, priority) -> + assertEquals( + priority, + type.priority, + ) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensionTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensionTest.kt new file mode 100644 index 0000000000..a149addcb6 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/TwoFactorRequiredExtensionTest.kt @@ -0,0 +1,66 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.util + +import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals + +class TwoFactorRequiredExtensionTest { + @Test + fun `availableAuthMethods returns the expected value`() { + val subject = GetTokenResponseJson.TwoFactorRequired( + authMethodsData = mapOf( + TwoFactorAuthMethod.EMAIL to mapOf("Email" to "ex***@email.com"), + TwoFactorAuthMethod.AUTHENTICATOR_APP to mapOf("Email" to null), + ), + captchaToken = null, + ssoToken = null, + ) + assertEquals( + listOf( + TwoFactorAuthMethod.EMAIL, + TwoFactorAuthMethod.AUTHENTICATOR_APP, + TwoFactorAuthMethod.RECOVERY_CODE, + ), + subject.availableAuthMethods, + ) + } + + @Test + fun `twoFactorDisplayEmail returns the expected value`() { + val subject = GetTokenResponseJson.TwoFactorRequired( + authMethodsData = mapOf( + TwoFactorAuthMethod.EMAIL to mapOf("Email" to "ex***@email.com"), + TwoFactorAuthMethod.AUTHENTICATOR_APP to mapOf("Email" to null), + ), + captchaToken = null, + ssoToken = null, + ) + assertEquals("ex***@email.com", subject.twoFactorDisplayEmail) + } + + @Test + fun `twoFactorDisplayEmail returns the expected value when null`() { + val subject = GetTokenResponseJson.TwoFactorRequired( + authMethodsData = mapOf( + TwoFactorAuthMethod.AUTHENTICATOR_APP to mapOf("Email" to null), + ), + captchaToken = null, + ssoToken = null, + ) + assertEquals("", subject.twoFactorDisplayEmail) + } + + @Test + fun `preferredAuthMethod returns the expected value`() { + val subject = GetTokenResponseJson.TwoFactorRequired( + authMethodsData = mapOf( + TwoFactorAuthMethod.EMAIL to mapOf("Email" to "ex***@email.com"), + TwoFactorAuthMethod.AUTHENTICATOR_APP to mapOf("Email" to null), + ), + captchaToken = null, + ssoToken = null, + ) + assertEquals(TwoFactorAuthMethod.AUTHENTICATOR_APP, subject.preferredAuthMethod) + } +} 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 72fdc60dfb..65231302e5 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 @@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResp import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService @@ -709,6 +710,48 @@ class AuthRepositoryTest { } } + @Test + fun `login get token returns two factor request should return TwoFactorRequired`() = runTest { + coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS) + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.MasterPassword( + username = EMAIL, + password = PASSWORD_HASH, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + .returns( + Result.success( + GetTokenResponseJson.TwoFactorRequired( + TWO_FACTOR_AUTH_METHODS_DATA, null, null, + ), + ), + ) + val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null) + assertEquals(LoginResult.TwoFactorRequired, result) + assertEquals( + repository.twoFactorData, + GetTokenResponseJson.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null), + ) + assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) + coVerify { accountsService.preLogin(email = EMAIL) } + coVerify { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.MasterPassword( + username = EMAIL, + password = PASSWORD_HASH, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } + } + @Test fun `register check data breaches error should still return register success`() = runTest { coEvery { @@ -1450,6 +1493,7 @@ class AuthRepositoryTest { private const val REFRESH_TOKEN = "refreshToken" private const val REFRESH_TOKEN_2 = "refreshToken2" private const val CAPTCHA_KEY = "captcha" + private const val DEFAULT_KDF_ITERATIONS = 600000 private const val ENCRYPTED_USER_KEY = "encryptedUserKey" private const val PUBLIC_KEY = "PublicKey" @@ -1457,6 +1501,10 @@ class AuthRepositoryTest { private const val USER_ID_1 = "2a135b23-e1fb-42c9-bec3-573857bc8181" private const val USER_ID_2 = "b9d32ec0-6497-4582-9798-b350f53bfa02" private val ORGANIZATIONS = listOf(createMockOrganization(number = 0)) + private val TWO_FACTOR_AUTH_METHODS_DATA = mapOf( + TwoFactorAuthMethod.EMAIL to mapOf("Email" to "ex***@email.com"), + TwoFactorAuthMethod.AUTHENTICATOR_APP to mapOf("Email" to null), + ) private val PRE_LOGIN_SUCCESS = PreLoginResponseJson( kdfParams = PreLoginResponseJson.KdfParams.Pbkdf2(iterations = 1u), ) 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 f35d90967f..88a3b6d470 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 @@ -48,6 +48,7 @@ class LoginScreenTest : BaseComposeTest() { private var onNavigateToMasterPasswordHintCalled = false private var onNavigateToEnterpriseSignOnCalled = false private var onNavigateToLoginWithDeviceCalled = false + private var onNavigateToTwoFactorLoginCalled = false private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { @@ -63,6 +64,7 @@ class LoginScreenTest : BaseComposeTest() { onNavigateToMasterPasswordHint = { onNavigateToMasterPasswordHintCalled = true }, onNavigateToEnterpriseSignOn = { onNavigateToEnterpriseSignOnCalled = true }, onNavigateToLoginWithDevice = { onNavigateToLoginWithDeviceCalled = true }, + onNavigateToTwoFactorLogin = { onNavigateToTwoFactorLoginCalled = true }, viewModel = viewModel, intentManager = intentManager, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt new file mode 100644 index 0000000000..d40cb71534 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt @@ -0,0 +1,197 @@ +package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +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.isDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import junit.framework.TestCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test + +class TwoFactorLoginScreenTest : BaseComposeTest() { + private val intentManager = mockk(relaxed = true) { + every { launchUri(any()) } returns Unit + } + private var onNavigateBackCalled = false + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + TwoFactorLoginScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + intentManager = intentManager, + ) + } + } + + @Test + fun `close button click should send CloseButtonClick action`() { + composeTestRule.onNodeWithContentDescription("Close").performClick() + verify { + viewModel.trySendAction(TwoFactorLoginAction.CloseButtonClick) + } + } + + @Test + fun `code input change should send CodeInputChanged action`() { + val input = "123456" + composeTestRule.onNodeWithText("Verification code").performTextInput(input) + verify { + viewModel.trySendAction(TwoFactorLoginAction.CodeInputChanged(input)) + } + } + + @Test + fun `continue button click should send ContinueButtonClick action`() { + mutableStateFlow.update { + it.copy(isContinueButtonEnabled = true) + } + composeTestRule.onNodeWithText("Continue").performClick() + verify { + viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick) + } + } + + @Test + fun `continue button enabled state should update according to the state`() { + composeTestRule.onNodeWithText("Continue").assertIsNotEnabled() + + mutableStateFlow.update { + it.copy(isContinueButtonEnabled = true) + } + + composeTestRule.onNodeWithText("Continue").assertIsEnabled() + } + + @Test + fun `description text should update according to state`() { + val emailDetails = + "Enter the 6 digit verification code that was emailed to ex***@email.com." + val authAppDetails = "Enter the 6 digit verification code from your authenticator app." + composeTestRule.onNodeWithText(emailDetails).isDisplayed() + composeTestRule.onNodeWithText(authAppDetails).assertDoesNotExist() + + mutableStateFlow.update { + it.copy(authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP) + } + + composeTestRule.onNodeWithText(emailDetails).assertDoesNotExist() + composeTestRule.onNodeWithText(authAppDetails).isDisplayed() + } + + @Test + fun `remember me click should send RememberMeToggle action`() { + composeTestRule.onNodeWithText("Remember me").performClick() + verify { + viewModel.trySendAction(TwoFactorLoginAction.RememberMeToggle(true)) + } + } + + @Test + fun `remember me should be toggled on or off according to the state`() { + composeTestRule.onNodeWithText("Remember me").assertIsOff() + + mutableStateFlow.update { it.copy(isRememberMeEnabled = true) } + + composeTestRule.onNodeWithText("Remember me").assertIsOn() + } + + @Test + fun `resend email button click should send ResendEmailClick action`() { + mutableStateFlow.update { + it.copy(authMethod = TwoFactorAuthMethod.EMAIL) + } + composeTestRule.onNodeWithText("Send verification code email again").performClick() + verify { + viewModel.trySendAction(TwoFactorLoginAction.ResendEmailClick) + } + } + + @Test + fun `resend email button visibility should should update according to state`() { + val buttonText = "Send verification code email again" + composeTestRule.onNodeWithText(buttonText).assertIsDisplayed() + + mutableStateFlow.update { + it.copy(authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP) + } + composeTestRule.onNodeWithText(buttonText).assertIsNotDisplayed() + } + + @Test + fun `options menu icon click should show the auth method options`() { + composeTestRule.onNodeWithContentDescription("More").performClick() + composeTestRule.onNodeWithText("Recovery code").assertIsDisplayed() + } + + @Test + fun `options menu option click should should send SelectAuthMethod and close the menu`() { + composeTestRule.onNodeWithContentDescription("More").performClick() + composeTestRule.onNodeWithText("Recovery code").performClick() + verify { + viewModel.trySendAction( + TwoFactorLoginAction.SelectAuthMethod(TwoFactorAuthMethod.RECOVERY_CODE), + ) + } + composeTestRule.onNodeWithText("Recovery code").assertDoesNotExist() + } + + @Test + fun `title text should update according to state`() { + composeTestRule.onNodeWithText("Email").isDisplayed() + composeTestRule.onNodeWithText("Authenticator App").assertDoesNotExist() + + mutableStateFlow.update { + it.copy(authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP) + } + + composeTestRule.onNodeWithText("Email").assertDoesNotExist() + composeTestRule.onNodeWithText("Authenticator App").isDisplayed() + } + + @Test + fun `NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateBack) + TestCase.assertTrue(onNavigateBackCalled) + } + + @Test + fun `NavigateToRecoveryCode should launch the recovery code uri`() { + mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToRecoveryCode) + verify { + intentManager.launchUri(any()) + } + } +} + +private val DEFAULT_STATE = TwoFactorLoginState( + authMethod = TwoFactorAuthMethod.EMAIL, + availableAuthMethods = listOf(TwoFactorAuthMethod.EMAIL, TwoFactorAuthMethod.RECOVERY_CODE), + codeInput = "", + displayEmail = "ex***@email.com", + isContinueButtonEnabled = false, + isRememberMeEnabled = false, +) 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 new file mode 100644 index 0000000000..b8984d65c5 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt @@ -0,0 +1,181 @@ +package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TwoFactorLoginViewModelTest : BaseViewModelTest() { + + private val authRepository: AuthRepository = mockk(relaxed = true) { + every { twoFactorData } returns TWO_FACTOR_DATA + } + private val savedStateHandle = SavedStateHandle().also { + it["email_address"] = "test@gmail.com" + } + + @Test + fun `initial state should be correct`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + } + } + + @Test + fun `CloseButtonClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(TwoFactorLoginAction.CloseButtonClick) + assertEquals( + TwoFactorLoginEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Test + fun `CodeInputChanged should update input and enable button if code is long enough`() = + runTest { + val input = "123456" + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(TwoFactorLoginAction.CodeInputChanged(input)) + assertEquals( + DEFAULT_STATE.copy( + codeInput = input, + isContinueButtonEnabled = true, + ), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `CodeInputChanged should update input and disable button if code is blank`() = + runTest { + val input = "123456" + val viewModel = createViewModel() + viewModel.eventFlow.test { + // Set it to true. + viewModel.actionChannel.trySend(TwoFactorLoginAction.CodeInputChanged(input)) + assertEquals( + DEFAULT_STATE.copy( + codeInput = input, + isContinueButtonEnabled = true, + ), + viewModel.stateFlow.value, + ) + + // Set it to false. + viewModel.actionChannel.trySend(TwoFactorLoginAction.CodeInputChanged("")) + assertEquals( + DEFAULT_STATE.copy( + codeInput = "", + isContinueButtonEnabled = false, + ), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `RememberMeToggle should update the state`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(TwoFactorLoginAction.RememberMeToggle(true)) + assertEquals( + DEFAULT_STATE.copy( + isRememberMeEnabled = true, + ), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `ResendEmailClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(TwoFactorLoginAction.ResendEmailClick) + assertEquals( + TwoFactorLoginEvent.ShowToast("Not yet implemented"), + awaitItem(), + ) + } + } + + @Test + fun `SelectAuthMethod with RECOVERY_CODE should launch the NavigateToRecoveryCode event`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend( + TwoFactorLoginAction.SelectAuthMethod( + TwoFactorAuthMethod.RECOVERY_CODE, + ), + ) + assertEquals( + TwoFactorLoginEvent.NavigateToRecoveryCode, + awaitItem(), + ) + } + } + + @Test + fun `SelectAuthMethod with other method should update the state`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend( + TwoFactorLoginAction.SelectAuthMethod( + TwoFactorAuthMethod.AUTHENTICATOR_APP, + ), + ) + assertEquals( + DEFAULT_STATE.copy( + authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP, + ), + viewModel.stateFlow.value, + ) + } + } + + private fun createViewModel(): TwoFactorLoginViewModel = + TwoFactorLoginViewModel( + authRepository = authRepository, + savedStateHandle = savedStateHandle, + ) + + companion object { + private val TWO_FACTOR_AUTH_METHODS_DATA = mapOf( + TwoFactorAuthMethod.EMAIL to mapOf("Email" to "ex***@email.com"), + TwoFactorAuthMethod.AUTHENTICATOR_APP to mapOf("Email" to null), + ) + private val TWO_FACTOR_DATA = + GetTokenResponseJson.TwoFactorRequired( + TWO_FACTOR_AUTH_METHODS_DATA, + null, + null, + ) + + private val DEFAULT_STATE = TwoFactorLoginState( + authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP, + availableAuthMethods = listOf( + TwoFactorAuthMethod.EMAIL, + TwoFactorAuthMethod.AUTHENTICATOR_APP, + TwoFactorAuthMethod.RECOVERY_CODE, + ), + codeInput = "", + displayEmail = "ex***@email.com", + isContinueButtonEnabled = false, + isRememberMeEnabled = 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 new file mode 100644 index 0000000000..c5a0c68a92 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt @@ -0,0 +1,53 @@ +package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod +import com.x8bit.bitwarden.ui.platform.base.util.asText +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TwoFactorAuthMethodExtensionTest { + @Test + fun `title returns the expected value`() { + mapOf( + TwoFactorAuthMethod.AUTHENTICATOR_APP to R.string.authenticator_app_title.asText(), + TwoFactorAuthMethod.EMAIL to R.string.email.asText(), + TwoFactorAuthMethod.DUO to "".asText(), + TwoFactorAuthMethod.YUBI_KEY to "".asText(), + TwoFactorAuthMethod.U2F to "".asText(), + TwoFactorAuthMethod.REMEMBER to "".asText(), + TwoFactorAuthMethod.DUO_ORGANIZATION to "".asText(), + TwoFactorAuthMethod.FIDO_2_WEB_APP to "".asText(), + TwoFactorAuthMethod.RECOVERY_CODE to R.string.recovery_code_title.asText(), + ) + .forEach { (type, title) -> + assertEquals( + title, + type.title, + ) + } + } + + @Test + fun `description returns the expected value`() { + mapOf( + TwoFactorAuthMethod.AUTHENTICATOR_APP to + R.string.enter_verification_code_app.asText(), + TwoFactorAuthMethod.EMAIL to + R.string.enter_verification_code_email.asText("ex***@email.com"), + TwoFactorAuthMethod.DUO to "".asText(), + TwoFactorAuthMethod.YUBI_KEY to "".asText(), + TwoFactorAuthMethod.U2F to "".asText(), + TwoFactorAuthMethod.REMEMBER to "".asText(), + TwoFactorAuthMethod.DUO_ORGANIZATION to "".asText(), + TwoFactorAuthMethod.FIDO_2_WEB_APP to "".asText(), + TwoFactorAuthMethod.RECOVERY_CODE to "".asText(), + ) + .forEach { (type, title) -> + assertEquals( + title, + type.description("ex***@email.com"), + ) + } + } +}