[PM-21203] Old user migration login error. (#5136)

This commit is contained in:
André Bispo
2025-05-07 22:25:32 +01:00
committed by GitHub
parent eec88d4924
commit 5c8f5670e4
11 changed files with 473 additions and 3 deletions

View File

@@ -1613,7 +1613,7 @@ class AuthRepositoryImpl(
* A helper function to extract the common logic of logging in through
* any of the available methods.
*/
@Suppress("LongMethod")
@Suppress("LongMethod", "MaxLineLength")
private suspend fun loginCommon(
email: String,
password: String? = null,
@@ -1677,6 +1677,10 @@ class AuthRepositoryImpl(
error = loginResponse.errorMessage,
)
is GetTokenResponseJson.Invalid.InvalidType.EncryptionKeyMigrationRequired -> {
LoginResult.EncryptionKeyMigrationRequired
}
is GetTokenResponseJson.Invalid.InvalidType.GenericInvalid -> {
LoginResult.Error(
errorMessage = loginResponse.errorMessage,

View File

@@ -14,6 +14,11 @@ sealed class LoginResult {
*/
data class CaptchaRequired(val captchaId: String) : LoginResult()
/**
* Encryption key migration is required.
*/
data object EncryptionKeyMigrationRequired : LoginResult()
/**
* Two-factor verification is required.
*/

View File

@@ -5,6 +5,7 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.data.repository.util.baseIdentityUrl
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
@@ -22,6 +23,7 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.util.toUriOrNull
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.utils.generateRandomString
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@@ -154,6 +156,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
prevalidateSso()
}
@Suppress("LongMethod")
private fun handleOnLoginResult(action: EnterpriseSignOnAction.Internal.OnLoginResult) {
when (val loginResult = action.loginResult) {
is LoginResult.CaptchaRequired -> {
@@ -197,6 +200,20 @@ class EnterpriseSignOnViewModel @Inject constructor(
)
}
is LoginResult.EncryptionKeyMigrationRequired -> {
val vaultUrl =
environmentRepository
.environment
.environmentUrlData
.baseWebVaultUrlOrDefault
showError(
message = R.string
.this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden
.asText(vaultUrl.toUriOrNull()?.host ?: vaultUrl),
)
}
LoginResult.CertificateError -> {
showError(message = R.string.we_couldnt_verify_the_servers_certificate.asText())
}

View File

@@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
@@ -16,6 +17,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
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.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.util.toUriOrNull
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
@@ -37,7 +39,7 @@ private const val KEY_STATE = "state"
@HiltViewModel
class LoginViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
environmentRepository: EnvironmentRepository,
private val environmentRepository: EnvironmentRepository,
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
) : BaseViewModel<LoginState, LoginEvent, LoginAction>(
@@ -166,6 +168,25 @@ class LoginViewModel @Inject constructor(
)
}
is LoginResult.EncryptionKeyMigrationRequired -> {
val vaultUrl =
environmentRepository
.environment
.environmentUrlData
.baseWebVaultUrlOrDefault
mutableStateFlow.update {
it.copy(
dialogState = LoginState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string
.this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden
.asText(vaultUrl.toUriOrNull()?.host ?: vaultUrl),
),
)
}
}
is LoginResult.TwoFactorRequired -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(

View File

@@ -286,6 +286,7 @@ class LoginWithDeviceViewModel @Inject constructor(
// NO-OP: This result should not be possible here
is LoginResult.ConfirmKeyConnectorDomain -> Unit
LoginResult.EncryptionKeyMigrationRequired -> Unit
}
}

View File

@@ -282,7 +282,7 @@ class TwoFactorLoginViewModel @Inject constructor(
/**
* Handle the login result.
*/
@Suppress("MaxLineLength")
@Suppress("MaxLineLength", "LongMethod")
private fun handleReceiveLoginResult(action: TwoFactorLoginAction.Internal.ReceiveLoginResult) {
// Dismiss the loading overlay.
mutableStateFlow.update { it.copy(dialogState = null) }
@@ -353,6 +353,7 @@ class TwoFactorLoginViewModel @Inject constructor(
// NO-OP: This result should not be possible here
is LoginResult.ConfirmKeyConnectorDomain -> Unit
LoginResult.EncryptionKeyMigrationRequired -> Unit
}
}

View File

@@ -1265,4 +1265,5 @@ Do you want to switch to this account?</string>
<string name="unable_to_read_certificate">Unable to read certificate.</string>
<string name="cannot_delete_your_account">Cannot delete your account</string>
<string name="cannot_delete_your_account_explanation">This action cannot be completed because your account is owned by an organization. Contact your organization administrator for additional details.</string>
<string name="this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden">This account will soon be deleted. Log in at %1$s to continue using Bitwarden.</string>
</resources>

View File

@@ -2392,6 +2392,47 @@ class AuthRepositoryTest {
)
}
@Test
@Suppress("MaxLineLength")
fun `login get token returns invalid request should return EncryptionKeyMigrationRequired`() =
runTest {
coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess()
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns GetTokenResponseJson
.Invalid(
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
errorMessage =
"Encryption key migration is required. Please log in to the web vault at",
),
)
.asSuccess()
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.EncryptionKeyMigrationRequired, result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify {
identityService.preLogin(email = EMAIL)
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
}
}
@Test
fun `login with device get token fails should return Error with no message`() = runTest {
val error = Throwable("Fail!")

View File

@@ -4,6 +4,8 @@ import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.VerifiedOrganizationDomainSsoDetailsResponse
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
@@ -461,6 +463,231 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
}
}
@Test
@Suppress("MaxLineLength")
fun `ssoCallbackResultFlow Success with same state with login EncryptionKeyMigrationRequired should update dialogState with web vault url`() =
runTest {
val orgIdentifier = "Bitwarden"
coEvery {
authRepository.login(any(), any(), any(), any(), any(), any())
} returns LoginResult.EncryptionKeyMigrationRequired
environmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(
base = "",
webVault = "vault.bitwarden.com",
),
)
val viewModel = createViewModel(
ssoData = DEFAULT_SSO_DATA,
)
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(
EnterpriseSignOnAction.OrgIdentifierInputChange(orgIdentifier),
)
assertEquals(
DEFAULT_STATE.copy(
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult)
assertEquals(
DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
R.string.logging_in.asText(),
),
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden
.asText("vault.bitwarden.com"),
),
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
}
coVerify(exactly = 1) {
authRepository.login(
email = "test@gmail.com",
ssoCode = "lmn",
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
captchaToken = null,
organizationIdentifier = orgIdentifier,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `ssoCallbackResultFlow Success with same state with login EncryptionKeyMigrationRequired should update dialogState with base url`() =
runTest {
val orgIdentifier = "Bitwarden"
coEvery {
authRepository.login(any(), any(), any(), any(), any(), any())
} returns LoginResult.EncryptionKeyMigrationRequired
environmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(
base = "base.bitwarden.com",
webVault = "",
),
)
val viewModel = createViewModel(
ssoData = DEFAULT_SSO_DATA,
)
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(
EnterpriseSignOnAction.OrgIdentifierInputChange(orgIdentifier),
)
assertEquals(
DEFAULT_STATE.copy(
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult)
assertEquals(
DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
R.string.logging_in.asText(),
),
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden
.asText("base.bitwarden.com"),
),
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
}
coVerify(exactly = 1) {
authRepository.login(
email = "test@gmail.com",
ssoCode = "lmn",
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
captchaToken = null,
organizationIdentifier = orgIdentifier,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `ssoCallbackResultFlow Success with same state with login EncryptionKeyMigrationRequired should update dialogState with default url`() =
runTest {
val orgIdentifier = "Bitwarden"
coEvery {
authRepository.login(any(), any(), any(), any(), any(), any())
} returns LoginResult.EncryptionKeyMigrationRequired
environmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(
base = "",
webVault = "",
),
)
val viewModel = createViewModel(
ssoData = DEFAULT_SSO_DATA,
)
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(
EnterpriseSignOnAction.OrgIdentifierInputChange(orgIdentifier),
)
assertEquals(
DEFAULT_STATE.copy(
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult)
assertEquals(
DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
R.string.logging_in.asText(),
),
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden
.asText("vault.bitwarden.com"),
),
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
}
coVerify(exactly = 1) {
authRepository.login(
email = "test@gmail.com",
ssoCode = "lmn",
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
captchaToken = null,
organizationIdentifier = orgIdentifier,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `ssoCallbackResultFlow Success with same state with login CertificateError should show loading dialog then show certificate error dialog`() =

View File

@@ -38,6 +38,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class LoginViewModelTest : BaseViewModelTest() {
private val mutableCaptchaTokenResultFlow =
@@ -333,6 +334,146 @@ class LoginViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `LoginButtonClick login returns EncryptionKeyMigrationRequired should update errorDialogState with webVault url`() =
runTest {
coEvery {
authRepository.login(
email = EMAIL,
password = "",
captchaToken = null,
)
} returns LoginResult.EncryptionKeyMigrationRequired
fakeEnvironmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(
base = "",
webVault = "vault.bitwarden.com",
),
)
val viewModel = createViewModel()
val defaultSelfHostedState = DEFAULT_STATE.copy(environmentLabel = "")
viewModel.stateFlow.test {
assertEquals(defaultSelfHostedState, awaitItem())
viewModel.trySendAction(LoginAction.LoginButtonClick)
assertEquals(
defaultSelfHostedState.copy(
environmentLabel = "",
dialogState = LoginState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
awaitItem(),
)
assertEquals(
defaultSelfHostedState.copy(
environmentLabel = "",
dialogState = LoginState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden
.asText("vault.bitwarden.com"),
),
),
awaitItem(),
)
}
coVerify {
authRepository.login(email = EMAIL, password = "", captchaToken = null)
}
}
@Suppress("MaxLineLength")
@Test
fun `LoginButtonClick login returns EncryptionKeyMigrationRequired should update errorDialogState with base url`() =
runTest {
coEvery {
authRepository.login(
email = EMAIL,
password = "",
captchaToken = null,
)
} returns LoginResult.EncryptionKeyMigrationRequired
fakeEnvironmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(
base = "base.bitwarden.com",
webVault = "",
),
)
val viewModel = createViewModel()
val defaultSelfHostedState = DEFAULT_STATE.copy(environmentLabel = "")
viewModel.stateFlow.test {
assertEquals(defaultSelfHostedState, awaitItem())
viewModel.trySendAction(LoginAction.LoginButtonClick)
assertEquals(
defaultSelfHostedState.copy(
dialogState = LoginState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
awaitItem(),
)
assertEquals(
defaultSelfHostedState.copy(
dialogState = LoginState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden
.asText("base.bitwarden.com"),
),
),
awaitItem(),
)
}
coVerify {
authRepository.login(email = EMAIL, password = "", captchaToken = null)
}
}
@Suppress("MaxLineLength")
@Test
fun `LoginButtonClick login returns EncryptionKeyMigrationRequired should update errorDialogState with default url`() =
runTest {
coEvery {
authRepository.login(
email = EMAIL,
password = "",
captchaToken = null,
)
} returns LoginResult.EncryptionKeyMigrationRequired
fakeEnvironmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(
base = "",
webVault = "",
),
)
val viewModel = createViewModel()
val defaultSelfHostedState = DEFAULT_STATE.copy(environmentLabel = "")
viewModel.stateFlow.test {
assertEquals(defaultSelfHostedState, awaitItem())
viewModel.trySendAction(LoginAction.LoginButtonClick)
assertEquals(
defaultSelfHostedState.copy(
dialogState = LoginState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
awaitItem(),
)
assertEquals(
defaultSelfHostedState.copy(
dialogState = LoginState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden
.asText("vault.bitwarden.com"),
),
),
awaitItem(),
)
}
coVerify {
authRepository.login(email = EMAIL, password = "", captchaToken = null)
}
}
@Suppress("MaxLineLength")
@Test
fun `LoginButtonClick login returns CertificateError should update errorDialogState`() =

View File

@@ -119,6 +119,11 @@ sealed class GetTokenResponseJson {
*/
data object NewDeviceVerification : InvalidType()
/**
* Represents an invalid response indicating that a new device verification is required.
*/
data object EncryptionKeyMigrationRequired : InvalidType()
/**
* Represents generic invalid response
*/
@@ -128,6 +133,12 @@ sealed class GetTokenResponseJson {
val invalidType: InvalidType
get() = if (errorMessage?.lowercase() == "new device verification required") {
InvalidType.NewDeviceVerification
} else if (errorMessage
?.lowercase()
?.contains(
"encryption key migration is required. please log in to the web vault at",
) == true) {
InvalidType.EncryptionKeyMigrationRequired
} else {
InvalidType.GenericInvalid
}