mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-1490: Two factor login (#775)
This commit is contained in:
parent
bc3a76260f
commit
3de3c8f0ed
21 changed files with 949 additions and 64 deletions
|
@ -117,6 +117,16 @@ interface AuthDiskSource {
|
|||
inMemoryOnly: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets a two-factor auth token using a user's [email].
|
||||
*/
|
||||
fun getTwoFactorToken(email: String): String?
|
||||
|
||||
/**
|
||||
* Stores a two-factor auth token using a user's [email].
|
||||
*/
|
||||
fun storeTwoFactorToken(email: String, twoFactorToken: String?)
|
||||
|
||||
/**
|
||||
* Retrieves an encrypted PIN for the given [userId].
|
||||
*/
|
||||
|
|
|
@ -27,6 +27,7 @@ private const val PIN_PROTECTED_USER_KEY_KEY = "$BASE_KEY:pinKeyEncryptedUserKey
|
|||
private const val ENCRYPTED_PIN_KEY = "$BASE_KEY:protectedPin"
|
||||
private const val ORGANIZATIONS_KEY = "$BASE_KEY:organizations"
|
||||
private const val ORGANIZATION_KEYS_KEY = "$BASE_KEY:encOrgKeys"
|
||||
private const val TWO_FACTOR_TOKEN_KEY = "$BASE_KEY:twoFactorToken"
|
||||
|
||||
/**
|
||||
* Primary implementation of [AuthDiskSource].
|
||||
|
@ -172,6 +173,16 @@ class AuthDiskSourceImpl(
|
|||
)
|
||||
}
|
||||
|
||||
override fun getTwoFactorToken(email: String): String? =
|
||||
getString(key = "${TWO_FACTOR_TOKEN_KEY}_$email")
|
||||
|
||||
override fun storeTwoFactorToken(email: String, twoFactorToken: String?) {
|
||||
putString(
|
||||
key = "${TWO_FACTOR_TOKEN_KEY}_$email",
|
||||
value = twoFactorToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getEncryptedPin(userId: String): String? =
|
||||
getString(key = "${ENCRYPTED_PIN_KEY}_$userId")
|
||||
|
||||
|
|
|
@ -23,6 +23,8 @@ sealed class GetTokenResponseJson {
|
|||
* @property shouldForcePasswordReset Whether or not the app must force a password reset.
|
||||
* @property shouldResetMasterPassword Whether or not the user is required to reset their
|
||||
* master password.
|
||||
* @property twoFactorToken If the user has chosen to remember the two-factor authorization,
|
||||
* this token will be cached and used for future auth requests.
|
||||
* @property masterPasswordPolicyOptions The options available for a user's master password.
|
||||
* @property userDecryptionOptions The options available to a user for decryption.
|
||||
*/
|
||||
|
@ -64,6 +66,9 @@ sealed class GetTokenResponseJson {
|
|||
@SerialName("ResetMasterPassword")
|
||||
val shouldResetMasterPassword: Boolean,
|
||||
|
||||
@SerialName("TwoFactorToken")
|
||||
val twoFactorToken: String?,
|
||||
|
||||
@SerialName("MasterPasswordPolicy")
|
||||
val masterPasswordPolicyOptions: MasterPasswordPolicyOptionsJson?,
|
||||
|
||||
|
|
|
@ -1,38 +1,43 @@
|
|||
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer
|
||||
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 {
|
||||
@Serializable(TwoFactorAuthMethodSerializer::class)
|
||||
enum class TwoFactorAuthMethod(val value: UInt) {
|
||||
@SerialName("0")
|
||||
AUTHENTICATOR_APP,
|
||||
AUTHENTICATOR_APP(value = 0U),
|
||||
|
||||
@SerialName("1")
|
||||
EMAIL,
|
||||
EMAIL(value = 1U),
|
||||
|
||||
@SerialName("2")
|
||||
DUO,
|
||||
DUO(value = 2U),
|
||||
|
||||
@SerialName("3")
|
||||
YUBI_KEY,
|
||||
YUBI_KEY(value = 3U),
|
||||
|
||||
@SerialName("4")
|
||||
U2F,
|
||||
U2F(value = 4U),
|
||||
|
||||
@SerialName("5")
|
||||
REMEMBER,
|
||||
REMEMBER(value = 5U),
|
||||
|
||||
@SerialName("6")
|
||||
DUO_ORGANIZATION,
|
||||
DUO_ORGANIZATION(value = 6U),
|
||||
|
||||
@SerialName("7")
|
||||
FIDO_2_WEB_APP,
|
||||
FIDO_2_WEB_APP(value = 7U),
|
||||
|
||||
@SerialName("-1")
|
||||
RECOVERY_CODE,
|
||||
RECOVERY_CODE(value = 100U),
|
||||
}
|
||||
|
||||
@Keep
|
||||
private class TwoFactorAuthMethodSerializer :
|
||||
BaseEnumeratedIntSerializer<TwoFactorAuthMethod>(TwoFactorAuthMethod.values())
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.x8bit.bitwarden.data.auth.repository
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
|
||||
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
|
||||
|
@ -49,9 +50,10 @@ interface AuthRepository : AuthenticatorProvider {
|
|||
val ssoCallbackResultFlow: Flow<SsoCallbackResult>
|
||||
|
||||
/**
|
||||
* The two-factor data necessary for login and also to populate the Two-Factor Login screen.
|
||||
* The two-factor response data necessary for login and also to populate the
|
||||
* Two-Factor Login screen.
|
||||
*/
|
||||
var twoFactorData: GetTokenResponseJson.TwoFactorRequired?
|
||||
var twoFactorResponse: GetTokenResponseJson.TwoFactorRequired?
|
||||
|
||||
/**
|
||||
* The currently persisted saved email address (or `null` if not set).
|
||||
|
@ -82,6 +84,18 @@ interface AuthRepository : AuthenticatorProvider {
|
|||
captchaToken: String?,
|
||||
): LoginResult
|
||||
|
||||
/**
|
||||
* Repeat the previous login attempt but this time with Two-Factor authentication
|
||||
* information. Password is included if available to unlock the vault after
|
||||
* authentication. Updated access token will be reflected in [authStateFlow].
|
||||
*/
|
||||
suspend fun login(
|
||||
email: String,
|
||||
password: String?,
|
||||
twoFactorData: TwoFactorDataModel,
|
||||
captchaToken: String?,
|
||||
): LoginResult
|
||||
|
||||
/**
|
||||
* Log out the current user.
|
||||
*/
|
||||
|
|
|
@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRespon
|
|||
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.model.TwoFactorDataModel
|
||||
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
|
||||
|
@ -86,6 +88,12 @@ class AuthRepositoryImpl(
|
|||
) : AuthRepository {
|
||||
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow<Boolean>(false)
|
||||
|
||||
/**
|
||||
* The auth information to make the identity token request will need to be
|
||||
* cached to make the request again in the case of two-factor authentication.
|
||||
*/
|
||||
private var identityTokenAuthModel: IdentityTokenAuthModel? = null
|
||||
|
||||
/**
|
||||
* A scope intended for use when simply collecting multiple flows in order to combine them. The
|
||||
* use of [Dispatchers.Unconfined] allows for this to happen synchronously whenever any of
|
||||
|
@ -93,7 +101,7 @@ class AuthRepositoryImpl(
|
|||
*/
|
||||
private val collectionScope = CoroutineScope(dispatcherManager.unconfined)
|
||||
|
||||
override var twoFactorData: TwoFactorRequired? = null
|
||||
override var twoFactorResponse: TwoFactorRequired? = null
|
||||
|
||||
override val activeUserId: String? get() = authDiskSource.userState?.activeUserId
|
||||
|
||||
|
@ -180,7 +188,6 @@ class AuthRepositoryImpl(
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
password: String,
|
||||
|
@ -195,10 +202,10 @@ class AuthRepositoryImpl(
|
|||
purpose = HashPurpose.SERVER_AUTHORIZATION,
|
||||
)
|
||||
}
|
||||
.flatMap { passwordHash ->
|
||||
identityService.getToken(
|
||||
uniqueAppId = authDiskSource.uniqueAppId,
|
||||
.map { passwordHash ->
|
||||
loginCommon(
|
||||
email = email,
|
||||
password = password,
|
||||
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||
username = email,
|
||||
password = passwordHash,
|
||||
|
@ -206,13 +213,53 @@ class AuthRepositoryImpl(
|
|||
captchaToken = captchaToken,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onFailure = { LoginResult.Error(errorMessage = null) },
|
||||
onSuccess = { it },
|
||||
)
|
||||
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
password: String?,
|
||||
twoFactorData: TwoFactorDataModel,
|
||||
captchaToken: String?,
|
||||
): LoginResult = identityTokenAuthModel?.let {
|
||||
loginCommon(
|
||||
email = email,
|
||||
password = password,
|
||||
authModel = it,
|
||||
twoFactorData = twoFactorData,
|
||||
captchaToken = captchaToken ?: twoFactorResponse?.captchaToken,
|
||||
)
|
||||
} ?: LoginResult.Error(errorMessage = null)
|
||||
|
||||
/**
|
||||
* A helper function to extract the common logic of logging in through
|
||||
* any of the available methods.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
private suspend fun loginCommon(
|
||||
email: String,
|
||||
password: String? = null,
|
||||
authModel: IdentityTokenAuthModel,
|
||||
twoFactorData: TwoFactorDataModel? = null,
|
||||
captchaToken: String?,
|
||||
): LoginResult = identityService
|
||||
.getToken(
|
||||
uniqueAppId = authDiskSource.uniqueAppId,
|
||||
email = email,
|
||||
authModel = authModel,
|
||||
twoFactorData = twoFactorData ?: getRememberedTwoFactorData(email),
|
||||
captchaToken = captchaToken,
|
||||
)
|
||||
.fold(
|
||||
onFailure = { LoginResult.Error(errorMessage = null) },
|
||||
onSuccess = { loginResponse ->
|
||||
when (loginResponse) {
|
||||
is CaptchaRequired -> LoginResult.CaptchaRequired(loginResponse.captchaKey)
|
||||
is TwoFactorRequired -> {
|
||||
twoFactorData = loginResponse
|
||||
identityTokenAuthModel = authModel
|
||||
twoFactorResponse = loginResponse
|
||||
LoginResult.TwoFactorRequired
|
||||
}
|
||||
|
||||
|
@ -223,18 +270,38 @@ class AuthRepositoryImpl(
|
|||
.environment
|
||||
.environmentUrlData,
|
||||
)
|
||||
vaultRepository.clearUnlockedData()
|
||||
vaultRepository.unlockVault(
|
||||
userId = userStateJson.activeUserId,
|
||||
email = userStateJson.activeAccount.profile.email,
|
||||
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
||||
userKey = loginResponse.key,
|
||||
privateKey = loginResponse.privateKey,
|
||||
masterPassword = password,
|
||||
// We can separately unlock the vault for organization data after
|
||||
// receiving the sync response if this data is currently absent.
|
||||
organizationKeys = null,
|
||||
)
|
||||
|
||||
// If the user just authenticated with a two-factor code and selected
|
||||
// the option to remember it, then the API response will return a token
|
||||
// that will be used in place of the two-factor code on the next login
|
||||
// attempt.
|
||||
loginResponse.twoFactorToken?.let {
|
||||
authDiskSource.storeTwoFactorToken(
|
||||
email = email,
|
||||
twoFactorToken = it,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove any cached data after successfully logging in.
|
||||
identityTokenAuthModel = null
|
||||
twoFactorResponse = null
|
||||
|
||||
// Attempt to unlock the vault if possible.
|
||||
password?.let {
|
||||
vaultRepository.clearUnlockedData()
|
||||
vaultRepository.unlockVault(
|
||||
userId = userStateJson.activeUserId,
|
||||
email = userStateJson.activeAccount.profile.email,
|
||||
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
||||
userKey = loginResponse.key,
|
||||
privateKey = loginResponse.privateKey,
|
||||
masterPassword = it,
|
||||
// We can separately unlock the vault for organization data after
|
||||
// receiving the sync response if this data is currently absent.
|
||||
organizationKeys = null,
|
||||
)
|
||||
}
|
||||
|
||||
authDiskSource.userState = userStateJson
|
||||
authDiskSource.storeUserKey(
|
||||
userId = userStateJson.activeUserId,
|
||||
|
@ -523,6 +590,18 @@ class AuthRepositoryImpl(
|
|||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the remembered two-factor token associated with the user's email, if applicable.
|
||||
*/
|
||||
private fun getRememberedTwoFactorData(email: String): TwoFactorDataModel? =
|
||||
authDiskSource.getTwoFactorToken(email = email)?.let { twoFactorToken ->
|
||||
TwoFactorDataModel(
|
||||
code = twoFactorToken,
|
||||
method = TwoFactorAuthMethod.REMEMBER.value.toString(),
|
||||
remember = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getVaultUnlockType(
|
||||
userId: String,
|
||||
): VaultUnlockType =
|
||||
|
|
|
@ -74,7 +74,12 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
|
|||
emailAddress = emailAddress,
|
||||
)
|
||||
},
|
||||
onNavigateToTwoFactorLogin = { navController.navigateToTwoFactorLogin() },
|
||||
onNavigateToTwoFactorLogin = { emailAddress, password ->
|
||||
navController.navigateToTwoFactorLogin(
|
||||
emailAddress = emailAddress,
|
||||
password = password,
|
||||
)
|
||||
},
|
||||
)
|
||||
loginWithDeviceDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
|
|
|
@ -46,7 +46,7 @@ fun NavGraphBuilder.loginDestination(
|
|||
onNavigateToMasterPasswordHint: (emailAddress: String) -> Unit,
|
||||
onNavigateToEnterpriseSignOn: () -> Unit,
|
||||
onNavigateToLoginWithDevice: (emailAddress: String) -> Unit,
|
||||
onNavigateToTwoFactorLogin: () -> Unit,
|
||||
onNavigateToTwoFactorLogin: (emailAddress: String, password: String?) -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
route = LOGIN_ROUTE,
|
||||
|
|
|
@ -67,7 +67,7 @@ fun LoginScreen(
|
|||
onNavigateToMasterPasswordHint: (String) -> Unit,
|
||||
onNavigateToEnterpriseSignOn: () -> Unit,
|
||||
onNavigateToLoginWithDevice: (emailAddress: String) -> Unit,
|
||||
onNavigateToTwoFactorLogin: () -> Unit,
|
||||
onNavigateToTwoFactorLogin: (String, String?) -> Unit,
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
) {
|
||||
|
@ -89,7 +89,10 @@ fun LoginScreen(
|
|||
onNavigateToLoginWithDevice(event.emailAddress)
|
||||
}
|
||||
|
||||
LoginEvent.NavigateToTwoFactorLogin -> onNavigateToTwoFactorLogin()
|
||||
is LoginEvent.NavigateToTwoFactorLogin -> {
|
||||
onNavigateToTwoFactorLogin(event.emailAddress, event.password)
|
||||
}
|
||||
|
||||
is LoginEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
|
|
@ -160,7 +160,12 @@ class LoginViewModel @Inject constructor(
|
|||
|
||||
is LoginResult.TwoFactorRequired -> {
|
||||
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
|
||||
sendEvent(LoginEvent.NavigateToTwoFactorLogin)
|
||||
sendEvent(
|
||||
LoginEvent.NavigateToTwoFactorLogin(
|
||||
emailAddress = state.emailAddress,
|
||||
password = state.passwordInput,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is LoginResult.Error -> {
|
||||
|
@ -317,7 +322,10 @@ sealed class LoginEvent {
|
|||
/**
|
||||
* Navigates to the two-factor login screen.
|
||||
*/
|
||||
data object NavigateToTwoFactorLogin : LoginEvent()
|
||||
data class NavigateToTwoFactorLogin(
|
||||
val emailAddress: String,
|
||||
val password: String?,
|
||||
) : LoginEvent()
|
||||
|
||||
/**
|
||||
* Shows a toast with the given [message].
|
||||
|
|
|
@ -1,17 +1,38 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
|
||||
private const val TWO_FACTOR_LOGIN_ROUTE = "two_factor_login"
|
||||
private const val EMAIL_ADDRESS = "email_address"
|
||||
private const val PASSWORD = "password"
|
||||
private const val TWO_FACTOR_LOGIN_PREFIX = "two_factor_login"
|
||||
private const val TWO_FACTOR_LOGIN_ROUTE =
|
||||
"$TWO_FACTOR_LOGIN_PREFIX/{${EMAIL_ADDRESS}}?$PASSWORD={$PASSWORD}"
|
||||
|
||||
/**
|
||||
* Class to retrieve Two-Factor Login arguments from the [SavedStateHandle].
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
data class TwoFactorLoginArgs(val emailAddress: String, val password: String?) {
|
||||
constructor(savedStateHandle: SavedStateHandle) : this(
|
||||
emailAddress = checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
|
||||
password = savedStateHandle[PASSWORD],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Two-Factor Login screen.
|
||||
*/
|
||||
fun NavController.navigateToTwoFactorLogin(navOptions: NavOptions? = null) {
|
||||
this.navigate(TWO_FACTOR_LOGIN_ROUTE, navOptions)
|
||||
fun NavController.navigateToTwoFactorLogin(
|
||||
emailAddress: String,
|
||||
password: String?,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate("$TWO_FACTOR_LOGIN_PREFIX/$emailAddress?$PASSWORD=$password", navOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -38,13 +38,18 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMetho
|
|||
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.description
|
||||
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.title
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
|
||||
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.LoadingDialogState
|
||||
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
|
||||
|
@ -71,12 +76,40 @@ fun TwoFactorLoginScreen(
|
|||
intentManager.launchUri("https://bitwarden.com/help/lost-two-step-device".toUri())
|
||||
}
|
||||
|
||||
is TwoFactorLoginEvent.NavigateToCaptcha -> {
|
||||
intentManager.startCustomTabsActivity(uri = event.uri)
|
||||
}
|
||||
|
||||
is TwoFactorLoginEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val dialog = state.dialogState) {
|
||||
is TwoFactorLoginState.DialogState.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
title = dialog.title ?: R.string.an_error_has_occurred.asText(),
|
||||
message = dialog.message,
|
||||
),
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(TwoFactorLoginAction.DialogDismiss) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is TwoFactorLoginState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(
|
||||
visibilityState = LoadingDialogState.Shown(
|
||||
text = dialog.message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
|
@ -135,6 +168,7 @@ fun TwoFactorLoginScreen(
|
|||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun TwoFactorLoginScreenContent(
|
||||
state: TwoFactorLoginState,
|
||||
onCodeInputChange: (String) -> Unit,
|
||||
|
|
|
@ -1,18 +1,27 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
|
||||
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.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -28,12 +37,16 @@ class TwoFactorLoginViewModel @Inject constructor(
|
|||
) : BaseViewModel<TwoFactorLoginState, TwoFactorLoginEvent, TwoFactorLoginAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: TwoFactorLoginState(
|
||||
authMethod = authRepository.twoFactorData.preferredAuthMethod,
|
||||
availableAuthMethods = authRepository.twoFactorData.availableAuthMethods,
|
||||
authMethod = authRepository.twoFactorResponse.preferredAuthMethod,
|
||||
availableAuthMethods = authRepository.twoFactorResponse.availableAuthMethods,
|
||||
codeInput = "",
|
||||
displayEmail = authRepository.twoFactorData.twoFactorDisplayEmail,
|
||||
displayEmail = authRepository.twoFactorResponse.twoFactorDisplayEmail,
|
||||
dialogState = null,
|
||||
isContinueButtonEnabled = false,
|
||||
isRememberMeEnabled = false,
|
||||
captchaToken = null,
|
||||
email = TwoFactorLoginArgs(savedStateHandle).emailAddress,
|
||||
password = TwoFactorLoginArgs(savedStateHandle).password,
|
||||
),
|
||||
) {
|
||||
init {
|
||||
|
@ -41,6 +54,18 @@ class TwoFactorLoginViewModel @Inject constructor(
|
|||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
// Automatically attempt to login again if a captcha token is received.
|
||||
authRepository
|
||||
.captchaTokenResultFlow
|
||||
.onEach {
|
||||
sendAction(
|
||||
TwoFactorLoginAction.Internal.ReceiveCaptchaToken(
|
||||
tokenResult = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: TwoFactorLoginAction) {
|
||||
|
@ -48,9 +73,38 @@ class TwoFactorLoginViewModel @Inject constructor(
|
|||
TwoFactorLoginAction.CloseButtonClick -> handleCloseButtonClicked()
|
||||
is TwoFactorLoginAction.CodeInputChanged -> handleCodeInputChanged(action)
|
||||
TwoFactorLoginAction.ContinueButtonClick -> handleContinueButtonClick()
|
||||
TwoFactorLoginAction.DialogDismiss -> handleDialogDismiss()
|
||||
is TwoFactorLoginAction.RememberMeToggle -> handleRememberMeToggle(action)
|
||||
TwoFactorLoginAction.ResendEmailClick -> handleResendEmailClick()
|
||||
is TwoFactorLoginAction.SelectAuthMethod -> handleSelectAuthMethod(action)
|
||||
|
||||
is TwoFactorLoginAction.Internal.ReceiveCaptchaToken -> {
|
||||
handleCaptchaTokenReceived(action.tokenResult)
|
||||
}
|
||||
|
||||
is TwoFactorLoginAction.Internal.ReceiveLoginResult -> handleReceiveLoginResult(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCaptchaTokenReceived(tokenResult: CaptchaCallbackTokenResult) {
|
||||
when (tokenResult) {
|
||||
CaptchaCallbackTokenResult.MissingToken -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Error(
|
||||
title = R.string.log_in_denied.asText(),
|
||||
message = R.string.captcha_failed.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is CaptchaCallbackTokenResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(captchaToken = tokenResult.token)
|
||||
}
|
||||
handleContinueButtonClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,8 +124,42 @@ class TwoFactorLoginViewModel @Inject constructor(
|
|||
* Verify the input and attempt to authenticate with the code.
|
||||
*/
|
||||
private fun handleContinueButtonClick() {
|
||||
// TODO: Finish implementation (BIT-918)
|
||||
sendEvent(TwoFactorLoginEvent.ShowToast("Not yet implemented"))
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// If the user is manually entering a code, remove any white spaces, just in case.
|
||||
val code = mutableStateFlow.value.codeInput.let { rawCode ->
|
||||
if (mutableStateFlow.value.authMethod == TwoFactorAuthMethod.AUTHENTICATOR_APP ||
|
||||
mutableStateFlow.value.authMethod == TwoFactorAuthMethod.EMAIL
|
||||
) {
|
||||
rawCode.replace(" ", "")
|
||||
} else {
|
||||
rawCode
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.login(
|
||||
email = mutableStateFlow.value.email,
|
||||
password = mutableStateFlow.value.password,
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = code,
|
||||
method = mutableStateFlow.value.authMethod.value.toString(),
|
||||
remember = mutableStateFlow.value.isRememberMeEnabled,
|
||||
),
|
||||
captchaToken = mutableStateFlow.value.captchaToken,
|
||||
)
|
||||
sendAction(
|
||||
TwoFactorLoginAction.Internal.ReceiveLoginResult(
|
||||
loginResult = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -81,6 +169,50 @@ class TwoFactorLoginViewModel @Inject constructor(
|
|||
sendEvent(TwoFactorLoginEvent.NavigateBack)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the dialog.
|
||||
*/
|
||||
private fun handleDialogDismiss() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the login result.
|
||||
*/
|
||||
private fun handleReceiveLoginResult(action: TwoFactorLoginAction.Internal.ReceiveLoginResult) {
|
||||
// Dismiss the loading overlay.
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
|
||||
when (val loginResult = action.loginResult) {
|
||||
// Launch the captcha flow if necessary.
|
||||
is LoginResult.CaptchaRequired -> {
|
||||
sendEvent(
|
||||
event = TwoFactorLoginEvent.NavigateToCaptcha(
|
||||
uri = generateUriForCaptcha(captchaId = loginResult.captchaId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// NO-OP: This error shouldn't be possible at this stage.
|
||||
is LoginResult.TwoFactorRequired -> Unit
|
||||
|
||||
// Display any error with the same invalid verification code message.
|
||||
is LoginResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.invalid_verification_code.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NO-OP: Let the auth flow handle navigation after this.
|
||||
is LoginResult.Success -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the state with the new toggle value.
|
||||
*/
|
||||
|
@ -124,10 +256,38 @@ data class TwoFactorLoginState(
|
|||
val authMethod: TwoFactorAuthMethod,
|
||||
val availableAuthMethods: List<TwoFactorAuthMethod>,
|
||||
val codeInput: String,
|
||||
val dialogState: DialogState?,
|
||||
val displayEmail: String,
|
||||
val isContinueButtonEnabled: Boolean,
|
||||
val isRememberMeEnabled: Boolean,
|
||||
) : Parcelable
|
||||
// Internal
|
||||
val captchaToken: String?,
|
||||
val email: String,
|
||||
val password: String?,
|
||||
) : Parcelable {
|
||||
/**
|
||||
* Represents the current state of any dialogs on the screen.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
/**
|
||||
* Represents an error dialog with the given [message] and optional [title]. It no title
|
||||
* is specified a default will be provided.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text? = null,
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
|
||||
/**
|
||||
* Represents a loading dialog with the given [message].
|
||||
*/
|
||||
@Parcelize
|
||||
data class Loading(
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events for the Two-Factor Login screen.
|
||||
|
@ -138,6 +298,11 @@ sealed class TwoFactorLoginEvent {
|
|||
*/
|
||||
data object NavigateBack : TwoFactorLoginEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the captcha verification screen.
|
||||
*/
|
||||
data class NavigateToCaptcha(val uri: Uri) : TwoFactorLoginEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the recovery code help page.
|
||||
*/
|
||||
|
@ -155,7 +320,6 @@ sealed class TwoFactorLoginEvent {
|
|||
* Models actions for the Two-Factor Login screen.
|
||||
*/
|
||||
sealed class TwoFactorLoginAction {
|
||||
|
||||
/**
|
||||
* Indicates that the top-bar close button was clicked.
|
||||
*/
|
||||
|
@ -173,6 +337,11 @@ sealed class TwoFactorLoginAction {
|
|||
*/
|
||||
data object ContinueButtonClick : TwoFactorLoginAction()
|
||||
|
||||
/**
|
||||
* Indicates that the dialog has been dismissed.
|
||||
*/
|
||||
data object DialogDismiss : TwoFactorLoginAction()
|
||||
|
||||
/**
|
||||
* Indicates that the Remember Me switch toggled.
|
||||
*/
|
||||
|
@ -191,4 +360,23 @@ sealed class TwoFactorLoginAction {
|
|||
data class SelectAuthMethod(
|
||||
val authMethod: TwoFactorAuthMethod,
|
||||
) : TwoFactorLoginAction()
|
||||
|
||||
/**
|
||||
* Models actions that the [TwoFactorLoginViewModel] itself might send.
|
||||
*/
|
||||
sealed class Internal : TwoFactorLoginAction() {
|
||||
/**
|
||||
* Indicates a captcha callback token has been received.
|
||||
*/
|
||||
data class ReceiveCaptchaToken(
|
||||
val tokenResult: CaptchaCallbackTokenResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates a login result has been received.
|
||||
*/
|
||||
data class ReceiveLoginResult(
|
||||
val loginResult: LoginResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -362,6 +362,44 @@ class AuthDiskSourceTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTwoFactorToken should pull from SharedPreferences`() {
|
||||
val twoFactorTokenBaseKey = "bwPreferencesStorage:twoFactorToken"
|
||||
val mockEmail = "mockUserId"
|
||||
val mockTwoFactorToken = "immaLilToken123"
|
||||
fakeSharedPreferences
|
||||
.edit {
|
||||
putString(
|
||||
"${twoFactorTokenBaseKey}_$mockEmail",
|
||||
mockTwoFactorToken,
|
||||
)
|
||||
}
|
||||
val actual = authDiskSource.getTwoFactorToken(email = mockEmail)
|
||||
assertEquals(
|
||||
mockTwoFactorToken,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storeTwoFactorToken should update SharedPreferences`() {
|
||||
val twoFactorTokenBaseKey = "bwPreferencesStorage:twoFactorToken"
|
||||
val mockEmail = "mockUserId"
|
||||
val mockTwoFactorToken = "immaLilToken123"
|
||||
authDiskSource.storeTwoFactorToken(
|
||||
email = mockEmail,
|
||||
twoFactorToken = mockTwoFactorToken,
|
||||
)
|
||||
val actual = fakeSharedPreferences.getString(
|
||||
"${twoFactorTokenBaseKey}_$mockEmail",
|
||||
null,
|
||||
)
|
||||
assertEquals(
|
||||
mockTwoFactorToken,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getUserAutoUnlockKey should pull from SharedPreferences`() {
|
||||
val userAutoUnlockKeyBaseKey = "bwSecureStorage:userKeyAutoUnlock"
|
||||
|
|
|
@ -23,6 +23,7 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
private val storedInvalidUnlockAttempts = mutableMapOf<String, Int?>()
|
||||
private val storedUserKeys = mutableMapOf<String, String?>()
|
||||
private val storedPrivateKeys = mutableMapOf<String, String?>()
|
||||
private val storedTwoFactorTokens = mutableMapOf<String, String?>()
|
||||
private val storedUserAutoUnlockKeys = mutableMapOf<String, String?>()
|
||||
private val storedPinProtectedUserKeys = mutableMapOf<String, Pair<String?, Boolean>>()
|
||||
private val storedEncryptedPins = mutableMapOf<String, String?>()
|
||||
|
@ -44,6 +45,7 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
storedInvalidUnlockAttempts.remove(userId)
|
||||
storedUserKeys.remove(userId)
|
||||
storedPrivateKeys.remove(userId)
|
||||
storedTwoFactorTokens.clear()
|
||||
storedUserAutoUnlockKeys.remove(userId)
|
||||
storedPinProtectedUserKeys.remove(userId)
|
||||
storedEncryptedPins.remove(userId)
|
||||
|
@ -85,6 +87,12 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
storedPrivateKeys[userId] = privateKey
|
||||
}
|
||||
|
||||
override fun getTwoFactorToken(email: String): String? = storedTwoFactorTokens[email]
|
||||
|
||||
override fun storeTwoFactorToken(email: String, twoFactorToken: String?) {
|
||||
storedTwoFactorTokens[email] = twoFactorToken
|
||||
}
|
||||
|
||||
override fun getUserAutoUnlockKey(userId: String): String? =
|
||||
storedUserAutoUnlockKeys[userId]
|
||||
|
||||
|
@ -173,6 +181,13 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||
assertEquals(privateKey, storedPrivateKeys[userId])
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the [twoFactorToken] was stored successfully using the [email].
|
||||
*/
|
||||
fun assertTwoFactorToken(email: String, twoFactorToken: String?) {
|
||||
assertEquals(twoFactorToken, storedTwoFactorTokens[email])
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the [userAutoUnlockKey] was stored successfully using the [userId].
|
||||
*/
|
||||
|
|
|
@ -254,6 +254,7 @@ private val LOGIN_SUCCESS = GetTokenResponseJson.Success(
|
|||
privateKey = "privateKey",
|
||||
shouldForcePasswordReset = true,
|
||||
shouldResetMasterPassword = true,
|
||||
twoFactorToken = null,
|
||||
masterPasswordPolicyOptions = MasterPasswordPolicyOptionsJson(
|
||||
minimumComplexity = 10,
|
||||
minimumLength = 100,
|
||||
|
|
|
@ -21,6 +21,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenRespon
|
|||
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.model.TwoFactorDataModel
|
||||
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
|
||||
|
@ -736,7 +737,7 @@ class AuthRepositoryTest {
|
|||
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||
assertEquals(LoginResult.TwoFactorRequired, result)
|
||||
assertEquals(
|
||||
repository.twoFactorData,
|
||||
repository.twoFactorResponse,
|
||||
GetTokenResponseJson.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null),
|
||||
)
|
||||
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||
|
@ -754,6 +755,184 @@ class AuthRepositoryTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login two factor with remember saves two factor auth token`() = runTest {
|
||||
// Attempt a normal login with a two factor error first, so that the auth
|
||||
// data will be cached.
|
||||
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 firstResult = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||
assertEquals(LoginResult.TwoFactorRequired, firstResult)
|
||||
coVerify { accountsService.preLogin(email = EMAIL) }
|
||||
coVerify {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||
username = EMAIL,
|
||||
password = PASSWORD_HASH,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
}
|
||||
|
||||
// Login with two factor data.
|
||||
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
|
||||
twoFactorToken = "twoFactorTokenToStore",
|
||||
)
|
||||
coEvery {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||
username = EMAIL,
|
||||
password = PASSWORD_HASH,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
twoFactorData = TWO_FACTOR_DATA,
|
||||
)
|
||||
} returns Result.success(successResponse)
|
||||
coEvery {
|
||||
vaultRepository.unlockVault(
|
||||
userId = USER_ID_1,
|
||||
email = EMAIL,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
userKey = successResponse.key,
|
||||
privateKey = successResponse.privateKey,
|
||||
organizationKeys = null,
|
||||
masterPassword = PASSWORD,
|
||||
)
|
||||
} returns VaultUnlockResult.Success
|
||||
coEvery { vaultRepository.syncIfNecessary() } just runs
|
||||
every {
|
||||
successResponse.toUserState(
|
||||
previousUserState = null,
|
||||
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
|
||||
)
|
||||
} returns SINGLE_USER_STATE_1
|
||||
val finalResult = repository.login(
|
||||
email = EMAIL,
|
||||
password = PASSWORD,
|
||||
twoFactorData = TWO_FACTOR_DATA,
|
||||
captchaToken = null,
|
||||
)
|
||||
assertEquals(LoginResult.Success, finalResult)
|
||||
assertNull(repository.twoFactorResponse)
|
||||
fakeAuthDiskSource.assertTwoFactorToken(
|
||||
email = EMAIL,
|
||||
twoFactorToken = "twoFactorTokenToStore",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login uses remembered two factor tokens`() = runTest {
|
||||
fakeAuthDiskSource.storeTwoFactorToken(EMAIL, "storedTwoFactorToken")
|
||||
val rememberedTwoFactorData = TwoFactorDataModel(
|
||||
code = "storedTwoFactorToken",
|
||||
method = TwoFactorAuthMethod.REMEMBER.value.toString(),
|
||||
remember = false,
|
||||
)
|
||||
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
|
||||
coEvery {
|
||||
accountsService.preLogin(email = 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,
|
||||
twoFactorData = rememberedTwoFactorData,
|
||||
)
|
||||
} returns Result.success(successResponse)
|
||||
coEvery {
|
||||
vaultRepository.unlockVault(
|
||||
userId = USER_ID_1,
|
||||
email = EMAIL,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
userKey = successResponse.key,
|
||||
privateKey = successResponse.privateKey,
|
||||
organizationKeys = null,
|
||||
masterPassword = PASSWORD,
|
||||
)
|
||||
} returns VaultUnlockResult.Success
|
||||
coEvery { vaultRepository.syncIfNecessary() } just runs
|
||||
every {
|
||||
GET_TOKEN_RESPONSE_SUCCESS.toUserState(
|
||||
previousUserState = null,
|
||||
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
|
||||
)
|
||||
} returns SINGLE_USER_STATE_1
|
||||
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||
assertEquals(LoginResult.Success, result)
|
||||
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
|
||||
coVerify { accountsService.preLogin(email = EMAIL) }
|
||||
fakeAuthDiskSource.assertPrivateKey(
|
||||
userId = USER_ID_1,
|
||||
privateKey = "privateKey",
|
||||
)
|
||||
fakeAuthDiskSource.assertUserKey(
|
||||
userId = USER_ID_1,
|
||||
userKey = "key",
|
||||
)
|
||||
coVerify {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.MasterPassword(
|
||||
username = EMAIL,
|
||||
password = PASSWORD_HASH,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
twoFactorData = rememberedTwoFactorData,
|
||||
)
|
||||
vaultRepository.unlockVault(
|
||||
userId = USER_ID_1,
|
||||
email = EMAIL,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
userKey = successResponse.key,
|
||||
privateKey = successResponse.privateKey,
|
||||
organizationKeys = null,
|
||||
masterPassword = PASSWORD,
|
||||
)
|
||||
vaultRepository.syncIfNecessary()
|
||||
}
|
||||
assertEquals(
|
||||
SINGLE_USER_STATE_1,
|
||||
fakeAuthDiskSource.userState,
|
||||
)
|
||||
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
|
||||
verify { vaultRepository.clearUnlockedData() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login two factor returns error if no cached auth data`() = runTest {
|
||||
val result = repository.login(
|
||||
email = EMAIL,
|
||||
password = PASSWORD,
|
||||
twoFactorData = TWO_FACTOR_DATA,
|
||||
captchaToken = null,
|
||||
)
|
||||
assertEquals(LoginResult.Error(errorMessage = null), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register check data breaches error should still return register success`() = runTest {
|
||||
coEvery {
|
||||
|
@ -1557,6 +1736,14 @@ class AuthRepositoryTest {
|
|||
private const val REFRESH_TOKEN = "refreshToken"
|
||||
private const val REFRESH_TOKEN_2 = "refreshToken2"
|
||||
private const val CAPTCHA_KEY = "captcha"
|
||||
private const val TWO_FACTOR_CODE = "123456"
|
||||
private val TWO_FACTOR_METHOD = TwoFactorAuthMethod.EMAIL
|
||||
private const val TWO_FACTOR_REMEMBER = true
|
||||
private val TWO_FACTOR_DATA = TwoFactorDataModel(
|
||||
code = TWO_FACTOR_CODE,
|
||||
method = TWO_FACTOR_METHOD.value.toString(),
|
||||
remember = TWO_FACTOR_REMEMBER,
|
||||
)
|
||||
|
||||
private const val DEFAULT_KDF_ITERATIONS = 600000
|
||||
private const val ENCRYPTED_USER_KEY = "encryptedUserKey"
|
||||
|
@ -1591,6 +1778,7 @@ class AuthRepositoryTest {
|
|||
privateKey = "privateKey",
|
||||
shouldForcePasswordReset = true,
|
||||
shouldResetMasterPassword = true,
|
||||
twoFactorToken = null,
|
||||
masterPasswordPolicyOptions = null,
|
||||
userDecryptionOptions = null,
|
||||
)
|
||||
|
|
|
@ -81,6 +81,7 @@ private val GET_TOKEN_RESPONSE_SUCCESS = GetTokenResponseJson.Success(
|
|||
privateKey = "privateKey",
|
||||
shouldForcePasswordReset = true,
|
||||
shouldResetMasterPassword = true,
|
||||
twoFactorToken = null,
|
||||
masterPasswordPolicyOptions = null,
|
||||
userDecryptionOptions = null,
|
||||
)
|
||||
|
|
|
@ -64,7 +64,7 @@ class LoginScreenTest : BaseComposeTest() {
|
|||
onNavigateToMasterPasswordHint = { onNavigateToMasterPasswordHintCalled = true },
|
||||
onNavigateToEnterpriseSignOn = { onNavigateToEnterpriseSignOnCalled = true },
|
||||
onNavigateToLoginWithDevice = { onNavigateToLoginWithDeviceCalled = true },
|
||||
onNavigateToTwoFactorLogin = { onNavigateToTwoFactorLoginCalled = true },
|
||||
onNavigateToTwoFactorLogin = { _, _ -> onNavigateToTwoFactorLoginCalled = true },
|
||||
viewModel = viewModel,
|
||||
intentManager = intentManager,
|
||||
)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
|
@ -14,6 +15,7 @@ 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.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
@ -47,6 +49,22 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `basicDialog should update according to state`() {
|
||||
composeTestRule.onNodeWithText("Error message").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Error(
|
||||
title = null,
|
||||
message = "Error message".asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Error message").isDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close button click should send CloseButtonClick action`() {
|
||||
composeTestRule.onNodeWithContentDescription("Close").performClick()
|
||||
|
@ -102,6 +120,19 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
|
|||
composeTestRule.onNodeWithText(authAppDetails).isDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadingOverlay should update according to state`() {
|
||||
composeTestRule.onNodeWithText("Loading...").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Loading("Loading...".asText()),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Loading...").isDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remember me click should send RememberMeToggle action`() {
|
||||
composeTestRule.onNodeWithText("Remember me").performClick()
|
||||
|
@ -131,7 +162,7 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `resend email button visibility should should update according to state`() {
|
||||
fun `resend email button visibility should update according to state`() {
|
||||
val buttonText = "Send verification code email again"
|
||||
composeTestRule.onNodeWithText(buttonText).assertIsDisplayed()
|
||||
|
||||
|
@ -178,6 +209,13 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
|
|||
TestCase.assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToCaptcha should call intentManager startCustomTabsActivity`() {
|
||||
val mockUri = mockk<Uri>()
|
||||
mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToCaptcha(mockUri))
|
||||
verify { intentManager.startCustomTabsActivity(mockUri) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToRecoveryCode should launch the recovery code uri`() {
|
||||
mutableEventFlow.tryEmit(TwoFactorLoginEvent.NavigateToRecoveryCode)
|
||||
|
@ -185,13 +223,22 @@ class TwoFactorLoginScreenTest : BaseComposeTest() {
|
|||
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,
|
||||
)
|
||||
companion object {
|
||||
private val DEFAULT_STATE = TwoFactorLoginState(
|
||||
authMethod = TwoFactorAuthMethod.EMAIL,
|
||||
availableAuthMethods = listOf(
|
||||
TwoFactorAuthMethod.EMAIL,
|
||||
TwoFactorAuthMethod.RECOVERY_CODE,
|
||||
),
|
||||
codeInput = "",
|
||||
displayEmail = "ex***@email.com",
|
||||
dialogState = null,
|
||||
isContinueButtonEnabled = false,
|
||||
isRememberMeEnabled = false,
|
||||
captchaToken = null,
|
||||
email = "example@email.com",
|
||||
password = "password123",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,24 +1,52 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.twofactorlogin
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.R
|
||||
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.datasource.network.model.TwoFactorDataModel
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TwoFactorLoginViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mutableCaptchaTokenResultFlow =
|
||||
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
|
||||
private val authRepository: AuthRepository = mockk(relaxed = true) {
|
||||
every { twoFactorData } returns TWO_FACTOR_DATA
|
||||
every { twoFactorResponse } returns TWO_FACTOR_RESPONSE
|
||||
every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
|
||||
}
|
||||
|
||||
private val savedStateHandle = SavedStateHandle().also {
|
||||
it["email_address"] = "test@gmail.com"
|
||||
it["email_address"] = "example@email.com"
|
||||
it["password"] = "password123"
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkStatic(::generateUriForCaptcha)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(::generateUriForCaptcha)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -29,6 +57,36 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `captchaTokenFlow success update should trigger a login`() = runTest {
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = "example@email.com",
|
||||
password = "password123",
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = "",
|
||||
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
|
||||
remember = false,
|
||||
),
|
||||
captchaToken = "token",
|
||||
)
|
||||
} returns LoginResult.Success
|
||||
createViewModel()
|
||||
mutableCaptchaTokenResultFlow.tryEmit(CaptchaCallbackTokenResult.Success("token"))
|
||||
coVerify {
|
||||
authRepository.login(
|
||||
email = "example@email.com",
|
||||
password = "password123",
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = "",
|
||||
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
|
||||
remember = false,
|
||||
),
|
||||
captchaToken = "token",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CloseButtonClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
@ -86,6 +144,156 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContinueButtonClick login returns success should update loadingDialogState`() = runTest {
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = "example@email.com",
|
||||
password = "password123",
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = "",
|
||||
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
|
||||
remember = false,
|
||||
),
|
||||
captchaToken = null,
|
||||
)
|
||||
} returns LoginResult.Success
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
coVerify {
|
||||
authRepository.login(
|
||||
email = "example@email.com",
|
||||
password = "password123",
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = "",
|
||||
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
|
||||
remember = false,
|
||||
),
|
||||
captchaToken = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContinueButtonClick login returns CaptchaRequired should emit NavigateToCaptcha`() =
|
||||
runTest {
|
||||
val mockkUri = mockk<Uri>()
|
||||
every {
|
||||
generateUriForCaptcha(captchaId = "mock_captcha_id")
|
||||
} returns mockkUri
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = "example@email.com",
|
||||
password = "password123",
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = "",
|
||||
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
|
||||
remember = false,
|
||||
),
|
||||
captchaToken = null,
|
||||
)
|
||||
} returns LoginResult.CaptchaRequired(captchaId = "mock_captcha_id")
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(TwoFactorLoginAction.ContinueButtonClick)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
TwoFactorLoginEvent.NavigateToCaptcha(uri = mockkUri),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
coVerify {
|
||||
authRepository.login(
|
||||
email = "example@email.com",
|
||||
password = "password123",
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = "",
|
||||
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
|
||||
remember = false,
|
||||
),
|
||||
captchaToken = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContinueButtonClick login returns Error should update errorStateDialog`() = runTest {
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = "example@email.com",
|
||||
password = "password123",
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = "",
|
||||
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
|
||||
remember = false,
|
||||
),
|
||||
captchaToken = null,
|
||||
)
|
||||
} returns LoginResult.Error(errorMessage = null)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.invalid_verification_code.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(TwoFactorLoginAction.DialogDismiss)
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
coVerify {
|
||||
authRepository.login(
|
||||
email = "example@email.com",
|
||||
password = "password123",
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = "",
|
||||
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
|
||||
remember = false,
|
||||
),
|
||||
captchaToken = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RememberMeToggle should update the state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
@ -158,7 +366,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
|
|||
TwoFactorAuthMethod.EMAIL to mapOf("Email" to "ex***@email.com"),
|
||||
TwoFactorAuthMethod.AUTHENTICATOR_APP to mapOf("Email" to null),
|
||||
)
|
||||
private val TWO_FACTOR_DATA =
|
||||
private val TWO_FACTOR_RESPONSE =
|
||||
GetTokenResponseJson.TwoFactorRequired(
|
||||
TWO_FACTOR_AUTH_METHODS_DATA,
|
||||
null,
|
||||
|
@ -174,8 +382,12 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
|
|||
),
|
||||
codeInput = "",
|
||||
displayEmail = "ex***@email.com",
|
||||
dialogState = null,
|
||||
isContinueButtonEnabled = false,
|
||||
isRememberMeEnabled = false,
|
||||
captchaToken = null,
|
||||
email = "example@email.com",
|
||||
password = "password123",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue