fix(auth): Added BackupPassword common checks.

This commit is contained in:
Neil Marietta
2024-12-12 14:37:32 +00:00
parent 9be02705ec
commit e2ca237565
14 changed files with 149 additions and 51 deletions
@@ -46,6 +46,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.core.auth.presentation.compose.R
import me.proton.core.auth.presentation.compose.sso.PasswordFormError.PasswordTooCommon
import me.proton.core.auth.presentation.compose.sso.PasswordFormError.PasswordTooShort
import me.proton.core.auth.presentation.compose.sso.PasswordFormError.PasswordsDoNotMatch
import me.proton.core.compose.component.ProtonPasswordOutlinedTextFieldWithError
import me.proton.core.compose.component.ProtonSolidButton
import me.proton.core.compose.component.appbar.ProtonTopAppBar
@@ -97,8 +100,7 @@ public fun BackupPasswordChangeScreen(
modifier = modifier,
onCloseClicked = onCloseClicked,
onContinueClicked = onContinueClicked,
isPasswordTooShort = state.isPasswordTooShort(),
arePasswordsNotMatching = state.arePasswordsNotMatching(),
formError = (state as? BackupPasswordChangeState.FormError)?.cause,
isLoading = state is BackupPasswordChangeState.Loading
)
}
@@ -108,8 +110,7 @@ public fun BackupPasswordChangeScaffold(
modifier: Modifier = Modifier,
onCloseClicked: () -> Unit = {},
onContinueClicked: (BackupPasswordChangeAction.ChangePassword) -> Unit = {},
isPasswordTooShort: Boolean = false,
arePasswordsNotMatching: Boolean = false,
formError: PasswordFormError? = null,
isLoading: Boolean = false,
) {
Scaffold(
@@ -138,12 +139,16 @@ public fun BackupPasswordChangeScaffold(
.padding(ProtonDimens.DefaultSpacing),
) {
val errorTooShort = stringResource(R.string.backup_password_setup_password_too_short)
val errorNotMatch = stringResource(R.string.backup_password_setup_password_not_matching)
val error = when (formError) {
null -> null
PasswordTooShort -> stringResource(R.string.backup_password_setup_password_too_short)
PasswordTooCommon -> stringResource(R.string.backup_password_setup_password_too_common)
PasswordsDoNotMatch -> stringResource(R.string.backup_password_setup_password_not_matching)
}
BackupPasswordChangeForm(
backupPasswordError = errorTooShort.takeIf { isPasswordTooShort },
backupPasswordRepeatedError = errorNotMatch.takeIf { arePasswordsNotMatching },
backupPasswordError = error,
backupPasswordRepeatedError = error?.takeIf { formError is PasswordsDoNotMatch },
onContinueClicked = onContinueClicked,
isLoading = isLoading,
)
@@ -29,12 +29,3 @@ public sealed interface BackupPasswordChangeState {
public data object Success : BackupPasswordChangeState
}
internal fun BackupPasswordChangeState.formErrorOrNull(): PasswordFormError? =
(this as? BackupPasswordChangeState.FormError)?.cause
internal fun BackupPasswordChangeState.isPasswordTooShort(): Boolean =
formErrorOrNull() == PasswordFormError.PasswordTooShort
internal fun BackupPasswordChangeState.arePasswordsNotMatching(): Boolean =
formErrorOrNull() == PasswordFormError.PasswordsDoNotMatch
@@ -50,6 +50,7 @@ import me.proton.core.observability.domain.ObservabilityContext
import me.proton.core.observability.domain.ObservabilityManager
import me.proton.core.observability.domain.metrics.LoginSsoChangePasswordTotal
import me.proton.core.presentation.utils.InputValidationResult
import me.proton.core.presentation.utils.InvalidPasswordProvider
import me.proton.core.presentation.utils.ValidationType
import me.proton.core.presentation.utils.onFailure
import me.proton.core.presentation.utils.onSuccess
@@ -62,10 +63,15 @@ import javax.inject.Inject
public class BackupPasswordChangeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val context: CryptoContext,
private val invalidPasswordProvider: InvalidPasswordProvider,
private val changeBackupPassword: ChangeBackupPassword,
override val observabilityManager: ObservabilityManager
) : ViewModel(), ObservabilityContext {
init {
viewModelScope.launch { invalidPasswordProvider.init() }
}
private val userId: UserId by lazy { savedStateHandle.getUserId() }
private val mutableAction = MutableStateFlow<BackupPasswordChangeAction?>(null)
@@ -91,12 +97,20 @@ public class BackupPasswordChangeViewModel @Inject constructor(
}.onSuccess {
InputValidationResult(
text = action.backupPassword,
validationType = ValidationType.PasswordMatch,
additionalText = action.repeatBackupPassword
validationType = ValidationType.InvalidPassword,
provider = invalidPasswordProvider
).onFailure {
emit(FormError(PasswordFormError.PasswordsDoNotMatch))
emit(FormError(PasswordFormError.PasswordTooCommon))
}.onSuccess {
emitAll(onChangeBackupPassword(action.backupPassword))
InputValidationResult(
text = action.backupPassword,
validationType = ValidationType.PasswordMatch,
additionalText = action.repeatBackupPassword
).onFailure {
emit(FormError(PasswordFormError.PasswordsDoNotMatch))
}.onSuccess {
emitAll(onChangeBackupPassword(action.backupPassword))
}
}
}
}
@@ -55,6 +55,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import me.proton.core.auth.presentation.compose.R
import me.proton.core.auth.presentation.compose.sso.PasswordFormError.PasswordTooCommon
import me.proton.core.auth.presentation.compose.sso.PasswordFormError.PasswordTooShort
import me.proton.core.auth.presentation.compose.sso.PasswordFormError.PasswordsDoNotMatch
import me.proton.core.compose.component.ProtonPasswordOutlinedTextFieldWithError
import me.proton.core.compose.component.ProtonSolidButton
import me.proton.core.compose.component.appbar.ProtonTopAppBar
@@ -110,8 +113,7 @@ public fun BackupPasswordSetupScreen(
organizationAdminEmail = state.data.organizationAdminEmail,
organizationIcon = state.data.organizationIcon,
organizationName = state.data.organizationName,
isPasswordTooShort = state.isPasswordTooShort(),
arePasswordsNotMatching = state.arePasswordsNotMatching(),
formError = (state as? BackupPasswordSetupState.FormError)?.cause,
isLoading = state is BackupPasswordSetupState.Loading
)
}
@@ -124,8 +126,7 @@ public fun BackupPasswordSetupScaffold(
organizationAdminEmail: String? = null,
organizationIcon: Any? = null,
organizationName: String? = null,
isPasswordTooShort: Boolean = false,
arePasswordsNotMatching: Boolean = false,
formError: PasswordFormError? = null,
isLoading: Boolean = false,
) {
Scaffold(
@@ -163,12 +164,16 @@ public fun BackupPasswordSetupScaffold(
color = LocalColors.current.separatorNorm
)
val errorTooShort = stringResource(R.string.backup_password_setup_password_too_short)
val errorNotMatch = stringResource(R.string.backup_password_setup_password_not_matching)
val error = when (formError) {
null -> null
PasswordTooShort -> stringResource(R.string.backup_password_setup_password_too_short)
PasswordTooCommon -> stringResource(R.string.backup_password_setup_password_too_common)
PasswordsDoNotMatch -> stringResource(R.string.backup_password_setup_password_not_matching)
}
BackupPasswordSetupForm(
backupPasswordError = errorTooShort.takeIf { isPasswordTooShort },
backupPasswordRepeatedError = errorNotMatch.takeIf { arePasswordsNotMatching },
backupPasswordError = error,
backupPasswordRepeatedError = error?.takeIf { formError is PasswordsDoNotMatch },
onContinueClicked = onContinueClicked,
isLoading = isLoading,
modifier = Modifier
@@ -289,7 +294,6 @@ private fun BackupPasswordSetupForm(
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(device = Devices.TABLET)
@Composable
private fun BackupPasswordSetupScreenPreview() {
ProtonTheme {
@@ -303,3 +307,20 @@ private fun BackupPasswordSetupScreenPreview() {
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun BackupPasswordSetupScreenFormErrorPreview() {
ProtonTheme {
BackupPasswordSetupScreen(
state = BackupPasswordSetupState.FormError(
data = BackupPasswordSetupData(
organizationAdminEmail = "admin@company.test",
organizationName = "The Company",
),
cause = PasswordsDoNotMatch
)
)
}
}
@@ -43,12 +43,3 @@ public sealed class BackupPasswordSetupState(
override val data: BackupPasswordSetupData
) : BackupPasswordSetupState(data)
}
internal fun BackupPasswordSetupState.formErrorOrNull(): PasswordFormError? =
(this as? BackupPasswordSetupState.FormError)?.cause
internal fun BackupPasswordSetupState.isPasswordTooShort(): Boolean =
formErrorOrNull() == PasswordFormError.PasswordTooShort
internal fun BackupPasswordSetupState.arePasswordsNotMatching(): Boolean =
formErrorOrNull() == PasswordFormError.PasswordsDoNotMatch
@@ -56,11 +56,11 @@ import me.proton.core.observability.domain.metrics.LoginSsoLoadOrganizationTotal
import me.proton.core.observability.domain.metrics.LoginSsoSetupPrimaryKeysTotal
import me.proton.core.observability.domain.metrics.LoginSsoVerifyUnprivatizationTotal
import me.proton.core.observability.domain.metrics.LoginSsoVerifyUnprivatizationTotal.VerifyStatus
import me.proton.core.observability.domain.metrics.LoginSsoVerifyUnprivatizationTotal.VerifyStatus.failure
import me.proton.core.observability.domain.metrics.LoginSsoVerifyUnprivatizationTotal.VerifyStatus.failurePublicAddressKeysError
import me.proton.core.observability.domain.metrics.LoginSsoVerifyUnprivatizationTotal.VerifyStatus.failureUnprivatizeStateError
import me.proton.core.observability.domain.metrics.LoginSsoVerifyUnprivatizationTotal.VerifyStatus.failureVerificationError
import me.proton.core.presentation.utils.InputValidationResult
import me.proton.core.presentation.utils.InvalidPasswordProvider
import me.proton.core.presentation.utils.ValidationType
import me.proton.core.presentation.utils.onFailure
import me.proton.core.presentation.utils.onSuccess
@@ -73,6 +73,7 @@ import javax.inject.Inject
public class BackupPasswordSetupViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val context: CryptoContext,
private val invalidPasswordProvider: InvalidPasswordProvider,
private val deviceSecretRepository: DeviceSecretRepository,
private val verifyUnprivatization: VerifyUnprivatization,
private val setupPrimaryKeys: SetupPrimaryKeys,
@@ -80,6 +81,10 @@ public class BackupPasswordSetupViewModel @Inject constructor(
override val observabilityManager: ObservabilityManager,
) : ViewModel(), ObservabilityContext {
init {
viewModelScope.launch { invalidPasswordProvider.init() }
}
private val userId: UserId by lazy { savedStateHandle.getUserId() }
private val mutableAction = MutableStateFlow<BackupPasswordSetupAction>(Load())
@@ -155,14 +160,22 @@ public class BackupPasswordSetupViewModel @Inject constructor(
}.onSuccess {
InputValidationResult(
text = action.backupPassword,
validationType = ValidationType.PasswordMatch,
additionalText = action.repeatBackupPassword
validationType = ValidationType.InvalidPassword,
provider = invalidPasswordProvider
).onFailure {
emit(FormError(state.value.data, PasswordFormError.PasswordsDoNotMatch))
emit(FormError(state.value.data, PasswordFormError.PasswordTooCommon))
}.onSuccess {
when (val organizationPublicKey = state.value.data.organizationPublicKey) {
null -> emit(Error(state.value.data, null))
else -> emitAll(onSetupPrimaryKeys(action.backupPassword, organizationPublicKey))
InputValidationResult(
text = action.backupPassword,
validationType = ValidationType.PasswordMatch,
additionalText = action.repeatBackupPassword
).onFailure {
emit(FormError(state.value.data, PasswordFormError.PasswordsDoNotMatch))
}.onSuccess {
when (val organizationPublicKey = state.value.data.organizationPublicKey) {
null -> emit(Error(state.value.data, null))
else -> emitAll(onSetupPrimaryKeys(action.backupPassword, organizationPublicKey))
}
}
}
}
@@ -2,5 +2,6 @@ package me.proton.core.auth.presentation.compose.sso
public sealed interface PasswordFormError {
public data object PasswordTooShort : PasswordFormError
public data object PasswordTooCommon : PasswordFormError
public data object PasswordsDoNotMatch : PasswordFormError
}
@@ -74,6 +74,8 @@
<string name="backup_password_setup_repeat_password_label">Repeat backup password</string>
<string name="backup_password_setup_continue_action">Continue</string>
<string name="backup_password_setup_password_too_short">Password should be at least 8 characters long.</string>
<string name="backup_password_setup_password_too_common">Password too common. Use a different password.</string>
<string name="backup_password_setup_password_not_matching">Passwords do not match.</string>
<string name="backup_password_change_title">Set backup password</string>
@@ -80,6 +80,24 @@ class BackupPasswordSetupScreenTest {
}
}
@Test
fun `passwords too common`() {
paparazzi.snapshot {
ProtonTheme {
BackupPasswordSetupScreen(
state = FormError(
data = BackupPasswordSetupData(
organizationAdminEmail = "admin@example.test",
organizationIcon = null,
organizationName = "Example Organization",
),
cause = PasswordFormError.PasswordTooCommon
)
)
}
}
}
@Test
fun `passwords do not match`() {
paparazzi.snapshot {
@@ -21,6 +21,8 @@ package me.proton.core.auth.presentation.compose.sso
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import me.proton.core.auth.domain.repository.DeviceSecretRepository
import me.proton.core.auth.domain.usecase.SetupPrimaryKeys
@@ -28,6 +30,7 @@ import me.proton.core.auth.domain.usecase.sso.VerifyUnprivatization
import me.proton.core.auth.presentation.compose.DeviceSecretRoutes
import me.proton.core.crypto.common.context.CryptoContext
import me.proton.core.observability.domain.ObservabilityManager
import me.proton.core.presentation.utils.InvalidPasswordProvider
import me.proton.core.test.kotlin.CoroutinesTest
import me.proton.core.usersettings.domain.repository.OrganizationRepository
import kotlin.test.BeforeTest
@@ -39,6 +42,9 @@ class BackupPasswordSetupViewModelTest : CoroutinesTest by CoroutinesTest() {
@MockK
private lateinit var context: CryptoContext
@MockK
private lateinit var invalidPasswordProvider: InvalidPasswordProvider
@MockK
private lateinit var observabilityManager: ObservabilityManager
@@ -59,9 +65,14 @@ class BackupPasswordSetupViewModelTest : CoroutinesTest by CoroutinesTest() {
@BeforeTest
fun setUp() {
MockKAnnotations.init(this)
coEvery { invalidPasswordProvider.init() } returns Unit
every { invalidPasswordProvider.isPasswordCommon(any()) } returns false
tested = BackupPasswordSetupViewModel(
savedStateHandle = SavedStateHandle(mapOf(DeviceSecretRoutes.Arg.KEY_USER_ID to "user-id")),
context = context,
invalidPasswordProvider = invalidPasswordProvider,
deviceSecretRepository = deviceSecretRepository,
verifyUnprivatization = verifyUnprivatization,
setupPrimaryKeys = setupPrimaryKeys,
@@ -90,6 +101,28 @@ class BackupPasswordSetupViewModelTest : CoroutinesTest by CoroutinesTest() {
}
}
@Test
fun `password too common`() = coroutinesTest {
every { invalidPasswordProvider.isPasswordCommon(any()) } returns true
tested.state.test {
assertEquals(BackupPasswordSetupState.Idle(BackupPasswordSetupData()), awaitItem())
// WHEN
tested.submit(BackupPasswordSetupAction.SetPassword("12345678", "12345678")).join()
// THEN
assertEquals(BackupPasswordSetupState.Loading(BackupPasswordSetupData()), awaitItem())
assertEquals(
expected = BackupPasswordSetupState.FormError(
data = BackupPasswordSetupData(),
cause = PasswordFormError.PasswordTooCommon
),
actual = awaitItem()
)
}
}
@Test
fun `passwords not matching`() = coroutinesTest {
tested.state.test {
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e400d8764979a996f762ff8c2829e8a47f50ba0fb5aea5f5f1ab4f2174ae50ed
size 38018
oid sha256:0c9d4ed4b884d93a155bc51dd76a2adeeafc9169331c0f67eb37fbe2012dd942
size 40981
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:163b9f72151331bac4c16025ed05ed14b2af21dcbcfc2f04299742cbcd7dcb5f
size 39101
oid sha256:6986e400d6a54a65afabacc21bb9e379411f6848d17962fde3d4e84da4397d43
size 42090
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d53cfb80b3fbaabb9f680d95075527ca38732d94ad1357b5b89cce366e6f898e
size 41287
@@ -19,10 +19,16 @@
package me.proton.core.presentation.utils
import android.content.Context
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class InvalidPasswordProvider(private val context: Context) {
@Reusable
class InvalidPasswordProvider @Inject constructor(
@ApplicationContext private val context: Context
) {
private var commonPasswords: Set<String>? = null