feat!(auth): Added Global SSO Change Password support.

MIGRATIONS:
- UserDatabase.MIGRATION_6
- AccountDatabase.MIGRATION_9
This commit is contained in:
Neil Marietta
2024-10-10 22:20:26 +02:00
parent d1061b7b40
commit 9bed1db76d
73 changed files with 7817 additions and 80 deletions
@@ -202,7 +202,7 @@ abstract class AccountManagerDatabase :
companion object {
const val name = "db-account-manager"
const val version = 56
const val version = 57
val migrations = listOf(
AccountManagerDatabaseMigrations.MIGRATION_1_2,
@@ -259,7 +259,8 @@ abstract class AccountManagerDatabase :
AccountManagerDatabaseMigrations.MIGRATION_52_53,
AccountManagerDatabaseMigrations.MIGRATION_53_54,
AccountManagerDatabaseMigrations.MIGRATION_54_55,
AccountManagerDatabaseMigrations.MIGRATION_55_56
AccountManagerDatabaseMigrations.MIGRATION_55_56,
AccountManagerDatabaseMigrations.MIGRATION_56_57,
)
fun databaseBuilder(context: Context): Builder<AccountManagerDatabase> =
@@ -392,4 +392,11 @@ object AccountManagerDatabaseMigrations {
MailSettingsDatabase.MIGRATION_1.migrate(db)
}
}
val MIGRATION_56_57 = object : Migration(56, 57) {
override fun migrate(db: SupportSQLiteDatabase) {
UserDatabase.MIGRATION_6.migrate(db)
AccountDatabase.MIGRATION_9.migrate(db)
}
}
}
@@ -81,6 +81,7 @@ class AccountSettingsViewModelTest : CoroutinesTest by CoroutinesTest() {
delinquent = null,
recovery = null,
keys = emptyList(),
flags = emptyMap(),
type = Type.Proton
)
@@ -61,7 +61,8 @@ class ObserveUserRecoveryStateTest {
sessionId = SessionId("test-session-id"),
reason = UserRecovery.Reason.Authentication
),
keys = emptyList()
keys = emptyList(),
flags = emptyMap(),
)
private val testUserNullRecovery = testUser.copy(recovery = null)
@@ -175,5 +175,17 @@ interface AccountDatabase : Database {
)
}
}
/**
* - Added [User.flags], insert migration to populate the value from backend (see AccountMigrator).
*/
val MIGRATION_9 = object : DatabaseMigration {
override fun migrate(database: SupportSQLiteDatabase) {
// If there are no migrations the value is NULL.
database.execSQL("UPDATE AccountMetadataEntity SET migrations = IFNULL(migrations || ';RefreshUser', 'RefreshUser')")
database.execSQL("UPDATE AccountEntity SET state = 'MigrationNeeded' WHERE state = 'Ready'")
}
}
}
}
@@ -28,7 +28,7 @@ data class SRPAuthenticationResponse(
@SerialName("Code")
val code: Int,
@SerialName("ServerProof")
val serverProof: ServerProof,
val serverProof: ServerProof? = null,
)
fun SRPAuthenticationResponse.isSuccess(): Boolean = code == ResponseCodes.OK
@@ -26,4 +26,5 @@ object LogTag {
const val ACTIVATE_DEVICE = "core.auth.domain.perform.backuppass.activate"
const val UNPRIVATIZE_USER = "core.auth.domain.perform.backuppass.unprivatize"
const val SETUP_KEYS = "core.auth.domain.perform.backuppass.setup.keys"
const val CHANGE_BACKUP_PASSWORD = "core.auth.domain.perform.backuppass.change"
}
@@ -78,6 +78,7 @@ class PostLoginLessAccountSetup @Inject constructor(
delinquent = null,
recovery = null,
keys = emptyList(),
flags = emptyMap(),
maxBaseSpace = 0,
maxDriveSpace = 0,
usedBaseSpace = 0,
@@ -30,7 +30,7 @@ class ValidateServerProof @Inject constructor() {
* Throws an [InvalidServerAuthenticationException] with the result of calling [lazyMessage]
* if the [ServerProof] isn't the one expected.
*/
operator fun invoke(serverProof: ServerProof, expectedProof: String, lazyMessage: () -> Any) {
operator fun invoke(serverProof: ServerProof?, expectedProof: String?, lazyMessage: () -> Any) {
if (serverProof != expectedProof) {
val message = "Server returned invalid srp proof, ${lazyMessage.invoke()}"
val exception = InvalidServerAuthenticationException(message)
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.domain.usecase.sso
import me.proton.core.account.domain.repository.AccountRepository
import me.proton.core.auth.domain.repository.AuthRepository
import me.proton.core.auth.domain.repository.DeviceSecretRepository
import me.proton.core.crypto.common.context.CryptoContext
import me.proton.core.crypto.common.keystore.EncryptedString
import me.proton.core.crypto.common.keystore.decrypt
import me.proton.core.crypto.common.keystore.use
import me.proton.core.domain.entity.UserId
import me.proton.core.user.domain.UserManager
import me.proton.core.user.domain.extension.nameNotNull
import me.proton.core.user.domain.repository.PassphraseRepository
import me.proton.core.user.domain.repository.UserRepository
import javax.inject.Inject
class ChangeBackupPassword @Inject constructor(
context: CryptoContext,
private val accountRepository: AccountRepository,
private val authRepository: AuthRepository,
private val userManager: UserManager,
private val userRepository: UserRepository,
private val passphraseRepository: PassphraseRepository,
private val deviceSecretRepository: DeviceSecretRepository,
private val getEncryptedSecret: GetEncryptedSecret,
) {
private val keyStore = context.keyStoreCrypto
private val srp = context.srpCrypto
suspend operator fun invoke(
userId: UserId,
newBackupPassword: EncryptedString,
): Boolean {
val user = userRepository.getUser(userId)
val username = user.nameNotNull()
val account = accountRepository.getAccountOrNull(userId)
val sessionId = requireNotNull(account?.sessionId)
val modulus = authRepository.randomModulus(sessionId)
val currentPassphrase = requireNotNull(passphraseRepository.getPassphrase(userId))
val deviceSecret = requireNotNull(deviceSecretRepository.getByUserId(userId)?.secret)
currentPassphrase.decrypt(keyStore).use { decryptedCurrentPassphrase ->
newBackupPassword.decrypt(keyStore).toByteArray().use { decryptedBackupPassword ->
val auth = srp.calculatePasswordVerifier(
username = username,
password = decryptedBackupPassword.array,
modulusId = modulus.modulusId,
modulus = modulus.modulus
)
return userManager.changePassword(
userId = userId,
newPassword = newBackupPassword,
secondFactorProof = null,
proofs = null,
srpSession = null,
auth = auth,
encryptedSecret = getEncryptedSecret.invoke(
passphrase = decryptedCurrentPassphrase,
deviceSecret = deviceSecret
)
)
}
}
}
}
@@ -42,8 +42,8 @@ class CheckOtherDevices @Inject constructor(
val devices = authDeviceRepository.getByUserId(userId)
val activeDevices = devices.filter { it.state == AuthDeviceState.Active }
return when {
hasTemporaryPassword -> Result.AdminHelpRequired
localDevice.isPendingAdmin() -> Result.AdminHelpRequested
hasTemporaryPassword -> Result.AdminHelpRequired
activeDevices.isNotEmpty() -> Result.OtherDevicesAvailable(devices)
else -> Result.BackupPassword
}
@@ -48,7 +48,7 @@ class GetEncryptedSecret @Inject constructor(
operator fun invoke(
passphrase: PlainByteArray,
deviceSecret: DeviceSecretString
): Based64EncodedAeadEncryptedSecret = pgpCrypto.getBase64Encoded(
): Based64EncodedAeadEncryptedSecret = pgpCrypto.getBase64EncodedNoWrap(
pgpCrypto.getBase64Decoded(deviceSecret.decrypt(keyStoreCrypto)).use { key ->
passphrase.encrypt(
crypto = aeadCrypto,
@@ -70,7 +70,8 @@ internal class AccountAvailabilityTest {
delinquent = null,
recovery = null,
keys = emptyList(),
type = Type.Proton
type = Type.Proton,
flags = emptyMap(),
)
@BeforeTest
@@ -66,7 +66,9 @@ class PostLoginSsoAccountSetupTest {
fun setUp() {
accountWorkflowHandler = mockk()
user = mockk()
user = mockk {
every { flags } returns emptyMap()
}
sessionId = mockk()
userCheck = mockk {
coEvery { this@mockk.invoke(any()) } returns PostLoginAccountSetup.UserCheckResult.Success
@@ -51,6 +51,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.ChangePassword
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.Close
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.DeviceRejected
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.Error
@@ -58,6 +59,7 @@ import me.proton.core.auth.presentation.compose.DeviceSecretViewState.FirstLogin
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.InvalidSecret
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.Loading
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.Success
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeScreen
import me.proton.core.auth.presentation.compose.sso.BackupPasswordInputScreen
import me.proton.core.auth.presentation.compose.sso.BackupPasswordSetupScreen
import me.proton.core.auth.presentation.compose.sso.RequestAccessDeniedScreen
@@ -125,12 +127,14 @@ public fun DeviceSecretScreen(
is Close -> onClose()
is Success -> onSuccess(state.userId)
is Loading -> DeviceSecretScaffold(
modifier = modifier,
onCloseClicked = onCloseClicked,
onRetryClicked = onReloadState,
isLoading = true,
email = state.email
)
is Error -> DeviceSecretScaffold(
modifier = modifier,
onCloseClicked = onCloseClicked,
onRetryClicked = onReloadState,
email = state.email,
@@ -169,6 +173,7 @@ public fun DeviceSecretScreen(
)
is InvalidSecret.NoDevice.RequireAdmin -> RequestAdminHelpScreen(
modifier = modifier,
onBackClicked = onCloseClicked,
onErrorMessage = onErrorMessage,
onSuccess = onReloadState,
@@ -180,8 +185,13 @@ public fun DeviceSecretScreen(
onBackToSignInClicked = onCloseClicked
)
// TODO: Replace with BackupPasswordChangeScreen()
is DeviceSecretViewState.ChangePassword -> onClose()
is ChangePassword -> BackupPasswordChangeScreen(
modifier = modifier,
onCloseClicked = onCloseClicked,
onCloseMessage = onCloseMessage,
onErrorMessage = onErrorMessage,
onSuccess = onReloadState,
)
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.sso
public sealed interface BackupPasswordChangeOperation
public sealed interface BackupPasswordChangeAction : BackupPasswordChangeOperation {
public data class ChangePassword(
val backupPassword: String,
val repeatBackupPassword: String,
val unused: Long = System.currentTimeMillis()
) : BackupPasswordChangeAction
}
@@ -0,0 +1,221 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.sso
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Devices
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.compose.component.ProtonPasswordOutlinedTextFieldWithError
import me.proton.core.compose.component.ProtonSolidButton
import me.proton.core.compose.component.appbar.ProtonTopAppBar
import me.proton.core.compose.theme.LocalColors
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.ProtonTypography
@Composable
public fun BackupPasswordChangeScreen(
onCloseClicked: () -> Unit,
onCloseMessage: (String?) -> Unit,
onErrorMessage: (String?) -> Unit,
onSuccess: () -> Unit,
modifier: Modifier = Modifier,
viewModel: BackupPasswordChangeViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
BackupPasswordChangeScreen(
modifier = modifier,
onCloseClicked = onCloseClicked,
onContinueClicked = { viewModel.submit(it) },
onCloseMessage = onCloseMessage,
onErrorMessage = onErrorMessage,
onSuccess = onSuccess,
state = state
)
}
@Composable
public fun BackupPasswordChangeScreen(
modifier: Modifier = Modifier,
onCloseClicked: () -> Unit = {},
onContinueClicked: (BackupPasswordChangeAction.ChangePassword) -> Unit = {},
onCloseMessage: (String?) -> Unit = {},
onErrorMessage: (String?) -> Unit = {},
onSuccess: () -> Unit = {},
state: BackupPasswordChangeState,
) {
LaunchedEffect(state) {
when (state) {
is BackupPasswordChangeState.Close -> onCloseMessage(state.message)
is BackupPasswordChangeState.Error -> onErrorMessage(state.message)
is BackupPasswordChangeState.Success -> onSuccess()
else -> Unit
}
}
BackupPasswordChangeScaffold(
modifier = modifier,
onCloseClicked = onCloseClicked,
onContinueClicked = onContinueClicked,
isPasswordTooShort = state.isPasswordTooShort(),
arePasswordsNotMatching = state.arePasswordsNotMatching(),
isLoading = state is BackupPasswordChangeState.Loading
)
}
@Composable
public fun BackupPasswordChangeScaffold(
modifier: Modifier = Modifier,
onCloseClicked: () -> Unit = {},
onContinueClicked: (BackupPasswordChangeAction.ChangePassword) -> Unit = {},
isPasswordTooShort: Boolean = false,
arePasswordsNotMatching: Boolean = false,
isLoading: Boolean = false,
) {
Scaffold(
modifier = modifier,
topBar = {
ProtonTopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onCloseClicked) {
Icon(
painterResource(id = R.drawable.ic_proton_close),
contentDescription = stringResource(id = R.string.presentation_close)
)
}
},
backgroundColor = LocalColors.current.backgroundNorm
)
}
) { paddingValues ->
Box(
modifier = Modifier.padding(paddingValues)
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.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)
BackupPasswordChangeForm(
backupPasswordError = errorTooShort.takeIf { isPasswordTooShort },
backupPasswordRepeatedError = errorNotMatch.takeIf { arePasswordsNotMatching },
onContinueClicked = onContinueClicked,
isLoading = isLoading,
)
}
}
}
}
@Composable
private fun BackupPasswordChangeForm(
backupPasswordError: String?,
backupPasswordRepeatedError: String?,
isLoading: Boolean,
onContinueClicked: (BackupPasswordChangeAction.ChangePassword) -> Unit,
modifier: Modifier = Modifier,
) {
var backupPassword by rememberSaveable { mutableStateOf("") }
var repeatBackupPassword by rememberSaveable { mutableStateOf("") }
Column(
modifier = modifier
) {
Text(
text = stringResource(id = R.string.backup_password_change_title),
style = ProtonTypography.Default.headline
)
Text(
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing),
text = stringResource(id = R.string.backup_password_change_description),
style = ProtonTypography.Default.body2Regular
)
ProtonPasswordOutlinedTextFieldWithError(
text = backupPassword,
onValueChanged = { backupPassword = it },
enabled = !isLoading,
singleLine = true,
label = { Text(text = stringResource(id = R.string.backup_password_setup_password_label)) },
errorText = backupPasswordError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing)
)
ProtonPasswordOutlinedTextFieldWithError(
text = repeatBackupPassword,
onValueChanged = { repeatBackupPassword = it },
enabled = !isLoading,
singleLine = true,
label = { Text(text = stringResource(id = R.string.backup_password_setup_repeat_password_label)) },
errorText = backupPasswordRepeatedError,
modifier = Modifier.padding(top = ProtonDimens.SmallSpacing)
)
ProtonSolidButton(
contained = false,
loading = isLoading,
modifier = Modifier
.padding(top = ProtonDimens.MediumSpacing)
.height(ProtonDimens.DefaultButtonMinHeight),
onClick = { onContinueClicked(BackupPasswordChangeAction.ChangePassword(backupPassword, repeatBackupPassword)) }
) {
Text(text = stringResource(id = R.string.backup_password_setup_continue_action))
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(device = Devices.TABLET)
@Composable
private fun BackupPasswordChangeScreenPreview() {
ProtonTheme {
BackupPasswordChangeScreen(state = BackupPasswordChangeState.Idle)
}
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.sso
public sealed interface BackupPasswordChangeState {
public data object Idle : BackupPasswordChangeState
public data object Loading : BackupPasswordChangeState
public data class FormError(val cause: PasswordFormError) : BackupPasswordChangeState
public data class Error(val message: String?) : BackupPasswordChangeState
public data class Close(val message: String?) : 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
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.sso
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.proton.core.auth.domain.LogTag
import me.proton.core.auth.domain.usecase.sso.ChangeBackupPassword
import me.proton.core.auth.presentation.compose.DeviceSecretRoutes.Arg.getUserId
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeAction.ChangePassword
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeState.Error
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeState.FormError
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeState.Idle
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeState.Loading
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeState.Success
import me.proton.core.compose.viewmodel.stopTimeoutMillis
import me.proton.core.crypto.common.context.CryptoContext
import me.proton.core.crypto.common.keystore.encrypt
import me.proton.core.domain.entity.UserId
import me.proton.core.presentation.utils.InputValidationResult
import me.proton.core.presentation.utils.ValidationType
import me.proton.core.presentation.utils.onFailure
import me.proton.core.presentation.utils.onSuccess
import me.proton.core.util.kotlin.catchAll
import me.proton.core.util.kotlin.catchWhen
import javax.inject.Inject
@HiltViewModel
public class BackupPasswordChangeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val context: CryptoContext,
private val changeBackupPassword: ChangeBackupPassword
) : ViewModel() {
private val userId: UserId by lazy { savedStateHandle.getUserId() }
private val mutableAction = MutableStateFlow<BackupPasswordChangeAction?>(null)
public val state: StateFlow<BackupPasswordChangeState> = mutableAction.flatMapLatest { action ->
when (action) {
null -> flowOf(Idle)
is ChangePassword -> onValidatePassword(action)
}
}.stateIn(viewModelScope, WhileSubscribed(stopTimeoutMillis), Idle)
public fun submit(action: BackupPasswordChangeAction): Job = viewModelScope.launch {
mutableAction.emit(action)
}
private fun onValidatePassword(action: ChangePassword) = flow {
emit(Loading)
InputValidationResult(
text = action.backupPassword,
validationType = ValidationType.PasswordMinLength
).onFailure {
emit(FormError(PasswordFormError.PasswordTooShort))
}.onSuccess {
InputValidationResult(
text = action.backupPassword,
validationType = ValidationType.PasswordMatch,
additionalText = action.repeatBackupPassword
).onFailure {
emit(FormError(PasswordFormError.PasswordsDoNotMatch))
}.onSuccess {
emitAll(onChangeBackupPassword(action.backupPassword))
}
}
}
private fun onChangeBackupPassword(backupPassword: String) = flow {
emit(Loading)
val password = backupPassword.encrypt(context.keyStoreCrypto)
changeBackupPassword.invoke(userId, password)
emit(Success)
}.catchWhen(Throwable::isActionNotAllowed) {
emit(BackupPasswordChangeState.Close(it.message))
}.catchAll(LogTag.CHANGE_BACKUP_PASSWORD) {
emit(Error(it.message))
}
}
@@ -112,9 +112,3 @@ public class BackupPasswordInputViewModel @Inject constructor(
emit(BackupPasswordInputState.Error(it.message))
}
}
private fun Throwable.isActionNotAllowed(): Boolean {
if (this !is ApiException) return false
val error = error as? ApiResult.Error.Http
return error?.proton?.code == ResponseCodes.NOT_ALLOWED
}
@@ -153,7 +153,7 @@ public fun BackupPasswordSetupScaffold(
.verticalScroll(rememberScrollState())
.padding(ProtonDimens.DefaultSpacing),
) {
SsoOrganizationAdminInfoHeader(
OrganizationAdminInfoHeader(
organizationAdminEmail = organizationAdminEmail,
organizationIcon = organizationIcon,
organizationName = organizationName
@@ -166,7 +166,7 @@ public fun BackupPasswordSetupScaffold(
val errorTooShort = stringResource(R.string.backup_password_setup_password_too_short)
val errorNotMatch = stringResource(R.string.backup_password_setup_password_not_matching)
SsoBackupPasswordSetupForm(
BackupPasswordSetupForm(
backupPasswordError = errorTooShort.takeIf { isPasswordTooShort },
backupPasswordRepeatedError = errorNotMatch.takeIf { arePasswordsNotMatching },
onContinueClicked = onContinueClicked,
@@ -181,7 +181,7 @@ public fun BackupPasswordSetupScaffold(
}
@Composable
private fun SsoOrganizationAdminInfoHeader(
private fun OrganizationAdminInfoHeader(
organizationAdminEmail: String?,
organizationIcon: Any?,
organizationName: String?,
@@ -232,7 +232,7 @@ private fun SsoOrganizationAdminInfoHeader(
}
@Composable
private fun SsoBackupPasswordSetupForm(
private fun BackupPasswordSetupForm(
backupPasswordError: String?,
backupPasswordRepeatedError: String?,
isLoading: Boolean,
@@ -36,7 +36,7 @@ public sealed class BackupPasswordSetupState(
public data class FormError(
override val data: BackupPasswordSetupData,
val cause: BackupPasswordSetupFormError
val cause: PasswordFormError
) : BackupPasswordSetupState(data)
public data class Success(
@@ -44,16 +44,11 @@ public sealed class BackupPasswordSetupState(
) : BackupPasswordSetupState(data)
}
internal fun BackupPasswordSetupState.formErrorOrNull(): BackupPasswordSetupFormError? =
internal fun BackupPasswordSetupState.formErrorOrNull(): PasswordFormError? =
(this as? BackupPasswordSetupState.FormError)?.cause
internal fun BackupPasswordSetupState.isPasswordTooShort(): Boolean =
formErrorOrNull() == BackupPasswordSetupFormError.PasswordTooShort
formErrorOrNull() == PasswordFormError.PasswordTooShort
internal fun BackupPasswordSetupState.arePasswordsNotMatching(): Boolean =
formErrorOrNull() == BackupPasswordSetupFormError.PasswordsDoNotMatch
public sealed interface BackupPasswordSetupFormError {
public data object PasswordTooShort : BackupPasswordSetupFormError
public data object PasswordsDoNotMatch : BackupPasswordSetupFormError
}
formErrorOrNull() == PasswordFormError.PasswordsDoNotMatch
@@ -124,14 +124,14 @@ public class BackupPasswordSetupViewModel @Inject constructor(
text = action.backupPassword,
validationType = ValidationType.PasswordMinLength
).onFailure {
emit(FormError(state.value.data, BackupPasswordSetupFormError.PasswordTooShort))
emit(FormError(state.value.data, PasswordFormError.PasswordTooShort))
}.onSuccess {
InputValidationResult(
text = action.backupPassword,
validationType = ValidationType.PasswordMatch,
additionalText = action.repeatBackupPassword
).onFailure {
emit(FormError(state.value.data, BackupPasswordSetupFormError.PasswordsDoNotMatch))
emit(FormError(state.value.data, PasswordFormError.PasswordsDoNotMatch))
}.onSuccess {
when (val organizationPublicKey = state.value.data.organizationPublicKey) {
null -> emit(Error(state.value.data, null))
@@ -47,6 +47,8 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
@@ -55,7 +57,6 @@ import me.proton.core.auth.presentation.compose.R
import me.proton.core.auth.presentation.compose.SMALL_SCREEN_HEIGHT
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.defaultNorm
@Composable
internal fun ConfirmationDigits(
@@ -116,7 +117,8 @@ private fun ConfirmationCodeDigit(
Text(
textAlign = TextAlign.Center,
text = digit.toString(),
style = textStyle
style = textStyle,
fontFamily = FontFamily.Monospace
)
}
}
@@ -159,7 +161,7 @@ internal fun ConfirmationDigitTextField(
true -> BorderStroke(1.dp, ProtonTheme.colors.interactionNorm)
false -> BorderStroke(1.dp, ProtonTheme.colors.separatorNorm)
},
digitStyle = ProtonTheme.typography.defaultNorm
digitStyle = ProtonTheme.typography.hero.copy(fontWeight = FontWeight.Normal)
)
}
}
@@ -191,7 +191,11 @@ private fun ConfirmationCodeInputScreen(
) {
var confirmationCode by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
var selected by remember(data) { mutableStateOf(data.pendingDevices.getOrNull(0)) }
var selected by remember { mutableStateOf<AuthDeviceData?>(null) }
when (selected?.deviceId) {
null -> selected = data.pendingDevices.firstOrNull()
!in data.pendingDevices.map { it.deviceId } -> selected = data.pendingDevices.firstOrNull()
}
Column(modifier = Modifier.padding(ProtonDimens.DefaultSpacing)) {
Text(
@@ -0,0 +1,6 @@
package me.proton.core.auth.presentation.compose.sso
public sealed interface PasswordFormError {
public data object PasswordTooShort : PasswordFormError
public data object PasswordsDoNotMatch : PasswordFormError
}
@@ -32,11 +32,13 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.proton.core.auth.domain.LogTag
import me.proton.core.auth.domain.entity.AuthDeviceState
import me.proton.core.auth.domain.repository.AuthDeviceRepository
import me.proton.core.auth.domain.repository.DeviceSecretRepository
import me.proton.core.auth.presentation.compose.DeviceApprovalRoutes.Arg.getUserId
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpAction.Load
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpAction.Submit
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpState.AdminHelpHelpRequested
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpState.Error
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpState.Idle
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpState.Loading
@@ -86,8 +88,12 @@ public class RequestAdminHelpViewModel @Inject constructor(
private fun onSubmit() = flow {
emit(Loading(state.value.data))
val deviceId = requireNotNull(deviceSecretRepository.getByUserId(userId)?.deviceId)
val device = authDeviceRepository.getByDeviceId(userId, deviceId)
check(device?.state != AuthDeviceState.PendingAdminActivation) {
"Device is already pending admin activation."
}
authDeviceRepository.requestAdminHelp(userId, deviceId)
emit(RequestAdminHelpState.AdminHelpHelpRequested(state.value.data))
emit(AdminHelpHelpRequested(state.value.data))
}.catch { error ->
emit(Error(state.value.data, error))
}
@@ -0,0 +1,11 @@
package me.proton.core.auth.presentation.compose.sso
import me.proton.core.network.domain.ApiException
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.ResponseCodes
public fun Throwable.isActionNotAllowed(): Boolean {
if (this !is ApiException) return false
val error = error as? ApiResult.Error.Http
return error?.proton?.code == ResponseCodes.NOT_ALLOWED
}
@@ -130,8 +130,7 @@ public fun WaitingAdminScaffold(
if (username != null) {
Text(
modifier = Modifier
.padding(top = ProtonDimens.MediumSpacing),
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing),
text = stringResource(
id = R.string.auth_login_share_confirmation_code_with_admin_subtitle,
username
@@ -56,6 +56,7 @@
<string name="backup_password_input_label">Backup password</string>
<string name="backup_password_input_subtitle">To make sure its really you trying to sign-in, please enter your backup password.</string>
<string name="backup_password_input_title">Enter your backup password</string>
<string name="backup_password_setup_title" comment="1 - company name">Join %1$s</string>
<string name="backup_password_setup_subtitle" comment="1 - email address of organization's admin">%1$s added you to their Proton organization.</string>
<string name="backup_password_setup_description">Set a backup password to add an extra layer of protection.</string>
@@ -64,6 +65,10 @@
<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_not_matching">Passwords do not match.</string>
<string name="backup_password_change_title">Set backup password</string>
<string name="backup_password_change_description">This password adds an extra layer of protection and allows you to sign in if you get locked out. Make sure to keep it somewhere safe.</string>
<string name="backup_password_invalid">Invalid backup password.</string>
<string name="backup_password_no_primary_key">No Primary Key.</string>
<string name="backup_password_no_key_salts">No Key Salts.</string>
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.presentation.compose.sso
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import me.proton.core.compose.theme.ProtonTheme
import org.junit.Rule
import kotlin.test.Test
class BackupPasswordChangeScreenTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5,
theme = "ProtonTheme"
)
@Test
fun `idle screen`() {
paparazzi.snapshot {
ProtonTheme {
BackupPasswordChangeScreen(
state = BackupPasswordChangeState.Idle
)
}
}
}
@Test
fun `password too short`() {
paparazzi.snapshot {
ProtonTheme {
BackupPasswordChangeScreen(
state = BackupPasswordChangeState.FormError(PasswordFormError.PasswordTooShort)
)
}
}
}
@Test
fun `passwords do not match`() {
paparazzi.snapshot {
ProtonTheme {
BackupPasswordChangeScreen(
state = BackupPasswordChangeState.FormError(PasswordFormError.PasswordsDoNotMatch)
)
}
}
}
}
@@ -73,7 +73,7 @@ class BackupPasswordSetupScreenTest {
organizationIcon = null,
organizationName = "Example Organization",
),
cause = BackupPasswordSetupFormError.PasswordTooShort
cause = PasswordFormError.PasswordTooShort
)
)
}
@@ -91,7 +91,7 @@ class BackupPasswordSetupScreenTest {
organizationIcon = null,
organizationName = "Example Organization",
),
cause = BackupPasswordSetupFormError.PasswordsDoNotMatch
cause = PasswordFormError.PasswordsDoNotMatch
)
)
}
@@ -78,7 +78,7 @@ class BackupPasswordSetupViewModelTest : CoroutinesTest by CoroutinesTest() {
assertEquals(
expected = BackupPasswordSetupState.FormError(
data = BackupPasswordSetupData(),
cause = BackupPasswordSetupFormError.PasswordTooShort
cause = PasswordFormError.PasswordTooShort
),
actual = awaitItem()
)
@@ -98,7 +98,7 @@ class BackupPasswordSetupViewModelTest : CoroutinesTest by CoroutinesTest() {
assertEquals(
expected = BackupPasswordSetupState.FormError(
data = BackupPasswordSetupData(),
cause = BackupPasswordSetupFormError.PasswordsDoNotMatch
cause = PasswordFormError.PasswordsDoNotMatch
),
actual = awaitItem()
)
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c6466c90dc230eb54de9315d3b1ab4cd6f2fbede844a77ff7a211d8235e09075
size 34830
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7921b5eb814192416f9b94ab0c7d23f612f52be1d1c8c6c97a8151d2542f304b
size 39800
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e400d8764979a996f762ff8c2829e8a47f50ba0fb5aea5f5f1ab4f2174ae50ed
size 38018
@@ -95,6 +95,7 @@ class ChooseExternalEmailViewModelTest : ArchTest by ArchTest(),
delinquent = null,
recovery = null,
keys = emptyList(),
flags = emptyMap(),
type = Type.Proton
)
@@ -167,6 +167,7 @@ class SignupViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Coroutines
delinquent = null,
recovery = null,
keys = emptyList(),
flags = emptyMap(),
type = Type.Proton
)
@@ -84,6 +84,7 @@ fun UserId.userEntity() = UserEntity(
delinquent = null,
recovery = null,
passphrase = null,
flags = emptyMap(),
maxBaseSpace = null,
maxDriveSpace = null,
usedBaseSpace = null,
File diff suppressed because it is too large Load Diff
@@ -202,7 +202,7 @@ abstract class AppDatabase :
companion object {
const val name = "db-account-manager"
const val version = 56
const val version = 57
val migrations = listOf(
AppDatabaseMigrations.MIGRATION_1_2,
@@ -259,7 +259,8 @@ abstract class AppDatabase :
AppDatabaseMigrations.MIGRATION_52_53,
AppDatabaseMigrations.MIGRATION_53_54,
AppDatabaseMigrations.MIGRATION_54_55,
AppDatabaseMigrations.MIGRATION_55_56
AppDatabaseMigrations.MIGRATION_55_56,
AppDatabaseMigrations.MIGRATION_56_57,
)
fun buildDatabase(context: Context): AppDatabase =
@@ -393,4 +393,11 @@ object AppDatabaseMigrations {
MailSettingsDatabase.MIGRATION_1.migrate(db)
}
}
val MIGRATION_56_57 = object : Migration(56, 57) {
override fun migrate(db: SupportSQLiteDatabase) {
UserDatabase.MIGRATION_6.migrate(db)
AccountDatabase.MIGRATION_9.migrate(db)
}
}
}
@@ -22,6 +22,8 @@ import androidx.room.TypeConverter
import me.proton.core.domain.entity.Product
import me.proton.core.domain.entity.UserId
import me.proton.core.network.domain.session.SessionId
import me.proton.core.util.kotlin.deserializeMapOrNull
import me.proton.core.util.kotlin.serialize
class CommonConverters {
@@ -37,6 +39,12 @@ class CommonConverters {
@TypeConverter
fun fromStringToListOfInt(value: String?): List<Int>? = Companion.fromStringToListOfInt(value)
@TypeConverter
fun fromMapOfStringBooleanToString(value: Map<String, Boolean>?): String? = value.orEmpty().serialize()
@TypeConverter
fun fromStringToMapOfStringBoolean(value: String?): Map<String, Boolean>? = value?.deserializeMapOrNull<String, Boolean>().orEmpty()
@TypeConverter
fun fromProductToString(value: Product?): String? = value?.name
@@ -21,19 +21,20 @@ package me.proton.core.key.data.api.request
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import me.proton.core.auth.data.api.request.Fido2Request
import me.proton.core.crypto.common.pgp.Based64Encoded
@Serializable
data class UpdateKeysForPasswordChangeRequest(
@SerialName("KeySalt")
val keySalt: String,
@SerialName("ClientEphemeral")
val clientEphemeral: String,
val clientEphemeral: String? = null,
@SerialName("ClientProof")
val clientProof: String,
val clientProof: String? = null,
@SerialName("SRPSession")
val srpSession: String,
val srpSession: String? = null,
@SerialName("TwoFactorCode")
val twoFactorCode: String?,
val twoFactorCode: String? = null,
@SerialName("FIDO2")
val fido2: Fido2Request? = null,
@SerialName("Auth")
@@ -41,7 +42,9 @@ data class UpdateKeysForPasswordChangeRequest(
@SerialName("Keys")
val keys: List<PrivateKeyRequest>? = null,
@SerialName("UserKeys")
val userKeys: List<PrivateKeyRequest>? = null
val userKeys: List<PrivateKeyRequest>? = null,
@SerialName("EncryptedSecret")
val encryptedSecret: Based64Encoded? = null,
)
@Serializable
@@ -51,4 +54,3 @@ data class PrivateKeyRequest(
@SerialName("ID")
val id: String
)
@@ -65,6 +65,8 @@ data class UserResponse(
val recovery: UserRecoveryResponse? = null,
@SerialName("Keys")
val keys: List<UserKeyResponse>,
@SerialName("Flags")
val flags: Map<String, Boolean>? = null,
@SerialName("MaxBaseSpace")
val maxBaseSpace: Long? = null,
@SerialName("MaxDriveSpace")
@@ -109,19 +109,20 @@ class PrivateKeyRepositoryImpl @Inject constructor(
override suspend fun updatePrivateKeys(
sessionUserId: SessionUserId,
keySalt: String,
srpProofs: SrpProofs,
srpSession: String,
srpProofs: SrpProofs?,
srpSession: String?,
secondFactorProof: SecondFactorProof?,
auth: Auth?,
keys: List<Key>?,
userKeys: List<Key>?
userKeys: List<Key>?,
encryptedSecret: Based64Encoded?
): Boolean {
return provider.get<KeyApi>(sessionUserId).invoke {
val response = updatePrivateKeys(
UpdateKeysForPasswordChangeRequest(
keySalt = keySalt,
clientEphemeral = srpProofs.clientEphemeral,
clientProof = srpProofs.clientProof,
clientEphemeral = srpProofs?.clientEphemeral,
clientProof = srpProofs?.clientProof,
srpSession = srpSession,
twoFactorCode = secondFactorProof.toSecondFactorCode(),
fido2 = secondFactorProof.toSecondFactorFido(),
@@ -131,10 +132,11 @@ class PrivateKeyRepositoryImpl @Inject constructor(
},
userKeys = userKeys?.map {
PrivateKeyRequest(privateKey = it.privateKey, id = it.keyId.id)
}
},
encryptedSecret = encryptedSecret
)
)
validateServerProof(response.serverProof, srpProofs.expectedServerProof) { "key update failed" }
validateServerProof(response.serverProof, srpProofs?.expectedServerProof) { "key update failed" }
response.isSuccess()
}.valueOrThrow
}
@@ -59,12 +59,13 @@ interface PrivateKeyRepository {
suspend fun updatePrivateKeys(
sessionUserId: SessionUserId,
keySalt: String,
srpProofs: SrpProofs,
srpSession: String,
secondFactorProof: SecondFactorProof?,
auth: Auth?,
srpProofs: SrpProofs? = null,
srpSession: String? = null,
secondFactorProof: SecondFactorProof? = null,
auth: Auth? = null,
keys: List<Key>? = null,
userKeys: List<Key>? = null
userKeys: List<Key>? = null,
encryptedSecret: Based64Encoded? = null
): Boolean
suspend fun reactivatePrivateKey(
@@ -56,6 +56,7 @@ fun UserId.userEntity() = UserEntity(
delinquent = null,
recovery = null,
passphrase = null,
flags = emptyMap(),
maxBaseSpace = null,
maxDriveSpace = null,
usedBaseSpace = null,
@@ -48,6 +48,7 @@ internal fun testUserEntity(userId: UserId) = UserEntity(
delinquent = null,
recovery = null,
passphrase = null,
flags = emptyMap(),
maxBaseSpace = null,
maxDriveSpace = null,
usedBaseSpace = null,
@@ -213,6 +213,7 @@ class UpgradePlansViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Coro
delinquent = null,
recovery = null,
keys = emptyList(),
flags = emptyMap(),
type = Type.Proton
)
@@ -48,6 +48,7 @@ internal fun testUserEntity(userId: UserId) = UserEntity(
delinquent = null,
recovery = null,
passphrase = null,
flags = emptyMap(),
maxBaseSpace = null,
maxDriveSpace = null,
usedBaseSpace = null,
@@ -86,7 +86,8 @@ class PerformUpdateUserPassword @Inject constructor(
secondFactorProof = secondFactorProof,
proofs = clientProofs,
srpSession = loginInfo.srpSession,
auth = auth
auth = auth,
encryptedSecret = null
)
}
}
@@ -91,7 +91,8 @@ class PerformResetUserPasswordTest {
subscribed = 0,
delinquent = null,
recovery = null,
keys = emptyList()
keys = emptyList(),
flags = emptyMap(),
)
// endregion
@@ -95,7 +95,8 @@ class PerformUpdateLoginPasswordTest {
subscribed = 0,
delinquent = null,
recovery = null,
keys = emptyList()
keys = emptyList(),
flags = emptyMap(),
)
private val testUserSettingsResponse = UserSettings.nil(testUserId).copy(
@@ -93,7 +93,8 @@ class PerformUpdateUserPasswordTest {
subscribed = 0,
delinquent = null,
recovery = null,
keys = emptyList()
keys = emptyList(),
flags = emptyMap(),
)
// endregion
@@ -121,7 +122,8 @@ class PerformUpdateUserPasswordTest {
secondFactorProof = any(),
proofs = any(),
srpSession = any(),
auth = any()
auth = any(),
encryptedSecret = any()
)
} returns true
@@ -199,7 +201,8 @@ class PerformUpdateUserPasswordTest {
secondFactorProof = secondFactorFido,
proofs = any(),
srpSession = any(),
auth = any()
auth = any(),
encryptedSecret = any()
)
} returns true
@@ -184,6 +184,7 @@ class PasswordManagementFragment :
showSuccess()
}
is PasswordManagementViewModel.State.CannotChangePassword -> showError(getString(R.string.settings_password_management_cannot_change_password))
is PasswordManagementViewModel.State.Error -> showError(it.error.getUserMessage(resources))
}
}.launchIn(viewLifecycleOwner.lifecycleScope)
@@ -45,6 +45,8 @@ import me.proton.core.observability.domain.ObservabilityManager
import me.proton.core.observability.domain.metrics.AccountRecoveryResetTotal
import me.proton.core.presentation.viewmodel.ProtonViewModel
import me.proton.core.user.domain.entity.UserRecovery
import me.proton.core.user.domain.extension.isSso
import me.proton.core.user.domain.usecase.ObserveUser
import me.proton.core.usersettings.domain.usecase.IsSessionAccountRecoveryEnabled
import me.proton.core.usersettings.domain.usecase.ObserveUserSettings
import me.proton.core.usersettings.domain.usecase.PerformUpdateLoginPassword
@@ -56,6 +58,7 @@ import javax.inject.Inject
@HiltViewModel
class PasswordManagementViewModel @Inject constructor(
private val keyStoreCrypto: KeyStoreCrypto,
private val observeUser: ObserveUser,
private val observeUserRecovery: ObserveUserRecovery,
private val observeUserSettings: ObserveUserSettings,
private val observeUserRecoverySelfInitiated: ObserveUserRecoverySelfInitiated,
@@ -73,6 +76,10 @@ class PasswordManagementViewModel @Inject constructor(
private val currentAction = MutableStateFlow<Action?>(null)
private val currentUserId = MutableStateFlow<UserId?>(null)
private val currentUser = currentUserId.filterNotNull().flatMapLatest {
observeUser(it)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val currentUserRecovery = currentUserId.filterNotNull().flatMapLatest {
observeUserRecovery(it)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
@@ -94,7 +101,7 @@ class PasswordManagementViewModel @Inject constructor(
userId = userId,
loginPasswordAvailable = true,
mailboxPasswordAvailable = isMailboxPassword && product != Product.Vpn,
recoveryResetAvailable = recoveryResetAvailable(userId),
recoveryResetAvailable = recoveryResetAvailable(userId) && !isUserSso,
recoveryResetEnabled = isRecoveryResetEnabled,
currentLoginPasswordNeeded = !isRecoveryResetEnabled || !isRecoveryInsecure || !isSelfInitiated,
twoFactorEnabled = isTwoFactorEnabled,
@@ -117,6 +124,8 @@ class PasswordManagementViewModel @Inject constructor(
resetPassword(action)
}
isUserSso -> updateBackupPassword(action)
else -> when (action.type) {
PasswordType.Login -> when (isMailboxPassword) {
true -> updateLoginPassword(action)
@@ -127,6 +136,12 @@ class PasswordManagementViewModel @Inject constructor(
}
}
private fun updateBackupPassword(action: Action.UpdatePassword): Flow<State> = flow {
// Currently not supported to change password for Global SSO user.
emit(State.CannotChangePassword)
perform(Action.ObserveState(action.userId))
}
private fun updateLoginPassword(action: Action.UpdatePassword): Flow<State> = flow {
emit(State.UpdatingPassword)
val encryptedPassword = action.password.encrypt(keyStoreCrypto)
@@ -190,8 +205,10 @@ class PasswordManagementViewModel @Inject constructor(
return observeState(action.userId)
}
private val user get() = currentUser.value
private val userRecovery get() = currentUserRecovery.value
private val userSettings get() = currentUserSettings.value
private val isUserSso get() = user?.isSso() ?: false
private val isSelfInitiated get() = currentSelfInitiated.value
private val isMailboxPassword get() = userSettings?.password?.mode == 2
private val isTwoFactorEnabled get() = userSettings?.twoFA?.enabled ?: false
@@ -241,6 +258,7 @@ class PasswordManagementViewModel @Inject constructor(
data object TwoFactorNeeded : State()
data object UpdatingPassword : State()
data object CannotChangePassword : State()
data object Success : State()
data class Error(val error: Throwable) : State()
}
@@ -38,6 +38,7 @@
<string name="settings_password_management_tab_second_password">Second password</string>
<string name="settings_password_management_change_password_note">To change either of your passwords, you\'ll need to know your main password. <a href="https://account.proton.me/reset-password">Forgot your main password?</a></string>
<string name="settings_password_management_header">Change password</string>
<string name="settings_password_management_cannot_change_password">You can\'t change your backup password. Please contact your administrator.</string>
<string name="settings_main_password_hint_current">Current main password</string>
<string name="settings_main_password_hint_new">New main password</string>
<string name="settings_main_password_hint_new_confirm">Confirm new main password</string>
@@ -37,6 +37,7 @@ import me.proton.core.test.kotlin.CoroutinesTest
import me.proton.core.test.kotlin.flowTest
import me.proton.core.user.domain.entity.Type
import me.proton.core.user.domain.entity.User
import me.proton.core.user.domain.usecase.ObserveUser
import me.proton.core.usersettings.domain.entity.PasswordSetting
import me.proton.core.usersettings.domain.entity.RecoverySetting
import me.proton.core.usersettings.domain.entity.TwoFASetting
@@ -60,6 +61,7 @@ import kotlin.test.assertTrue
class PasswordManagementViewModelTest : ArchTest by ArchTest(), CoroutinesTest by CoroutinesTest() {
// region mocks
private val observeUser = mockk<ObserveUser>()
private val observeUserRecovery = mockk<ObserveUserRecovery>()
private val observeUserSettings = mockk<ObserveUserSettings>()
private val observeUserRecoverySelfInitiated = mockk<ObserveUserRecoverySelfInitiated>()
@@ -109,6 +111,7 @@ class PasswordManagementViewModelTest : ArchTest by ArchTest(), CoroutinesTest b
delinquent = null,
recovery = null,
keys = emptyList(),
flags = emptyMap(),
type = Type.Proton
)
// endregion
@@ -118,12 +121,14 @@ class PasswordManagementViewModelTest : ArchTest by ArchTest(), CoroutinesTest b
@Before
fun beforeEveryTest() {
coEvery { isAccountRecoveryResetEnabled.invoke(testUserId) } returns false
coEvery { observeUser.invoke(testUserId) } returns flowOf(testUser)
coEvery { observeUserRecovery.invoke(testUserId) } returns flowOf(testUser.recovery)
coEvery { observeUserSettings.invoke(testUserId) } returns flowOf(testUserSettingsResponse)
coEvery { observeUserRecoverySelfInitiated.invoke(testUserId) } returns flowOf(false)
viewModel =
PasswordManagementViewModel(
keyStoreCrypto,
observeUser,
observeUserRecovery,
observeUserSettings,
observeUserRecoverySelfInitiated,
@@ -45,6 +45,7 @@ object TestUsers {
email = "user1@example.com",
displayName = "user 1 name",
keys = listOf(Key1.response, Key2Inactive.response),
flags = emptyMap(),
type = Type.Proton.value
)
@@ -93,6 +94,7 @@ object TestUsers {
email = "user1@example.com",
displayName = "user 1 name",
keys = listOf(Key1.response),
flags = emptyMap(),
type = Type.Proton.value
)
@@ -230,7 +230,8 @@ class UserManagerPasswordTests {
),
srpSession = "test-srp-session",
auth = mockk(),
secondFactorProof = null
secondFactorProof = null,
encryptedSecret = null
)
// THEN
@@ -718,6 +718,7 @@ class UserRepositoryImplTests {
delinquent = null,
recovery = null,
passphrase = null,
flags = emptyMap(),
maxBaseSpace = null,
maxDriveSpace = null,
usedBaseSpace = null,
@@ -31,6 +31,7 @@ import me.proton.core.crypto.common.keystore.decrypt
import me.proton.core.crypto.common.keystore.encrypt
import me.proton.core.crypto.common.keystore.use
import me.proton.core.crypto.common.pgp.Armored
import me.proton.core.crypto.common.pgp.Based64Encoded
import me.proton.core.crypto.common.pgp.SignatureContext
import me.proton.core.crypto.common.srp.Auth
import me.proton.core.crypto.common.srp.SrpProofs
@@ -153,9 +154,10 @@ class UserManagerImpl @Inject constructor(
userId: UserId,
newPassword: EncryptedString,
secondFactorProof: SecondFactorProof?,
proofs: SrpProofs,
srpSession: String,
auth: Auth?
proofs: SrpProofs?,
srpSession: String?,
auth: Auth?,
encryptedSecret: Based64Encoded?
): Boolean {
newPassword.decrypt(keyStore).toByteArray().use { decryptedNewPassword ->
val keySalt = pgp.generateNewKeySalt()
@@ -181,7 +183,8 @@ class UserManagerImpl @Inject constructor(
secondFactorProof = secondFactorProof,
auth = auth,
keys = updatedKeys,
userKeys = updatedUserKeys
userKeys = updatedUserKeys,
encryptedSecret = encryptedSecret
)
lock(userId)
@@ -130,5 +130,19 @@ interface UserDatabase : Database, UserKeyDatabase {
)
}
}
/**
* - Added UserEntity column: flags.
*/
val MIGRATION_6 = object : DatabaseMigration {
override fun migrate(database: SupportSQLiteDatabase) {
database.addTableColumn(
table = "UserEntity",
column = "flags",
type = "TEXT",
defaultValue = null
)
}
}
}
}
@@ -62,6 +62,7 @@ data class UserEntity(
@Embedded(prefix = "recovery_")
val recovery: UserRecoveryEntity?,
val passphrase: EncryptedByteArray?,
val flags: Map<String, Boolean>?,
val maxBaseSpace: Long?,
val maxDriveSpace: Long?,
val usedBaseSpace: Long?,
@@ -51,6 +51,7 @@ fun UserResponse.toUser(): User {
delinquent = Delinquent.map[delinquent],
recovery = recovery?.toUserRecovery(),
keys = keys.map { it.toUserKey(userId) },
flags = flags.orEmpty(),
maxBaseSpace = maxBaseSpace,
maxDriveSpace = maxDriveSpace,
usedBaseSpace = usedBaseSpace,
@@ -78,6 +79,7 @@ internal fun User.toEntity(passphrase: EncryptedByteArray?) = UserEntity(
delinquent = delinquent?.value,
recovery = recovery?.toUserRecoveryEntity(),
passphrase = passphrase,
flags = flags,
maxBaseSpace = maxBaseSpace,
maxDriveSpace = maxDriveSpace,
usedBaseSpace = usedBaseSpace,
@@ -103,6 +105,7 @@ internal fun UserEntity.toUser(keys: List<UserKey>) = User(
delinquent = Delinquent.map[delinquent],
recovery = recovery?.toUserRecovery(),
keys = keys,
flags = flags.orEmpty(),
maxBaseSpace = maxBaseSpace,
maxDriveSpace = maxDriveSpace,
usedBaseSpace = usedBaseSpace,
@@ -275,6 +275,7 @@ class UserManagerImplTest {
proofs = mockk(),
srpSession = "srp",
auth = mockk(),
encryptedSecret = null
)
// Then
assertFalse(result)
@@ -296,6 +297,7 @@ class UserManagerImplTest {
proofs = mockk(),
srpSession = "srp",
auth = mockk(),
encryptedSecret = null
)
// Then
coVerify {
@@ -325,6 +327,7 @@ class UserManagerImplTest {
proofs = mockk(),
srpSession = "srp",
auth = mockk(),
encryptedSecret = null
)
// Then
coVerify {
@@ -351,6 +354,7 @@ class UserManagerImplTest {
proofs = mockk(),
srpSession = "srp",
auth = mockk(),
encryptedSecret = null
)
// Then
coVerify {
@@ -138,9 +138,10 @@ interface UserManager {
userId: UserId,
newPassword: EncryptedString,
secondFactorProof: SecondFactorProof?,
proofs: SrpProofs,
srpSession: String,
auth: Auth?
proofs: SrpProofs?,
srpSession: String?,
auth: Auth?,
encryptedSecret: Based64Encoded?
): Boolean
/**
@@ -99,6 +99,7 @@ data class User(
* @see [UserManager.lock]
* */
override val keys: List<UserKey>,
val flags: Map<String, Boolean>,
val maxBaseSpace: Long? = null,
val maxDriveSpace: Long? = null,
val usedBaseSpace: Long? = null,
@@ -95,10 +95,14 @@ fun User.getUsedDriveSpacePercentage(): Int? = getUsedPercentage(usedDriveSpace,
fun User.getUsedTotalSpacePercentage(): Int = getUsedPercentage(usedSpace, maxSpace)!!
/**
* @return true if the user have a temporary password.
* @return true if the user has a temporary password.
*/
fun User.hasTemporaryPassword() = false
fun User.hasTemporaryPassword(): Boolean = flags.getOrDefault("has-temporary-password", false)
/**
* @return true if the user is SSO.
*/
fun User.isSso(): Boolean = flags.getOrDefault("sso", false)
@Suppress("MagicNumber")
private fun getUsedPercentage(used: Long?, max: Long?): Int? {
@@ -46,6 +46,7 @@ class UserKtTest {
delinquent = null,
recovery = null,
keys = emptyList(),
flags = emptyMap(),
type = Type.Proton
)
@@ -97,6 +97,16 @@ inline fun <reified T : Any> String.deserializeList(): List<T> = Serializer.deco
inline fun <reified T : Any, reified V : Any> String.deserializeMap(): Map<T, V> =
Serializer.decodeFromString(this)
/**
* @return [Map] of [T], [V] object from the receiver [String]
* This uses reflection: TODO improve for avoid it
*/
@NeedSerializable
inline fun <reified T : Any, reified V : Any> String.deserializeMapOrNull(): Map<T, V>? = try {
deserializeMap()
} catch (e: SerializationException) {
null
}
/**
* @return [String] from the receiver [T] object