[PM-13396] Show error when logging into an unofficial Bitwarden server (#4088)

This commit is contained in:
Patrick Honkonen 2024-10-17 11:11:13 -04:00 committed by GitHub
parent a9b6f296d8
commit 5faa30e2f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 396 additions and 36 deletions

View file

@ -94,6 +94,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
@ -153,6 +154,7 @@ class AuthRepositoryImpl(
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource,
private val configDiskSource: ConfigDiskSource,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
@ -1472,7 +1474,12 @@ class AuthRepositoryImpl(
captchaToken = captchaToken,
)
.fold(
onFailure = { LoginResult.Error(errorMessage = null) },
onFailure = {
when (configDiskSource.serverConfig?.isOfficialBitwardenServer) {
false -> LoginResult.UnofficialServerError
else -> LoginResult.Error(errorMessage = null)
}
},
onSuccess = { loginResponse ->
when (loginResponse) {
is GetTokenResponseJson.CaptchaRequired -> LoginResult.CaptchaRequired(

View file

@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
@ -45,6 +46,7 @@ object AuthRepositoryModule {
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
configDiskSource: ConfigDiskSource,
dispatcherManager: DispatcherManager,
environmentRepository: EnvironmentRepository,
settingsRepository: SettingsRepository,
@ -64,6 +66,7 @@ object AuthRepositoryModule {
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
configDiskSource = configDiskSource,
haveIBeenPwnedService = haveIBeenPwnedService,
dispatcherManager = dispatcherManager,
environmentRepository = environmentRepository,

View file

@ -23,4 +23,9 @@ sealed class LoginResult {
* There was an error logging in.
*/
data class Error(val errorMessage: String?) : LoginResult()
/**
* There was an error while logging into an unofficial Bitwarden server.
*/
data object UnofficialServerError : LoginResult()
}

View file

@ -19,4 +19,10 @@ data class ServerConfig(
@SerialName("serverData")
val serverData: ConfigResponseJson,
)
) {
/**
* Whether the server is an official Bitwarden server or not.
*/
val isOfficialBitwardenServer: Boolean
get() = serverData.server == null
}

View file

@ -138,6 +138,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
prevalidateSso()
}
@Suppress("MaxLineLength")
private fun handleOnLoginResult(action: EnterpriseSignOnAction.Internal.OnLoginResult) {
when (val loginResult = action.loginResult) {
is LoginResult.CaptchaRequired -> {
@ -160,6 +161,17 @@ class EnterpriseSignOnViewModel @Inject constructor(
}
}
is LoginResult.UnofficialServerError -> {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = R.string.this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server
.asText(),
),
)
}
}
is LoginResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
authRepository.rememberedOrgIdentifier = state.orgIdentifierInput

View file

@ -143,6 +143,7 @@ class LoginViewModel @Inject constructor(
}
}
@Suppress("MaxLineLength")
private fun handleReceiveLoginResult(action: LoginAction.Internal.ReceiveLoginResult) {
when (val loginResult = action.loginResult) {
is LoginResult.CaptchaRequired -> {
@ -176,6 +177,17 @@ class LoginViewModel @Inject constructor(
}
}
LoginResult.UnofficialServerError -> {
mutableStateFlow.update {
it.copy(
dialogState = LoginState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server.asText(),
),
)
}
}
is LoginResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
}

View file

@ -225,6 +225,7 @@ class LoginWithDeviceViewModel @Inject constructor(
}
}
@Suppress("MaxLineLength")
private fun handleReceiveLoginResult(
action: LoginWithDeviceAction.Internal.ReceiveLoginResult,
) {
@ -261,6 +262,18 @@ class LoginWithDeviceViewModel @Inject constructor(
}
}
is LoginResult.UnofficialServerError -> {
mutableStateFlow.update {
it.copy(
dialogState = LoginWithDeviceState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server
.asText(),
),
)
}
}
is LoginResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
}

View file

@ -278,6 +278,7 @@ class TwoFactorLoginViewModel @Inject constructor(
/**
* Handle the login result.
*/
@Suppress("MaxLineLength")
private fun handleReceiveLoginResult(action: TwoFactorLoginAction.Internal.ReceiveLoginResult) {
// Dismiss the loading overlay.
mutableStateFlow.update { it.copy(dialogState = null) }
@ -308,6 +309,18 @@ class TwoFactorLoginViewModel @Inject constructor(
}
}
is LoginResult.UnofficialServerError -> {
mutableStateFlow.update {
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server
.asText(),
),
)
}
}
// NO-OP: Let the auth flow handle navigation after this.
is LoginResult.Success -> Unit
}

View file

@ -1058,4 +1058,5 @@ Do you want to switch to this account?</string>
<string name="import_help_highlight">import help</string>
<string name="save_the_exported_file_somewhere_on_your_computer_you_can_find_easily">Save the exported file somewhere on your computer you can find easily.</string>
<string name="save_the_exported_file_highlight">Save the exported file</string>
<string name="this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server">This is not a recognized Bitwarden server. You may need to check with your provider or update your server.</string>
</resources>

View file

@ -98,6 +98,9 @@ import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
@ -208,6 +211,7 @@ class AuthRepositoryTest {
)
.asSuccess()
}
private val configDiskSource = FakeConfigDiskSource()
private val vaultSdkSource = mockk<VaultSdkSource> {
coEvery {
getAuthRequestKey(
@ -249,6 +253,7 @@ class AuthRepositoryTest {
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = fakeAuthDiskSource,
configDiskSource = configDiskSource,
environmentRepository = fakeEnvironmentRepository,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
@ -1430,8 +1435,10 @@ class AuthRepositoryTest {
coVerify { identityService.preLogin(email = EMAIL) }
}
@Suppress("MaxLineLength")
@Test
fun `login get token fails should return Error with no message`() = runTest {
fun `login get token fails should return Error with no message when server is an official Bitwarden server`() =
runTest {
coEvery {
identityService.preLogin(email = EMAIL)
} returns PRE_LOGIN_SUCCESS.asSuccess()
@ -1463,6 +1470,31 @@ class AuthRepositoryTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `login get token fails should return UnofficialServerError when server is an unofficial Bitwarden server`() =
runTest {
configDiskSource.serverConfig = SERVER_CONFIG_UNOFFICIAL
coEvery {
identityService.preLogin(email = 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 RuntimeException().asFailure()
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.UnofficialServerError, result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify { identityService.preLogin(email = EMAIL) }
}
@Test
fun `login get token returns Invalid should return Error with correct message`() = runTest {
coEvery {
@ -6515,5 +6547,34 @@ class AuthRepositoryTest {
private val FIRST_TIME_STATE = UserState.FirstTimeState(
showImportLoginsCard = true,
)
private val SERVER_CONFIG_DEFAULT = ServerConfig(
lastSync = 0L,
serverData = ConfigResponseJson(
type = "mockType",
version = "mockVersion",
gitHash = "mockGitHash",
server = null,
environment = ConfigResponseJson.EnvironmentJson(
cloudRegion = "mockCloudRegion",
vaultUrl = "mockVaultUrl",
apiUrl = "mockApiUrl",
identityUrl = "mockIdentityUrl",
notificationsUrl = "mockNotificationsUrl",
ssoUrl = "mockSsoUrl",
),
featureStates = emptyMap(),
),
)
private val SERVER_CONFIG_UNOFFICIAL = SERVER_CONFIG_DEFAULT
.copy(
serverData = SERVER_CONFIG_DEFAULT.serverData.copy(
server = ConfigResponseJson.ServerJson(
name = "mockUnofficialServerName",
url = "mockUnofficialServerUrl",
),
),
)
}
}

View file

@ -304,7 +304,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `ssoCallbackResultFlow Success with same state with login Error should show loading dialog then show an error`() =
fun `ssoCallbackResultFlow Success with same state with login Error should show loading dialog then show an error when server is an official Bitwarden server`() =
runTest {
val orgIdentifier = "Bitwarden"
coEvery {
@ -368,6 +368,72 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `ssoCallbackResultFlow Success with same state with login UnofficialServerError should show loading dialog then show unofficial error when Bitwarden server is unofficial`() =
runTest {
val orgIdentifier = "Bitwarden"
coEvery {
authRepository.login(any(), any(), any(), any(), any(), any())
} returns LoginResult.UnofficialServerError
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(
message = R.string.this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server.asText(),
),
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 Success should show loading dialog, hide it, and save org identifier`() =

View file

@ -280,6 +280,44 @@ class LoginViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `LoginButtonClick login returns UnofficialServerError should update errorDialogState`() =
runTest {
coEvery {
authRepository.login(
email = EMAIL,
password = "",
captchaToken = null,
)
} returns LoginResult.UnofficialServerError
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(LoginAction.LoginButtonClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = LoginState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = LoginState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server.asText(),
),
),
awaitItem(),
)
}
coVerify {
authRepository.login(email = EMAIL, password = "", captchaToken = null)
}
}
@Test
fun `LoginButtonClick login returns success should update loadingDialogState`() = runTest {
coEvery {

View file

@ -362,6 +362,69 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `on createAuthRequestWithUpdates Success and login UnofficialServerError should should update the state`() =
runTest {
coEvery {
authRepository.login(
email = EMAIL,
requestId = DEFAULT_LOGIN_DATA.requestId,
accessCode = DEFAULT_LOGIN_DATA.accessCode,
asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey,
requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey,
masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash,
captchaToken = null,
)
} returns LoginResult.UnofficialServerError
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE),
)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
),
loginData = DEFAULT_LOGIN_DATA,
dialogState = LoginWithDeviceState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
),
dialogState = LoginWithDeviceState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server.asText(),
),
loginData = DEFAULT_LOGIN_DATA,
),
awaitItem(),
)
}
}
coVerify(exactly = 1) {
authRepository.login(
email = EMAIL,
requestId = AUTH_REQUEST.id,
accessCode = AUTH_REQUEST_RESPONSE.accessCode,
asymmetricalKey = requireNotNull(AUTH_REQUEST.key),
requestPrivateKey = AUTH_REQUEST_RESPONSE.privateKey,
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
captchaToken = null,
)
}
}
@Test
fun `on captchaTokenResultFlow missing token should should display error dialog`() = runTest {
val viewModel = createViewModel()

View file

@ -646,6 +646,66 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `ContinueButtonClick login returns UnofficialServerError should update dialogState`() =
runTest {
coEvery {
authRepository.login(
email = DEFAULT_EMAIL_ADDRESS,
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
remember = false,
),
captchaToken = null,
orgIdentifier = DEFAULT_ORG_IDENTIFIER,
)
} returns LoginResult.UnofficialServerError
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.this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server.asText(),
),
),
awaitItem(),
)
viewModel.trySendAction(TwoFactorLoginAction.DialogDismiss)
assertEquals(DEFAULT_STATE, awaitItem())
}
coVerify {
authRepository.login(
email = DEFAULT_EMAIL_ADDRESS,
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
remember = false,
),
captchaToken = null,
orgIdentifier = DEFAULT_ORG_IDENTIFIER,
)
}
}
@Test
fun `RememberMeToggle should update the state`() {
val viewModel = createViewModel()