mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-917 BIT-1489: Show two factor auth view (#756)
This commit is contained in:
parent
6f9147b2b2
commit
41229d0324
24 changed files with 1229 additions and 1 deletions
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
?: ""
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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].
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue