BIT-917 BIT-1489: Show two factor auth view (#756)

This commit is contained in:
Shannon Draeker 2024-01-25 09:46:24 -07:00 committed by Álison Fernandes
parent 6f9147b2b2
commit 41229d0324
24 changed files with 1229 additions and 1 deletions

View file

@ -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<TwoFactorAuthMethod, Map<String, String?>?>,
@SerialName("CaptchaBypassToken")
val captchaToken: String?,
@SerialName("SsoEmail2faSessionToken")
val ssoToken: String?,
) : GetTokenResponseJson()
}

View file

@ -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,
}

View file

@ -50,6 +50,9 @@ class IdentityServiceImpl constructor(
bitwardenError.parseErrorBodyOrNull<GetTokenResponseJson.CaptchaRequired>(
code = 400,
json = json,
) ?: bitwardenError.parseErrorBodyOrNull<GetTokenResponseJson.TwoFactorRequired>(
code = 400,
json = json,
) ?: bitwardenError.parseErrorBodyOrNull<GetTokenResponseJson.Invalid>(
code = 400,
json = json,

View file

@ -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
}

View file

@ -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<TwoFactorAuthMethod>
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")
?: ""

View file

@ -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<SsoCallbackResult>
/**
* 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).
*/

View file

@ -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<AuthState> = 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,

View file

@ -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.
*/

View file

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

View file

@ -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,
)
}
}

View file

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

View file

@ -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].
*/

View file

@ -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,
)
}
}

View file

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

View file

@ -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<TwoFactorLoginState, TwoFactorLoginEvent, TwoFactorLoginAction>(
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<TwoFactorAuthMethod>,
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()
}

View file

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

View file

@ -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",

View file

@ -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,
)
}
}
}

View file

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

View file

@ -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),
)

View file

@ -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<LoginEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<LoginViewModel>(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,
)

View file

@ -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<IntentManager>(relaxed = true) {
every { launchUri(any()) } returns Unit
}
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<TwoFactorLoginEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<TwoFactorLoginViewModel>(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,
)

View file

@ -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,
)
}
}

View file

@ -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"),
)
}
}
}