BIT-189 Check for data breaches during create account (#154)

This commit is contained in:
Andrew Haisting 2023-10-24 11:36:11 -05:00 committed by Álison Fernandes
parent 2472648434
commit 8864315342
15 changed files with 577 additions and 84 deletions

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import okhttp3.ResponseBody
import retrofit2.http.GET
import retrofit2.http.Path
/**
* Defines endpoints for the "have I been pwned" API. For docs see
* https://haveibeenpwned.com/API/v2.
*/
interface HaveIBeenPwnedApi {
@GET("/range/{hashPrefix}")
suspend fun fetchBreachedPasswords(
@Path("hashPrefix")
hashPrefix: String,
): Result<ResponseBody>
}

View file

@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.di
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.di.NetworkModule
@ -35,4 +37,15 @@ object NetworkModule {
@Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit,
json: Json,
): IdentityService = IdentityServiceImpl(retrofit.create(), json)
@Provides
@Singleton
fun providesHaveIBeenPwnedService(
@Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit,
): HaveIBeenPwnedService = HaveIBeenPwnedServiceImpl(
retrofit.newBuilder()
.baseUrl("https://api.pwnedpasswords.com")
.build()
.create(),
)
}

View file

@ -0,0 +1,14 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
/**
* Defines methods for interacting with the have I been pwned API.
*/
interface HaveIBeenPwnedService {
/**
* Check to see if the given password has been breached. Returns true if breached.
*/
suspend fun hasPasswordBeenBreached(
password: String,
): Result<Boolean>
}

View file

@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.HaveIBeenPwnedApi
import java.security.MessageDigest
class HaveIBeenPwnedServiceImpl(private val api: HaveIBeenPwnedApi) : HaveIBeenPwnedService {
@Suppress("MagicNumber")
override suspend fun hasPasswordBeenBreached(password: String): Result<Boolean> {
// Hash the password:
val hashedPassword = MessageDigest
.getInstance("SHA-1")
.digest(password.toByteArray())
.joinToString(separator = "", transform = { "%02x".format(it) })
// Take just the prefix to send to the API:
val hashPrefix = hashedPassword.substring(0, 5)
return api
.fetchBreachedPasswords(hashPrefix = hashPrefix)
.mapCatching { responseBody ->
val allPwnedPasswords = responseBody.string()
// First split the response by newline: each hashed password is on a new line.
.split("\r\n")
.map { pwnedSuffix ->
// Then remove everything after the ":", since we only want the pwned hash:
// Before: 20d61603aba324bf08799896110561f05e1ad3be:12
// After: 20d61603aba324bf08799896110561f05e1ad3be
pwnedSuffix.substring(0, endIndex = pwnedSuffix.indexOf(":"))
}
// Then see if any of those passwords match our full password hash:
allPwnedPasswords.any { pwnedSuffix ->
(hashPrefix + pwnedSuffix).equals(hashedPassword, ignoreCase = true)
}
}
}
}

View file

@ -50,6 +50,7 @@ interface AuthRepository {
masterPassword: String,
masterPasswordHint: String?,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
): RegisterResult
/**

View file

@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs
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.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
@ -35,9 +36,11 @@ private const val DEFAULT_KDF_ITERATIONS = 600000
/**
* Default implementation of [AuthRepository].
*/
@Suppress("LongParameterList")
@Singleton
class AuthRepositoryImpl @Inject constructor(
private val accountsService: AccountsService,
private val haveIBeenPwnedService: HaveIBeenPwnedService,
private val identityService: IdentityService,
private val authSdkSource: AuthSdkSource,
private val authDiskSource: AuthDiskSource,
@ -143,12 +146,26 @@ class AuthRepositoryImpl @Inject constructor(
}
}
@Suppress("ReturnCount")
override suspend fun register(
email: String,
masterPassword: String,
masterPasswordHint: String?,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
): RegisterResult {
if (shouldCheckDataBreaches) {
haveIBeenPwnedService
.hasPasswordBeenBreached(password = masterPassword)
.fold(
onFailure = { return RegisterResult.Error(null) },
onSuccess = { foundDataBreaches ->
if (foundDataBreaches) {
return RegisterResult.DataBreachFound
}
},
)
}
val kdf = Kdf.Pbkdf2(DEFAULT_KDF_ITERATIONS.toUInt())
return authSdkSource
.makeRegisterKeys(

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.di
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
@ -22,9 +23,11 @@ object RepositoryModule {
@Provides
@Singleton
fun bindsAuthRepository(
@Suppress("LongParameterList")
fun providesAuthRepository(
accountsService: AccountsService,
identityService: IdentityService,
haveIBeenPwnedService: HaveIBeenPwnedService,
authSdkSource: AuthSdkSource,
authDiskSource: AuthDiskSource,
): AuthRepository = AuthRepositoryImpl(
@ -32,6 +35,7 @@ object RepositoryModule {
identityService = identityService,
authSdkSource = authSdkSource,
authDiskSource = authDiskSource,
haveIBeenPwnedService = haveIBeenPwnedService,
dispatcher = Dispatchers.IO,
)
}

View file

@ -24,4 +24,9 @@ sealed class RegisterResult {
* @param errorMessage a message describing the error.
*/
data class Error(val errorMessage: String?) : RegisterResult()
/**
* Password hash was found in a data breach.
*/
data object DataBreachFound : RegisterResult()
}

View file

@ -45,6 +45,7 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Acc
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ContinueWithBreachedPasswordClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ErrorDialogDismiss
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
@ -56,12 +57,15 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountEvent.Navi
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountEvent.NavigateToTerms
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButtonTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.theme.clickableSpanStyle
/**
@ -104,13 +108,49 @@ fun CreateAccountScreen(
}
}
}
BitwardenBasicDialog(
visibilityState = state.errorDialogState,
onDismissRequest = remember(viewModel) { { viewModel.trySendAction(ErrorDialogDismiss) } },
)
BitwardenLoadingDialog(
visibilityState = state.loadingDialogState,
)
val haveIBeenPwnedMessage = remember {
R.string.weak_password_identified_and_found_in_a_data_breach_alert_description.asText()
}
// Show dialog if needed:
when (val dialog = state.dialog) {
is CreateAccountDialog.Error -> {
BitwardenBasicDialog(
visibilityState = dialog.state,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
)
}
CreateAccountDialog.HaveIBeenPwned -> {
BitwardenTwoButtonDialog(
title = R.string.weak_and_exposed_master_password.asText(),
message = haveIBeenPwnedMessage,
confirmButtonText = R.string.yes.asText(),
dismissButtonText = R.string.no.asText(),
onConfirmClick = remember(viewModel) {
{ viewModel.trySendAction(ContinueWithBreachedPasswordClick) }
},
onDismissClick = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
)
}
CreateAccountDialog.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(R.string.create_account.asText()),
)
}
null -> Unit
}
Column(
modifier = Modifier
.fillMaxSize()

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ContinueWithBreachedPasswordClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange
@ -22,7 +23,6 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -51,8 +51,7 @@ class CreateAccountViewModel @Inject constructor(
passwordHintInput = "",
isAcceptPoliciesToggled = false,
isCheckDataBreachesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
dialog = null,
),
) {
@ -93,6 +92,8 @@ class CreateAccountViewModel @Inject constructor(
is CreateAccountAction.Internal.ReceiveCaptchaToken -> {
handleReceiveCaptchaToken(action)
}
ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick()
}
}
@ -103,16 +104,21 @@ class CreateAccountViewModel @Inject constructor(
is CaptchaCallbackTokenResult.MissingToken -> {
mutableStateFlow.update {
it.copy(
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.captcha_failed.asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.captcha_failed.asText(),
),
),
)
}
}
is CaptchaCallbackTokenResult.Success -> {
submitRegisterAccountRequest(captchaToken = result.token)
submitRegisterAccountRequest(
shouldCheckForDataBreaches = false,
captchaToken = result.token,
)
}
}
}
@ -122,7 +128,7 @@ class CreateAccountViewModel @Inject constructor(
) {
when (val registerAccountResult = action.registerResult) {
is RegisterResult.CaptchaRequired -> {
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(
CreateAccountEvent.NavigateToCaptcha(
uri = generateUriForCaptcha(captchaId = registerAccountResult.captchaId),
@ -134,18 +140,19 @@ class CreateAccountViewModel @Inject constructor(
// TODO parse and display server errors BIT-910
mutableStateFlow.update {
it.copy(
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = registerAccountResult.errorMessage?.asText()
?: R.string.generic_error_message.asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = registerAccountResult.errorMessage?.asText()
?: R.string.generic_error_message.asText(),
),
),
)
}
}
is RegisterResult.Success -> {
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(
CreateAccountEvent.NavigateToLogin(
email = mutableStateFlow.value.emailInput,
@ -153,6 +160,12 @@ class CreateAccountViewModel @Inject constructor(
),
)
}
RegisterResult.DataBreachFound -> {
mutableStateFlow.update {
it.copy(dialog = CreateAccountDialog.HaveIBeenPwned)
}
}
}
}
@ -174,7 +187,7 @@ class CreateAccountViewModel @Inject constructor(
private fun handleDialogDismiss() {
mutableStateFlow.update {
it.copy(errorDialogState = BasicDialogState.Hidden)
it.copy(dialog = null)
}
}
@ -205,7 +218,7 @@ class CreateAccountViewModel @Inject constructor(
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
)
mutableStateFlow.update { it.copy(errorDialogState = dialog) }
mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) }
}
!mutableStateFlow.value.emailInput.isValidEmail() -> {
@ -213,7 +226,7 @@ class CreateAccountViewModel @Inject constructor(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(),
)
mutableStateFlow.update { it.copy(errorDialogState = dialog) }
mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) }
}
mutableStateFlow.value.passwordInput.length < MIN_PASSWORD_LENGTH -> {
@ -221,7 +234,7 @@ class CreateAccountViewModel @Inject constructor(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(MIN_PASSWORD_LENGTH),
)
mutableStateFlow.update { it.copy(errorDialogState = dialog) }
mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) }
}
mutableStateFlow.value.passwordInput != mutableStateFlow.value.confirmPasswordInput -> {
@ -229,7 +242,7 @@ class CreateAccountViewModel @Inject constructor(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
)
mutableStateFlow.update { it.copy(errorDialogState = dialog) }
mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) }
}
!mutableStateFlow.value.isAcceptPoliciesToggled -> {
@ -237,24 +250,31 @@ class CreateAccountViewModel @Inject constructor(
title = R.string.an_error_has_occurred.asText(),
message = R.string.accept_policies_error.asText(),
)
mutableStateFlow.update { it.copy(errorDialogState = dialog) }
mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) }
}
else -> {
submitRegisterAccountRequest(captchaToken = null)
submitRegisterAccountRequest(
shouldCheckForDataBreaches = mutableStateFlow.value.isCheckDataBreachesToggled,
captchaToken = null,
)
}
}
private fun submitRegisterAccountRequest(captchaToken: String?) {
private fun handleContinueWithBreachedPasswordClick() {
submitRegisterAccountRequest(shouldCheckForDataBreaches = false, captchaToken = null)
}
private fun submitRegisterAccountRequest(
shouldCheckForDataBreaches: Boolean,
captchaToken: String?,
) {
mutableStateFlow.update {
it.copy(
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
)
it.copy(dialog = CreateAccountDialog.Loading)
}
viewModelScope.launch {
val result = authRepository.register(
shouldCheckDataBreaches = shouldCheckForDataBreaches,
email = mutableStateFlow.value.emailInput,
masterPassword = mutableStateFlow.value.passwordInput,
masterPasswordHint = mutableStateFlow.value.passwordHintInput.ifBlank { null },
@ -280,10 +300,32 @@ data class CreateAccountState(
val passwordHintInput: String,
val isCheckDataBreachesToggled: Boolean,
val isAcceptPoliciesToggled: Boolean,
val errorDialogState: BasicDialogState,
val loadingDialogState: LoadingDialogState,
val dialog: CreateAccountDialog?,
) : Parcelable
/**
* Models dialogs that can be displayed on the create account screen.
*/
sealed class CreateAccountDialog : Parcelable {
/**
* Loading dialog.
*/
@Parcelize
data object Loading : CreateAccountDialog()
/**
* Confirm the user wants to continue with potentially breached password.
*/
@Parcelize
data object HaveIBeenPwned : CreateAccountDialog()
/**
* General error dialog with an OK button.
*/
@Parcelize
data class Error(val state: BasicDialogState.Shown) : CreateAccountDialog()
}
/**
* Models events for the create account screen.
*/
@ -337,6 +379,11 @@ sealed class CreateAccountAction {
*/
data object CloseClick : CreateAccountAction()
/**
* User clicked "Yes" when being asked if they are sure they want to use a breached password.
*/
data object ContinueWithBreachedPasswordClick : CreateAccountAction()
/**
* Email input changed.
*/

View file

@ -0,0 +1,61 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.x8bit.bitwarden.ui.platform.base.util.Text
/**
* Represents a Bitwarden-styled dialog that is hidden or shown based on [visibilityState].
*
* @param title title to show.
* @param message message to show.
* @param confirmButtonText text to show on confirm button.
* @param dismissButtonText text to show on dismiss button.
* @param onConfirmClick called when the confirm button is clicked.
* @param onDismissClick called when the dismiss button is clicked.
* @param onDismissRequest called when the user attempts to dismiss the dialog (for example by
* tapping outside of it).
*/
@Composable
fun BitwardenTwoButtonDialog(
title: Text,
message: Text,
confirmButtonText: Text,
dismissButtonText: Text,
onConfirmClick: () -> Unit,
onDismissClick: () -> Unit,
onDismissRequest: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
BitwardenTextButton(
label = dismissButtonText(),
onClick = onDismissClick,
)
},
confirmButton = {
BitwardenTextButton(
label = confirmButtonText(),
onClick = onConfirmClick,
)
},
title = {
Text(
text = title(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
)
},
text = {
Text(
text = message(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
},
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
}

View file

@ -0,0 +1,48 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.HaveIBeenPwnedApi
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
class HaveIBeenPwnedServiceTest : BaseServiceTest() {
private val haveIBeenPwnedApi: HaveIBeenPwnedApi = retrofit.create()
private val service = HaveIBeenPwnedServiceImpl(haveIBeenPwnedApi)
@Test
fun `when service returns failure should return failure`() = runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
assertTrue(service.hasPasswordBeenBreached(PWNED_PASSWORD).isFailure)
}
@Test
fun `when given password is in response returns true`() = runTest {
val response = MockResponse().setBody(HIBP_RESPONSE)
server.enqueue(response)
val result = service.hasPasswordBeenBreached(PWNED_PASSWORD)
assertTrue(result.getOrThrow())
}
@Test
fun `when given password is not in response returns false`() = runTest {
val response = MockResponse().setBody(HIBP_RESPONSE)
server.enqueue(response)
val result = service.hasPasswordBeenBreached("testpassword")
assertFalse(result.getOrThrow())
}
}
private const val PWNED_PASSWORD = "password1234"
private val HIBP_RESPONSE = """
FBD6D76BB5D2041542D7D2E3FAC5BB05593:36865
F390F21EBEFEF07A1DA4E661AF830FD76A6:3
F3CAEF537A4881A05E2A9A9A8A236FE7C14:1
F44FD6981B10EC24A93989A0C61E71C767C:5
""".trimIndent()

View file

@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJs
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.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
@ -22,6 +23,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
@ -45,6 +47,7 @@ class AuthRepositoryTest {
private val accountsService: AccountsService = mockk()
private val identityService: IdentityService = mockk()
private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk()
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val authSdkSource = mockk<AuthSdkSource> {
coEvery {
@ -76,6 +79,7 @@ class AuthRepositoryTest {
private val repository = AuthRepositoryImpl(
accountsService = accountsService,
identityService = identityService,
haveIBeenPwnedService = haveIBeenPwnedService,
authSdkSource = authSdkSource,
authDiskSource = fakeAuthDiskSource,
dispatcher = UnconfinedTestDispatcher(),
@ -83,7 +87,7 @@ class AuthRepositoryTest {
@BeforeEach
fun beforeEach() {
clearMocks(identityService, accountsService)
clearMocks(identityService, accountsService, haveIBeenPwnedService)
mockkStatic(GET_TOKEN_RESPONSE_EXTENSIONS_PATH)
}
@ -230,6 +234,72 @@ class AuthRepositoryTest {
}
}
@Test
fun `register check data breaches error should return Error`() = runTest {
coEvery {
haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD)
} returns Result.failure(Throwable())
val result = repository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
)
assertEquals(RegisterResult.Error(null), result)
}
@Test
fun `register check data breaches found should return DataBreachFound`() = runTest {
coEvery {
haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD)
} returns true.asSuccess()
val result = repository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
)
assertEquals(RegisterResult.DataBreachFound, result)
}
@Test
fun `register check data breaches Success should return Success`() = runTest {
coEvery {
haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD)
} returns false.asSuccess()
coEvery {
accountsService.register(
body = RegisterRequestJson(
email = EMAIL,
masterPasswordHash = PASSWORD_HASH,
masterPasswordHint = null,
captchaResponse = null,
key = ENCRYPTED_USER_KEY,
keys = RegisterRequestJson.Keys(
publicKey = PUBLIC_KEY,
encryptedPrivateKey = PRIVATE_KEY,
),
kdfType = PBKDF2_SHA256,
kdfIterations = DEFAULT_KDF_ITERATIONS.toUInt(),
),
)
} returns Result.success(RegisterResponseJson.Success(captchaBypassToken = CAPTCHA_KEY))
val result = repository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
)
assertEquals(RegisterResult.Success(CAPTCHA_KEY), result)
coVerify { haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD) }
}
@Test
fun `register Success should return Success`() = runTest {
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
@ -256,6 +326,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
assertEquals(RegisterResult.Success(CAPTCHA_KEY), result)
}
@ -295,6 +366,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
assertEquals(RegisterResult.Error(errorMessage = null), result)
}
@ -334,6 +406,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
assertEquals(RegisterResult.CaptchaRequired(captchaId = CAPTCHA_KEY), result)
}
@ -364,6 +437,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
assertEquals(RegisterResult.Error(errorMessage = null), result)
}

View file

@ -27,7 +27,6 @@ import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -312,9 +311,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy(
errorDialogState = BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
),
),
),
)
@ -335,14 +336,62 @@ class CreateAccountScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(CreateAccountAction.ErrorDialogDismiss) }
}
@Test
fun `clicking No on the HIBP dialog should send ErrorDialogDismiss action`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy(dialog = CreateAccountDialog.HaveIBeenPwned),
)
every { eventFlow } returns emptyFlow()
every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule
.onAllNodesWithText("No")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(CreateAccountAction.ErrorDialogDismiss) }
}
@Test
fun `clicking Yes on the HIBP dialog should send ContinueWithBreachedPasswordClick action`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy(dialog = CreateAccountDialog.HaveIBeenPwned),
)
every { eventFlow } returns emptyFlow()
every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(CreateAccountAction.ContinueWithBreachedPasswordClick) }
}
@Test
fun `when BasicDialogState is Shown should show dialog`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy(
errorDialogState = BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
),
),
),
)
@ -407,8 +456,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
passwordHintInput = "",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
dialog = null,
)
}
}

View file

@ -17,8 +17,8 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Pas
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -70,8 +70,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
passwordHintInput = "hint",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
dialog = null,
)
val handle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = CreateAccountViewModel(
@ -91,9 +90,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(EmailInputChange(input))
val expectedState = DEFAULT_STATE.copy(
emailInput = input,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(),
),
),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@ -112,10 +113,12 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(EmailInputChange(input))
val expectedState = DEFAULT_STATE.copy(
emailInput = input,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
),
),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@ -136,9 +139,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
val expectedState = DEFAULT_STATE.copy(
emailInput = EMAIL,
passwordInput = input,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(12),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(12),
),
),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@ -159,9 +164,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
val expectedState = DEFAULT_STATE.copy(
emailInput = "test@test.com",
passwordInput = input,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
),
),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@ -184,9 +191,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
emailInput = "test@test.com",
passwordInput = password,
confirmPasswordInput = password,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.accept_policies_error.asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.accept_policies_error.asText(),
),
),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@ -205,6 +214,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
} returns RegisterResult.Success(captchaToken = "mock_token")
}
@ -218,11 +228,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
assertEquals(
VALID_INPUT_STATE.copy(
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
),
VALID_INPUT_STATE.copy(dialog = CreateAccountDialog.Loading),
stateFlow.awaitItem(),
)
assertEquals(
@ -247,6 +253,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
} returns RegisterResult.Error(errorMessage = "mock_error")
}
@ -258,19 +265,16 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
assertEquals(VALID_INPUT_STATE, awaitItem())
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
assertEquals(
VALID_INPUT_STATE.copy(
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
),
VALID_INPUT_STATE.copy(dialog = CreateAccountDialog.Loading),
awaitItem(),
)
assertEquals(
VALID_INPUT_STATE.copy(
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = "mock_error".asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = "mock_error".asText(),
),
),
),
awaitItem(),
@ -292,6 +296,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
} returns RegisterResult.CaptchaRequired(captchaId = "mock_captcha_id")
}
@ -322,6 +327,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
} returns RegisterResult.Success(captchaToken = "mock_captcha_token")
}
@ -341,6 +347,69 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
}
}
@Test
@Suppress("MaxLineLength")
fun `ContinueWithBreachedPasswordClick should call repository with checkDataBreaches false`() {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
} returns RegisterResult.Error(null)
}
val viewModel = CreateAccountViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
)
viewModel.trySendAction(CreateAccountAction.ContinueWithBreachedPasswordClick)
coVerify {
repo.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
}
}
@Test
fun `SubmitClick register returns ShowDataBreaches should show HaveIBeenPwned dialog`() =
runTest {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
)
} returns RegisterResult.DataBreachFound
}
val viewModel = CreateAccountViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
)
viewModel.actionChannel.trySend(CreateAccountAction.CheckDataBreachesToggle(true))
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
viewModel.stateFlow.test {
assertEquals(
VALID_INPUT_STATE.copy(
isCheckDataBreachesToggled = true,
dialog = CreateAccountDialog.HaveIBeenPwned,
),
awaitItem(),
)
}
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = CreateAccountViewModel(
@ -459,8 +528,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
passwordHintInput = "",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
dialog = null,
)
private val VALID_INPUT_STATE = CreateAccountState(
passwordInput = PASSWORD,
@ -469,8 +537,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
passwordHintInput = "",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = true,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
dialog = null,
)
private const val LOGIN_RESULT_PATH =
"com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt"