diff --git a/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt b/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt index da505cf5..e9c9efac 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/di/ApplicationModule.kt @@ -19,6 +19,7 @@ package com.infomaniak.auth.di import android.content.Context import com.infomaniak.auth.BuildConfig +import com.infomaniak.auth.MainApplication import com.infomaniak.auth.lib.AuthenticatorFacade import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge @@ -26,7 +27,8 @@ import com.infomaniak.auth.lib.network.interfaces.BreadcrumbType import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface import com.infomaniak.auth.lib.network.interfaces.CrashReportLevel import com.infomaniak.auth.utils.AccountUtils -import com.infomaniak.auth.utils.toMigrationApiToken +import com.infomaniak.auth.utils.toLoginApiToken +import com.infomaniak.auth.utils.toSharedApiToken import com.infomaniak.auth.utils.toUser import com.infomaniak.core.auth.room.UserDatabase import com.infomaniak.core.common.utils.buildUserAgent @@ -52,8 +54,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.transform import javax.inject.Singleton -import com.infomaniak.auth.lib.models.migration.SharedApiToken as MigrationApiToken -import com.infomaniak.lib.login.ApiToken as LoginApiToken +import com.infomaniak.auth.lib.models.migration.SharedApiToken @Module @InstallIn(SingletonComponent::class) @@ -174,7 +175,7 @@ object ApplicationModule { override suspend fun getTokenFromCrossAppLogin( userId: Long - ): MigrationApiToken? = crossAppLoginFacade.accountsCheckingState.transform { state -> + ): SharedApiToken? = crossAppLoginFacade.accountsCheckingState.transform { state -> val matchingAccount = state.checkedAccounts.find { it.id == userId } ?: when (state.status) { AccountsCheckingStatus.Checking -> return@transform // Wait for next emission. @@ -184,25 +185,18 @@ object ApplicationModule { } if (matchingAccount == null) return@transform emit(null) val result = crossAppLoginFacade.attemptLogin(listOf(matchingAccount)) - emit(result.tokens.singleOrNull()?.toMigrationApiToken()) + emit(result.tokens.singleOrNull()?.toSharedApiToken()) }.first() - override suspend fun getTokenFromDatabase(userId: Long): String? { - return accountUtils.getUserById(userId.toInt())?.apiToken?.accessToken + override suspend fun getTokenFromDatabase(userId: Long): SharedApiToken? { + return accountUtils.getUserById(userId.toInt())?.apiToken?.toSharedApiToken() } - override suspend fun persistTokenForAccount(userId: Long, token: String) { + override suspend fun persistTokenForAccount(userId: Long, token: SharedApiToken) { + MainApplication.userDataCleanableList.forEach { it.resetForUser(userId) } val dao = UserDatabase.getDatabase().userDao() val user = accountUtils.getUserById(userId.toInt()) ?: return - dao.update( - user.copy( - apiToken = LoginApiToken( - accessToken = token, - tokenType = user.apiToken.tokenType, - userId = userId.toInt() - ) - ) - ) + dao.update(user.copy(apiToken = token.toLoginApiToken())) } override suspend fun persistUserProfile(userProfile: SharedUserProfile) { diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/previewparameter/AccountPreviewParameter.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/previewparameter/AccountPreviewParameter.kt index 8a29757f..4ff2e98e 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/previewparameter/AccountPreviewParameter.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/previewparameter/AccountPreviewParameter.kt @@ -37,7 +37,7 @@ val fakeAccounts = persistentListOf( initials = "JS", email = "john.smith@ik.me", avatarUrl = null, - status = Account.Status.LoggedIn, + status = Account.Status.LoggedIn(), ), Account( id = 1, diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/AccountDetailsScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/AccountDetailsScreen.kt index eb5f2d5b..8b59b763 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/AccountDetailsScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/AccountDetailsScreen.kt @@ -251,7 +251,7 @@ private fun SettingsSections( isRefreshing = true } - val firstSectionItem = if (accountStatus == Account.Status.LoggedIn) { + val firstSectionItem = if (accountStatus is Account.Status.LoggedIn) { persistentListOf( OptionItemType.WithLoader( stringResId = R.string.refreshPendingLoginsButton, @@ -333,7 +333,7 @@ private enum class AccountSecurityConfiguration( companion object { fun Account.Status.toSecurityConfiguration(): AccountSecurityConfiguration = when (this) { - Account.Status.LoggedIn -> Secured + is Account.Status.LoggedIn -> Secured is Account.Status.NotConnected -> Disconnected else -> PartiallyProtected // TODO: Use secure level to determine the status more precisely } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt index 1b57b235..727c0622 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt @@ -134,7 +134,8 @@ fun ActionRequiredCard( } } } - is Account.Status.LoggedIn, is Account.Status.NotConnected.AttemptingToConnect -> Unit + is Account.Status.LoggedIn, + is Account.Status.NotConnected.AttemptingToConnect -> Unit } } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt index 2aabf829..c0561be3 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt @@ -64,6 +64,7 @@ import com.infomaniak.auth.ui.components.Avatar import com.infomaniak.auth.ui.components.StatusCard import com.infomaniak.auth.ui.components.StatusCardVariant import com.infomaniak.auth.ui.previewparameter.fakeAccountPairs +import com.infomaniak.auth.ui.screen.accountlist.AccountListViewModel.AccountListUiState import com.infomaniak.auth.ui.screen.accountlist.AccountSecurityLevel.Companion.toAccountSecurityLevel import com.infomaniak.auth.ui.theme.AppDimens.DefaultCornerRadius import com.infomaniak.auth.ui.theme.AuthenticatorTheme @@ -86,6 +87,7 @@ fun AccountListScreen( uiState = { state }, onAccountClicked = onAccountClicked, onChallengesRefreshRequested = viewModel::refreshChallenges, + onUserProfilesRefreshRequested = viewModel::refreshUserProfiles, ) } is AccountListUiState.Loading -> Unit @@ -97,6 +99,7 @@ fun AccountListScreen( uiState: () -> AccountListUiState.Success, onAccountClicked: (Account) -> Unit, onChallengesRefreshRequested: () -> Unit, + onUserProfilesRefreshRequested: () -> Unit, modifier: Modifier = Modifier ) { val state = uiState() @@ -122,6 +125,7 @@ fun AccountListScreen( onRefresh = { isRefreshing = true onChallengesRefreshRequested() + onUserProfilesRefreshRequested() }, ) { Column( @@ -229,11 +233,18 @@ private enum class AccountSecurityLevel(val iconResId: Int, val iconTint: @Compo Danger(iconResId = R.drawable.shield_exclamation_mark, iconTint = { AuthenticatorTheme.customColors.iconTintWarning }); companion object { - fun Account.Status.toAccountSecurityLevel() = when (this) { - Account.Status.LoggedIn -> Secured + fun Account.Status.toAccountSecurityLevel(): AccountSecurityLevel = when (this) { + is Account.Status.LoggedIn -> accountSecurityLevelForScore(securityScore) is Account.Status.NotConnected -> Danger //TODO: Shouldn't this show the exclamation mark too? else -> Warning // TODO: Use secure level to determine the status more precisely } + + private fun accountSecurityLevelForScore(securityScore: Int?): AccountSecurityLevel { + return when (securityScore) { + 5 -> Secured + else -> Warning + } + } } } @@ -247,6 +258,7 @@ private fun AccountListScreenPreview() { uiState = { AccountListUiState.Success(fakeAccountPairs) }, onAccountClicked = {}, onChallengesRefreshRequested = {}, + onUserProfilesRefreshRequested = {}, ) } } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt index 642989c7..f5bc8d41 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt @@ -63,17 +63,21 @@ class AccountListViewModel @Inject constructor( viewModelScope.launch { authenticatorFacade.accounts.first() .filter { account -> - account.status == Account.Status.LoggedIn + account.status is Account.Status.LoggedIn } .forEach { account -> twoFactorAuthManager.refreshChallengeNow(account.id) } } } -} -@Immutable -sealed interface AccountListUiState { - data object Loading : AccountListUiState - data class Success(val accountPairs: ImmutableList>) : AccountListUiState + fun refreshUserProfiles() { + authenticatorFacade.refreshUserProfiles() + } + + @Immutable + sealed interface AccountListUiState { + data object Loading : AccountListUiState + data class Success(val accountPairs: ImmutableList>) : AccountListUiState + } } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt index be303fb9..906b3eae 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt @@ -24,7 +24,9 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack @@ -40,6 +42,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState +import com.infomaniak.auth.lib.Account import com.infomaniak.auth.lib.AppStatus import com.infomaniak.auth.ui.navigation.NavDestination import com.infomaniak.auth.ui.navigation.baseEntryProvider @@ -85,10 +88,17 @@ fun MainScreen( } } + var showPasswordChangedDialogFor: Account? by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.accountsWithPasswordUpdate.collect { accounts -> + accounts.firstOrNull()?.let { showPasswordChangedDialogFor = it } + } + } + MainScreen(backStack, entryDecorators) } - @OptIn(ExperimentalPermissionsApi::class) private fun handleAppStatus( appStatus: AppStatus, diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainViewModel.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainViewModel.kt index d490a806..524fd6a0 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainViewModel.kt @@ -20,13 +20,16 @@ package com.infomaniak.auth.ui.screen.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.infomaniak.auth.data.preferences.PermissionPreferences +import com.infomaniak.auth.lib.Account import com.infomaniak.auth.lib.AuthenticatorFacade import com.infomaniak.auth.lib.repository.AppSettingsRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -39,6 +42,15 @@ class MainViewModel @Inject constructor( ) : ViewModel() { val appStatus = authenticatorFacade.appStatus + val accountsWithPasswordUpdate: Flow> = authenticatorFacade.accounts.map { accounts -> + accounts.filter { + when (val status = it.status) { + is Account.Status.LoggedIn if (status.passwordChangedAck != null) -> true + else -> false + } + } + } + val isAppLocked = appSettingsRepository.getSettings().mapNotNull { it?.isAppLockEnabled } val hasTriggeredNotificationPermission: StateFlow = flow { emitAll(PermissionPreferences().hasTriggeredNotificationPermissionFlow) diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/onboarding/start/OnboardingStartViewModel.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/onboarding/start/OnboardingStartViewModel.kt index 3672d05f..f9e5fa87 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/onboarding/start/OnboardingStartViewModel.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/onboarding/start/OnboardingStartViewModel.kt @@ -27,6 +27,7 @@ import com.infomaniak.auth.lib.AppStatus import com.infomaniak.auth.lib.AuthenticatorFacade import com.infomaniak.auth.lib.matomo.MatomoName import com.infomaniak.auth.utils.AccountUtils +import com.infomaniak.auth.utils.toSharedUser import com.infomaniak.core.auth.models.UserLoginResult import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.auth.utils.LoginUtils @@ -70,15 +71,7 @@ class OnboardingStartViewModel @Inject constructor( } private suspend fun addUserToAuthenticatorDB(user: User) { - val connectedAccount = Account( - id = user.id.toLong(), - fullName = "${user.firstname} ${user.lastname}", - initials = user.getInitials(), - email = user.email, - avatarUrl = user.avatar, - status = Account.Status.LoggedIn, - ) - authenticatorFacade.addAccounts(listOf(connectedAccount)) + authenticatorFacade.addAccounts(listOf(user.toSharedUser())) } suspend fun connectSelectedAccounts( diff --git a/app/src/main/kotlin/com/infomaniak/auth/utils/AccountUtils.kt b/app/src/main/kotlin/com/infomaniak/auth/utils/AccountUtils.kt index fbbbd7f0..e302e4f6 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/utils/AccountUtils.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/utils/AccountUtils.kt @@ -33,4 +33,5 @@ class AccountUtils @Inject constructor( suspend fun isUserConnected(): Boolean = users.first().isNotEmpty() suspend fun getUserById(id: Int): User? = userDao.findById(id) + suspend fun getUsersById(userIds: IntArray): List = userDao.loadAllByIds(userIds) } diff --git a/app/src/main/kotlin/com/infomaniak/auth/utils/MigrationUtils.kt b/app/src/main/kotlin/com/infomaniak/auth/utils/MigrationUtils.kt index 16fe90ea..775bcdba 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/utils/MigrationUtils.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/utils/MigrationUtils.kt @@ -18,32 +18,38 @@ package com.infomaniak.auth.utils import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile +import com.infomaniak.auth.lib.models.migration.user.preferences.Preferences import com.infomaniak.auth.lib.models.migration.user.preferences.SharedCountry import com.infomaniak.auth.lib.models.migration.user.preferences.SharedLanguage import com.infomaniak.auth.lib.models.migration.user.preferences.SharedOrganizationPreference -import com.infomaniak.auth.lib.models.migration.user.preferences.Preferences import com.infomaniak.auth.lib.models.migration.user.preferences.SharedTimeZone import com.infomaniak.auth.lib.models.migration.user.preferences.security.SharedAuthDevices import com.infomaniak.auth.lib.models.migration.user.preferences.security.SharedSecurity import com.infomaniak.core.auth.models.user.User -import com.infomaniak.auth.lib.models.migration.SharedApiToken as MigrationApiToken -import com.infomaniak.core.auth.models.user.preferences.Country as CoreCountry -import com.infomaniak.core.auth.models.user.preferences.Language as CoreLanguage -import com.infomaniak.core.auth.models.user.preferences.OrganizationPreference as CoreOrganizationPreference +import com.infomaniak.auth.lib.models.migration.SharedApiToken +import com.infomaniak.core.auth.models.user.preferences.Country +import com.infomaniak.core.auth.models.user.preferences.Language +import com.infomaniak.core.auth.models.user.preferences.OrganizationPreference import com.infomaniak.core.auth.models.user.preferences.Preferences as CorePreferences -import com.infomaniak.core.auth.models.user.preferences.TimeZone as CoreTimeZone -import com.infomaniak.core.auth.models.user.preferences.security.AuthDevices as CoreAuthDevices -import com.infomaniak.core.auth.models.user.preferences.security.Security as CoreSecurity +import com.infomaniak.core.auth.models.user.preferences.TimeZone +import com.infomaniak.core.auth.models.user.preferences.security.AuthDevices +import com.infomaniak.core.auth.models.user.preferences.security.Security import com.infomaniak.lib.login.ApiToken as LoginApiToken -fun LoginApiToken.toMigrationApiToken(): MigrationApiToken { - return MigrationApiToken( +fun LoginApiToken.toSharedApiToken(): SharedApiToken { + return SharedApiToken( accessToken = accessToken, tokenType = tokenType, userId = userId, ) } +fun SharedApiToken.toLoginApiToken() = LoginApiToken( + accessToken = accessToken, + tokenType = tokenType, + userId = userId, +) + fun SharedUserProfile.toUser(): User { return User( id = id, @@ -59,35 +65,78 @@ fun SharedUserProfile.toUser(): User { ) } +fun User.toSharedUser(): SharedUserProfile { + return SharedUserProfile( + id = id, + displayName = displayName, + firstname = firstname, + lastname = lastname, + email = email, + avatar = avatar, + login = login, + isStaff = isStaff, + preferences = preferences.toPreferences(), + apiToken = apiToken.toSharedApiToken(), + ) +} + private fun Preferences.toCorePreferences() = CorePreferences( - security = security?.toCoreSecurity(), - organizationPreference = organizationPreference.toCoreOrganizationPreference(), - language = language.toCoreLanguage(), - country = country.toCoreCountry(), - timezone = timezone?.toCoreTimeZone(), + security = security?.toSecurity(), + organizationPreference = organizationPreference.toOrganizationPreference(), + language = language.toLanguage(), + country = country.toCountry(), + timezone = timezone?.toTimeZone(), +) + +private fun CorePreferences.toPreferences() = Preferences( + security = security?.toSharedSecurity(), + organizationPreference = organizationPreference.toSharedOrganizationPreference(), + language = language.toSharedLanguage(), + country = country.toSharedCountry(), + timezone = timezone?.toSharedTimeZone(), ) -private fun SharedLanguage.toCoreLanguage() = CoreLanguage( +private fun SharedLanguage.toLanguage() = Language( shortName = shortName, locale = locale, shortLocale = shortLocale, ) -private fun SharedCountry.toCoreCountry() = CoreCountry( +private fun Language.toSharedLanguage() = SharedLanguage( + shortName = shortName, + locale = locale, + shortLocale = shortLocale, +) + +private fun SharedCountry.toCountry() = Country( shortName = shortName, isEnabled = isEnabled, ) -private fun SharedTimeZone.toCoreTimeZone() = CoreTimeZone( +private fun Country.toSharedCountry() = SharedCountry( + shortName = shortName, + isEnabled = isEnabled, +) + +private fun SharedTimeZone.toTimeZone() = TimeZone( + gmt = gmt, +) + +private fun TimeZone.toSharedTimeZone() = SharedTimeZone( gmt = gmt, ) -private fun SharedOrganizationPreference.toCoreOrganizationPreference() = CoreOrganizationPreference( +private fun SharedOrganizationPreference.toOrganizationPreference() = OrganizationPreference( + currentOrganizationId = currentOrganizationId, + lastLoginAt = lastLoginAt, +) + +private fun OrganizationPreference.toSharedOrganizationPreference() = SharedOrganizationPreference( currentOrganizationId = currentOrganizationId, lastLoginAt = lastLoginAt, ) -private fun SharedSecurity.toCoreSecurity() = CoreSecurity( +private fun SharedSecurity.toSecurity() = Security( score = score, hasRecoveryEmail = hasRecoveryEmail, hasValidPhone = hasValidPhone, @@ -102,10 +151,28 @@ private fun SharedSecurity.toCoreSecurity() = CoreSecurity( lastLoginAt = lastLoginAt, dateLastChangedPassword = dateLastChangedPassword, doubleAuthMethod = doubleAuthMethod, - authDevices = authDevices?.mapTo(ArrayList()) { it.toCoreAuthDevices() }, + authDevices = authDevices?.mapTo(ArrayList()) { it.toAuthDevices() }, ) -private fun SharedAuthDevices.toCoreAuthDevices() = CoreAuthDevices( +private fun Security.toSharedSecurity() = SharedSecurity( + score = score, + hasRecoveryEmail = hasRecoveryEmail, + hasValidPhone = hasValidPhone, + emailValidatedAt = emailValidatedAt, + otp = otp, + sms = sms, + smsPhone = smsPhone, + yubikey = yubikey, + infomaniakApplication = infomaniakApplication, + doubleAuth = doubleAuth, + remainingRescueCode = remainingRescueCode, + lastLoginAt = lastLoginAt, + dateLastChangedPassword = dateLastChangedPassword, + doubleAuthMethod = doubleAuthMethod, + authDevices = authDevices?.mapTo(ArrayList()) { it.toSharedAuthDevices() }, +) + +private fun SharedAuthDevices.toAuthDevices() = AuthDevices( id = id, name = name, lastConnexion = lastConnexion ?: 0L, @@ -117,8 +184,14 @@ private fun SharedAuthDevices.toCoreAuthDevices() = CoreAuthDevices( deletedAt = deletedAt, ) -private fun MigrationApiToken.toLoginApiToken() = LoginApiToken( - accessToken = accessToken, - tokenType = tokenType, - userId = userId, +private fun AuthDevices.toSharedAuthDevices() = SharedAuthDevices( + id = id, + name = name, + lastConnexion = lastConnexion, + userAgent = userAgent, + userIp = userIp, + device = device, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, ) diff --git a/multiplatform-lib/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json b/multiplatform-lib/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json index 7c7b64d9..12af54c7 100644 --- a/multiplatform-lib/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json +++ b/multiplatform-lib/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "ab7e6c6aadc7811b61b0ffe94bc67ec1", + "identityHash": "969c0701051de7596ddc1d094141aed5", "entities": [ { "tableName": "AccountEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `fullName` TEXT NOT NULL, `initials` TEXT NOT NULL, `email` TEXT NOT NULL, `avatarUrl` TEXT, `status` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `fullName` TEXT NOT NULL, `initials` TEXT NOT NULL, `email` TEXT NOT NULL, `avatarUrl` TEXT, `status` INTEGER NOT NULL, `securityScore` INTEGER, `lastPasswordUpdate` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -42,6 +42,16 @@ "columnName": "status", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "securityScore", + "columnName": "securityScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastPasswordUpdate", + "columnName": "lastPasswordUpdate", + "affinity": "INTEGER" } ], "primaryKey": { @@ -54,7 +64,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab7e6c6aadc7811b61b0ffe94bc67ec1')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '969c0701051de7596ddc1d094141aed5')" ] } } \ No newline at end of file diff --git a/multiplatform-lib/src/commonMain/kotlin/Account.kt b/multiplatform-lib/src/commonMain/kotlin/Account.kt index db4a5d00..e0b3cedc 100644 --- a/multiplatform-lib/src/commonMain/kotlin/Account.kt +++ b/multiplatform-lib/src/commonMain/kotlin/Account.kt @@ -27,7 +27,14 @@ data class Account( ) { sealed interface Status { - data object LoggedIn : Status + /** + * @property securityScore can range from 0 to 5 + * @property passwordChangedAck is set when the password changed. Call it to acknowledge the change and dismiss it. + */ + data class LoggedIn( + val securityScore: Int? = null, + val passwordChangedAck: (() -> Unit)? = null, + ) : Status sealed interface NotConnected : Status { diff --git a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt index e18b45a1..dbeb4bf2 100644 --- a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt +++ b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt @@ -26,14 +26,13 @@ import com.infomaniak.auth.lib.internal.network.ApiRoutes import com.infomaniak.auth.lib.internal.repositories.AccountsRepository import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository import com.infomaniak.auth.lib.internal.requests.AuthenticatorRequest +import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds abstract class AuthenticatorFacade internal constructor() { @@ -48,7 +47,7 @@ abstract class AuthenticatorFacade internal constructor() { * * Will lead to [appStatus] to switch to the [AppStatus.LoggingIn] case. */ - abstract suspend fun addAccounts(connectedAccounts: List) + abstract suspend fun addAccounts(connectedAccounts: List) /** * Remove account from the authenticator. @@ -62,6 +61,8 @@ abstract class AuthenticatorFacade internal constructor() { @Throws(Exception::class) abstract suspend fun refreshTokenFor(userId: Long) + abstract fun refreshUserProfiles() + companion object { fun create( @@ -108,36 +109,5 @@ abstract class AuthenticatorFacade internal constructor() { ) } - fun dummyInstance( - userAgent: String, - apiHost: String, - crashReport: CrashReportInterface?, - scope: CoroutineScope = CoroutineScope(Dispatchers.Default), - loadingDurationMillis: Long = 2.seconds.inWholeMilliseconds, - resetAfterMillis: Long = 20.seconds.inWholeMilliseconds, - ): AuthenticatorFacade { - val routes = ApiRoutes(apiHost) - val webAuthnRepository = WebAuthnRepository( - authenticatorRequest = AuthenticatorRequest( - httpClient = ApiClientProvider( - scope = scope, - userAgent = userAgent, - routes = routes, - crashReport = crashReport, - ).httpClient, - routes = routes, - ) - ) - val accountsRepository = AccountsRepository(getAccountsRoomDatabase(databaseNameOrPath = null)) - val authenticatorManager = - AuthenticatorManager(webAuthnRepository = webAuthnRepository, accountsRepository = accountsRepository) - return DummyAuthenticatorFacade( - accountsRepository = accountsRepository, - authenticatorManager = authenticatorManager, - scope = scope, - loadingDuration = loadingDurationMillis.milliseconds, - resetAfter = resetAfterMillis.milliseconds, - ) - } } } diff --git a/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt b/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt deleted file mode 100644 index 589adf5b..00000000 --- a/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Infomaniak Authenticator - Android - * Copyright (C) 2026 Infomaniak Network SA - * - * This program 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. - * - * This program 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 this program. If not, see . - */ -package com.infomaniak.auth.lib - -import com.infomaniak.auth.lib.internal.db.AccountEntity -import com.infomaniak.auth.lib.internal.extensions.toAccount -import com.infomaniak.auth.lib.internal.extensions.toEntity -import com.infomaniak.auth.lib.internal.managers.AuthenticatorManager -import com.infomaniak.auth.lib.internal.repositories.AccountsRepository -import com.infomaniak.auth.lib.internal.utils.raceOf -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.shareIn -import kotlin.time.Duration - -class DummyAuthenticatorFacade internal constructor( - private val accountsRepository: AccountsRepository, - private val authenticatorManager: AuthenticatorManager, - scope: CoroutineScope, - loadingDuration: Duration, - resetAfter: Duration, -) : AuthenticatorFacade() { - override val accounts: Flow> - - private var _accounts: List by MutableStateFlow>(emptyList()).also { - accounts = accountsRepository.getAccounts().map { - it.map { it.toAccount(action = null) } - } - }::value - - private val next = Channel() - - override val appStatus: SharedFlow = flow { - var i = 0 - var isMigratingFromLegacyKAuth = true - while (true) { - val loginRequiredStatus: AppStatus.LoginRequired = when { - isMigratingFromLegacyKAuth -> AppStatus.LoginRequired.MigratingFromLegacyKAuth(proceed = { next.trySend(Unit) }) - else -> AppStatus.LoginRequired.NotMigrating - } - emit(loginRequiredStatus) - next.receive() // Waits for the addAccounts function or the proceed lambda to be called. - emit(AppStatus.LoggingIn) - delay(loadingDuration) - if (isMigratingFromLegacyKAuth) { - isMigratingFromLegacyKAuth = false - val legacyAccount = Account( - id = 0, - fullName = "John", - initials = "Smith", - email = "john.smith@example.com", - avatarUrl = "https://avatars.githubusercontent.com/u/1788629?v=4", - status = Account.Status.NotConnected.AttemptingToConnect - ) - _accounts += legacyAccount.copy( - status = Account.Status.NotConnected.ReLogin( - legacyAccount = legacyAccount, - lastIssue = null, - sendCredentials = { next.trySend(Unit) }, - ) - ) - emit(AppStatus.LoggingIn) - next.receive() - } - emit(AppStatus.EverythingReady(proceed = { next.trySend(Unit) })) - next.receive() - do { - emit(AppStatus.SetupComplete(addAnAccount = { next.trySend(Unit) })) - val addAnAccount = raceOf( - { next.receive(); true }, - { delay(resetAfter); false }, - ) - if (addAnAccount) { - emit(AppStatus.AddingAnAccount(cancel = { next.trySend(Unit) })) - next.receive() - } - } while (addAnAccount) - i++ - } - }.distinctUntilChanged().shareIn(scope, SharingStarted.Lazily, replay = 1) - - override suspend fun addAccounts(connectedAccounts: List) { - _accounts += connectedAccounts - if (connectedAccounts.isNotEmpty()) next.trySend(Unit) - val accountWithNoError = connectedAccounts.first() - val accountInError = accountWithNoError.copy(id = 123).toEntity(AccountEntity.Status.PasskeyRegistrationPending) - accountsRepository.upsertAccounts(connectedAccounts.map { it.toEntity(AccountEntity.Status.LoggedIn) }) - accountsRepository.upsertAccounts(listOf(accountInError)) - } - - override suspend fun removeAccount(token: String, id: Long) { - authenticatorManager.removeAccount(token, id) - accountsRepository.deleteAccount(id) - } - - override suspend fun refreshTokenFor(userId: Long) { - TODO("Not yet implemented") - } -} diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index 5e83f250..d2fd8bb5 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -27,20 +27,23 @@ import com.infomaniak.auth.lib.CredentialsForMigration import com.infomaniak.auth.lib.Issue import com.infomaniak.auth.lib.Issue.Retriable.Cause import com.infomaniak.auth.lib.internal.db.AccountEntity +import com.infomaniak.auth.lib.internal.db.AccountEntity.Status import com.infomaniak.auth.lib.internal.db.AccountsDatabase import com.infomaniak.auth.lib.internal.extensions.cancellable import com.infomaniak.auth.lib.internal.extensions.firstOrElse import com.infomaniak.auth.lib.internal.extensions.toAccount -import com.infomaniak.auth.lib.internal.extensions.toEntity +import com.infomaniak.auth.lib.internal.extensions.toAccountEntity import com.infomaniak.auth.lib.internal.managers.AuthenticatorManager import com.infomaniak.auth.lib.internal.managers.MigrationManager import com.infomaniak.auth.lib.internal.utils.DynamicLazyMap +import com.infomaniak.auth.lib.internal.utils.buildFlowWithElements import com.infomaniak.auth.lib.internal.utils.launchRacer import com.infomaniak.auth.lib.internal.utils.race import com.infomaniak.auth.lib.internal.utils.raceOf import com.infomaniak.auth.lib.internal.utils.sharedFlow import com.infomaniak.auth.lib.internal.utils.waitForComplete import com.infomaniak.auth.lib.internal.utils.withTimeoutOrNull +import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile import com.infomaniak.auth.lib.network.exceptions.ApiException import com.infomaniak.auth.lib.network.exceptions.NetworkException import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge @@ -57,16 +60,15 @@ import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn @@ -97,25 +99,28 @@ internal class AuthenticatorFacadeImpl( entities.any { entity -> entity.isLoggedIn } }.distinctUntilChanged().shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 1) - private val accountsToLogin = DynamicLazyMap.sharedFlow( + private val userIdsToStatusFlows = DynamicLazyMap.sharedFlow( coroutineScope = coroutineScope, cacheManager = { _, _ -> delay(5.seconds) // Should be more than enough to keep the state between re-uses. } ) { userId: Long -> - loginAttemptsFlow(userId) + accountStatusForUser(userId) } private val proceedMigration: CompletableJob = Job() - private val flowOfNull = flowOf(null) + private val profileRefreshesTrigger = MutableSharedFlow(extraBufferCapacity = 1) - override val accounts: Flow> = channelFlow { - accountEntities.collectLatest { entities -> - val idsOfAccountsToLogIn = entities.mapNotNull { entity -> entity.id.takeUnless { entity.isLoggedIn } }.toSet() - accountsToLogin.useElements(idsOfAccountsToLogIn) { map -> - accountsFlow(entities, map).collectLatest { send(it) } - awaitCancellation() // Stay in the useElements scope until a new list of accounts is received. + /** [Account.status] values come from the [accountStatusForUser] function. */ + override val accounts: Flow> = accountEntities.flatMapLatest { entities -> + + val userIds = entities.mapTo(mutableSetOf()) { it.id } + + userIdsToStatusFlows.buildFlowWithElements(userIds) { userIdsToStatusFlow -> + val flowsOfStatus = entities.map { userIdsToStatusFlow.getValue(it.id) } + combine(flowsOfStatus) { statuses -> + entities.mapIndexed { index, entity -> entity.toAccount(statuses[index]) } } } }.flowOn(Dispatchers.Default).distinctUntilChanged().shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) @@ -123,9 +128,9 @@ internal class AuthenticatorFacadeImpl( override val appStatus: SharedFlow = appStatusFlow() .shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) - override suspend fun addAccounts(connectedAccounts: List) { + override suspend fun addAccounts(connectedAccounts: List) { val entities = connectedAccounts.map { - it.toEntity(AccountEntity.Status.PasskeyRegistrationPending) + it.toAccountEntity(Status.PasskeyRegistrationPending) } dao.upsert(entities) } @@ -139,24 +144,18 @@ internal class AuthenticatorFacadeImpl( val token = authenticatorManager.getToken(clientId, userId).firstOrElse { error("Could not get the key for user $userId from the storage: $it") } - authenticatorBridge.persistTokenForAccount(userId, token.accessToken) + authenticatorBridge.persistTokenForAccount(userId, token) } - private fun accountsFlow( - entities: List, - accountsToLogin: Map> - ): Flow> { - val flows = entities.map { accountsToLogin[it.id] ?: flowOfNull } - return combine(flows) { notConnectedActions -> - entities.mapIndexed { index, entity -> entity.toAccount(notConnectedActions[index]) } - } + override fun refreshUserProfiles() { + profileRefreshesTrigger.tryEmit(Unit) } private fun appStatusFlow(): Flow = flow { var needsToShowEverythingReady = false val appStatusFlow: Flow = accountEntities.transformLatest { entities -> - val atLeastOneConnectedAccount = entities.any { it.status == AccountEntity.Status.LoggedIn } + val atLeastOneConnectedAccount = entities.any { it.status == Status.LoggedIn } val noConnectedAccount = !atLeastOneConnectedAccount if (noConnectedAccount) { @@ -166,7 +165,7 @@ internal class AuthenticatorFacadeImpl( /** Waiting for [addAccounts] to be called, which will cancel this, as `accountEntities` emits. */ awaitCancellation() } else { - val needsMigration = entities.any { it.status == AccountEntity.Status.ToBeMigrated } + val needsMigration = entities.any { it.status == Status.ToBeMigrated } if (needsMigration) { emit(AppStatus.LoginRequired.MigratingFromLegacyKAuth(proceed = proceedMigration::complete)) proceedMigration.join() @@ -220,18 +219,21 @@ internal class AuthenticatorFacadeImpl( ) } - private fun loginAttemptsFlow(userId: Long): Flow = + private fun accountStatusForUser(userId: Long): Flow = dao.getAccountAsFlow(userId).transformLatest { entity -> emit(null) when (entity?.status) { - AccountEntity.Status.ToBeMigrated -> migrationAttempts(entity) - AccountEntity.Status.PasskeyRegistrationPending, AccountEntity.Status.FirstPasskeyAuthenticationPending -> { + Status.ToBeMigrated -> migrationAttempts(entity) + Status.PasskeyRegistrationPending, Status.FirstPasskeyAuthenticationPending -> { registrationAttempts(entity) } - AccountEntity.Status.RestoringFromBackup, AccountEntity.Status.DeletingOldKeyAfterRestoration -> { + Status.RestoringFromBackup, Status.DeletingOldKeyAfterRestoration -> { restoreFromBackupAttempts(account = entity) } - AccountEntity.Status.LoggedIn, null -> Unit // Should not happen in practice. + Status.LoggedIn, Status.PasswordChanged -> { + handledLoggedInState(entity) + } + null -> Unit // Should not happen in practice. } } @@ -248,8 +250,8 @@ internal class AuthenticatorFacadeImpl( private suspend fun FlowCollector.registrationAttempts(notRegisteredAccount: AccountEntity) { val passKeyAlreadyRegistered = when (val accountStatus = notRegisteredAccount.status) { - AccountEntity.Status.PasskeyRegistrationPending -> false - AccountEntity.Status.FirstPasskeyAuthenticationPending -> true + Status.PasskeyRegistrationPending -> false + Status.FirstPasskeyAuthenticationPending -> true else -> throw IllegalArgumentException("registrationAttempts doesn't support $accountStatus") } val userId = notRegisteredAccount.id @@ -259,16 +261,23 @@ internal class AuthenticatorFacadeImpl( if (!passKeyAlreadyRegistered) { // Just in case orphans passkeys are lying around, we want to make sure to start from a clean state. authenticatorManager.deleteKeysFor(notRegisteredAccount.id) - val _ = authenticatorManager.registerPasskey(token, userId) - dao.upsert(notRegisteredAccount.copy(status = AccountEntity.Status.FirstPasskeyAuthenticationPending)) + val _ = authenticatorManager.registerPasskey(token.accessToken, userId) + dao.upsert(notRegisteredAccount.copy(status = Status.FirstPasskeyAuthenticationPending)) // The DB update above is expected to cause the cancellation & restart of this. } val token = authenticatorManager.getToken( clientId = clientId, userId = userId, ).firstOrElse { error("Key not found: ${it.details}") } - authenticatorBridge.persistTokenForAccount(userId, token.accessToken) - dao.upsert(notRegisteredAccount.copy(status = AccountEntity.Status.LoggedIn)) + authenticatorBridge.persistTokenForAccount(userId, token) + val profile = authenticatorManager.getUserProfile(token.accessToken) + dao.upsert( + notRegisteredAccount.copy( + securityScore = profile.preferences.security?.score, + lastPasswordUpdate = profile.preferences.security?.dateLastChangedPassword, + status = Status.LoggedIn + ) + ) } } @@ -282,7 +291,7 @@ internal class AuthenticatorFacadeImpl( * 4. Authenticate with it, getting a new access token */ private suspend fun FlowCollector.migrationAttempts(accountToMigrate: AccountEntity) { - require(accountToMigrate.status == AccountEntity.Status.ToBeMigrated) + require(accountToMigrate.status == Status.ToBeMigrated) if (shouldTryImmediateLogin()) { tryCrossAppLogin(accountToMigrate) { return } @@ -328,19 +337,20 @@ internal class AuthenticatorFacadeImpl( authentication: MigrationAuthentication, ): Boolean { val userId = notConnectedAccount.id - val succeeded = migrationManager.tryMigrating( + val userProfile = migrationManager.tryMigrating( userId = userId, authentication = authentication, - persistUser = { apiToken -> - val userProfile = authenticatorManager.getUserProfile(apiToken.accessToken) - userProfile.apiToken = apiToken - authenticatorBridge.persistUserProfile(userProfile) - }, + persistUser = authenticatorBridge::persistUserProfile, ) - if (succeeded.not()) return false + if (userProfile == null) return false - dao.upsert(notConnectedAccount.copy(status = AccountEntity.Status.LoggedIn)) + val updatedAccount = notConnectedAccount.copy( + status = Status.LoggedIn, + securityScore = userProfile.preferences.security?.score, + lastPasswordUpdate = userProfile.preferences.security?.dateLastChangedPassword + ) + dao.upsert(updatedAccount) return true } @@ -379,7 +389,7 @@ internal class AuthenticatorFacadeImpl( it.printStackTrace() val issue = ReLogin.DismissableIssue( dismiss = { issueDismissals.trySend(Unit) }, - cause = it.toIssueCause(accountToMigrate.id) + cause = it.toIssueCause() ) status.copy(lastIssue = issue) } @@ -411,6 +421,46 @@ internal class AuthenticatorFacadeImpl( } } + private suspend fun FlowCollector.handledLoggedInState(account: AccountEntity) { + val needsToAcknowledgePasswordUpdate: Boolean = account.status == Status.PasswordChanged + if (needsToAcknowledgePasswordUpdate) { + val previousStatus = waitForComplete { passwordChangedAcknowledgedAsync -> + Account.Status.LoggedIn( + securityScore = account.securityScore, + passwordChangedAck = passwordChangedAcknowledgedAsync::complete + ).also { emit(it) } + } + emit(previousStatus.copy(passwordChangedAck = null)) + dao.upsert(account.copy(status = Status.LoggedIn)) + } else { + emit(Account.Status.LoggedIn(securityScore = account.securityScore)) + } + updateUserProfileLoop(account) + } + + private suspend fun updateUserProfileLoop(account: AccountEntity) { + require(account.isLoggedIn) + val token = authenticatorBridge.getTokenFromDatabase(account.id) ?: return + while (true) { + runCatching { + val profile = authenticatorManager.getUserProfile(token.accessToken) + val profileSecurity = profile.preferences.security ?: return + val newStatus = when (account.lastPasswordUpdate) { + profileSecurity.dateLastChangedPassword -> account.status + else -> Status.PasswordChanged + } + val updatedAccount = account.copy(status = newStatus, securityScore = profileSecurity.score) + if (updatedAccount != account) { // Avoid re-trigger loops when we're up to date. + dao.upsert(updatedAccount) + } + }.cancellable().onFailure { + it.printStackTrace() + it.reportIfNeeded(account.id, message = "profile update refresh failed") + } + profileRefreshesTrigger.first() + } + } + private suspend inline fun FlowCollector.withRetries( userId: Long, onGiveUp: () -> Unit = {}, @@ -421,14 +471,15 @@ internal class AuthenticatorFacadeImpl( return block() }.cancellable().onFailure { it.printStackTrace() + it.reportIfNeeded(userId, "re-login migration attempt failed") if (it is IllegalStateException || it is IllegalArgumentException) { // Local errors, no recourse. val issue = Issue.NonRetriable(it.message ?: it::class.simpleName ?: "$it") emit(Account.Status.NotConnected.LoginFailed(issue)) awaitCancellation() } - val issueReason = it.toIssueCause(userId) + val issueCause = it.toIssueCause() val shouldRetryAsync = CompletableDeferred() - val issue = Issue.Retriable(cause = issueReason, proceed = shouldRetryAsync::complete) + val issue = Issue.Retriable(cause = issueCause, proceed = shouldRetryAsync::complete) emit(Account.Status.NotConnected.LoginFailed(issue)) val shouldRetry = shouldRetryAsync.await() if (shouldRetry) continue else onGiveUp() @@ -436,20 +487,25 @@ internal class AuthenticatorFacadeImpl( } } - private fun Throwable.toIssueCause(userId: Long): Cause = when (this) { + private fun Throwable.toIssueCause(): Cause = when (this) { is NetworkException, is IOException -> Cause.NetworkIssue is ApiException if (statusCode == 503) -> Cause.ServerUnavailable is ApiException.ApiErrorException -> { - crashReport.capture(userId, "re-login migration attempt failed", this) Cause.Other(12_000 + statusCode, "http $statusCode $errorCode $errorMessage") } is ApiException.UnexpectedApiErrorFormatException -> { - crashReport.capture(userId, "re-login migration attempt failed", this) Cause.Other(22_000 + statusCode, "http $statusCode $bodyResponse") } else -> { - crashReport.capture(userId, "re-login migration attempt failed", this) Cause.Other(11_000, message ?: this::class.simpleName ?: "$this") } } + + private fun Throwable.reportIfNeeded(userId: Long, message: String) { + when (this) { + is NetworkException, is IOException -> Unit + is ApiException if (statusCode == 503) -> Unit + else -> crashReport.capture(userId, message, this) + } + } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt index 9d417017..291af3ce 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt @@ -29,8 +29,10 @@ internal data class AccountEntity( val email: String, val avatarUrl: String? = null, val status: Status, + val securityScore: Int? = null, + val lastPasswordUpdate: Long? = null ) { - val isLoggedIn: Boolean get() = status == Status.LoggedIn + val isLoggedIn: Boolean get() = status == Status.LoggedIn || status == Status.PasswordChanged enum class Status { @@ -58,5 +60,7 @@ internal data class AccountEntity( RestoringFromBackup, DeletingOldKeyAfterRestoration, + + PasswordChanged, } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt index 1f7cbfce..b30ea05a 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt @@ -32,6 +32,9 @@ internal interface AccountsDao { @Query("SELECT * FROM AccountEntity WHERE id = :id") fun getAccountAsFlow(id: Long): Flow + @Query("SELECT * FROM AccountEntity WHERE id = :id") + suspend fun getAccount(id: Long): AccountEntity? + @Upsert suspend fun upsert(account: AccountEntity) diff --git "a/multiplatform-lib/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt" "b/multiplatform-lib/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt" index d2a05c5c..0cca7575 100644 --- "a/multiplatform-lib/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt" +++ "b/multiplatform-lib/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt" @@ -19,34 +19,34 @@ package com.infomaniak.auth.lib.internal.extensions import com.infomaniak.auth.lib.Account import com.infomaniak.auth.lib.internal.db.AccountEntity -import com.infomaniak.auth.lib.internal.db.AccountEntity.Status import com.infomaniak.auth.lib.internal.models.LegacyUser +import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile -internal fun AccountEntity.toAccount(action: Account.Status.NotConnected?): Account { +internal fun AccountEntity.toAccount(status: Account.Status?): Account { return Account( id = id, fullName = fullName, initials = initials, email = email, avatarUrl = avatarUrl, - status = when (status) { - AccountEntity.Status.LoggedIn -> Account.Status.LoggedIn - else -> action ?: Account.Status.NotConnected.AttemptingToConnect - } + status = status ?: Account.Status.NotConnected.AttemptingToConnect ) } -internal fun Account.toEntity(status: AccountEntity.Status): AccountEntity { +internal fun SharedUserProfile.toAccountEntity(status: AccountEntity.Status): AccountEntity { return AccountEntity( - id = id, - fullName = fullName, - initials = initials, + id = id.toLong(), + fullName = "$firstname $lastname", + initials = getInitials(), email = email, - avatarUrl = avatarUrl, - status = status + avatarUrl = avatar, + status = status, + securityScore = preferences.security?.score, + lastPasswordUpdate = preferences.security?.dateLastChangedPassword, ) } + internal fun LegacyUser.toEntity(): AccountEntity { val initials = "${displayName.firstOrNull()?.uppercase()}" + "${displayName.substring(displayName.indexOf(" ") + 1).firstOrNull()?.uppercase()}" @@ -56,6 +56,6 @@ internal fun LegacyUser.toEntity(): AccountEntity { initials = initials, email = email, avatarUrl = avatar, - status = Status.ToBeMigrated + status = AccountEntity.Status.ToBeMigrated ) } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt index a1b890a3..b2067d4f 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt @@ -22,6 +22,7 @@ import com.infomaniak.auth.lib.internal.db.AccountEntity import com.infomaniak.auth.lib.internal.db.AccountsDatabase import com.infomaniak.auth.lib.internal.extensions.firstOrElse import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository +import com.infomaniak.auth.lib.models.migration.SharedApiToken internal class AccountRestorer( accountsDatabase: AccountsDatabase, @@ -32,7 +33,7 @@ internal class AccountRestorer( private val dao = accountsDatabase.getDao() - suspend fun restore(account: AccountEntity, persistToken: suspend (userId: Long, token: String) -> Unit) { + suspend fun restore(account: AccountEntity, persistToken: suspend (userId: Long, token: SharedApiToken) -> Unit) { val keyPairManager = authenticatorManager.keyPairManager val existingKeyIds = keyPairManager.getSortedKeyIds(MatchOn.UserId(account.id)) checkKeyCountIsOneOrTwo(existingKeyIds) @@ -68,7 +69,7 @@ internal class AccountRestorer( userId = account.id, keyIdOrDefault = newKeyId, ).firstOrElse { error(it) } - persistToken(account.id, tokenWithNewPassKey.accessToken) + persistToken(account.id, tokenWithNewPassKey) // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore oldKeyId?.let { keyId -> webAuthnRepository.deletePasskeyIfExists(tokenWithNewPassKey.accessToken, keyId) @@ -80,6 +81,7 @@ internal class AccountRestorer( private fun AccountEntity.hasNewKeyAlreadyBeenRegistered() = when (status) { AccountEntity.Status.RestoringFromBackup -> false AccountEntity.Status.DeletingOldKeyAfterRestoration -> true + AccountEntity.Status.PasswordChanged, AccountEntity.Status.ToBeMigrated, AccountEntity.Status.PasskeyRegistrationPending, AccountEntity.Status.FirstPasskeyAuthenticationPending, diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 2ee8d96b..a7a604fa 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -34,6 +34,7 @@ import com.infomaniak.auth.lib.internal.otp.getSecretFor import com.infomaniak.auth.lib.internal.otp.needMigration import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository import com.infomaniak.auth.lib.models.migration.SharedApiToken +import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile import com.infomaniak.auth.lib.network.exceptions.ApiException import com.osmerion.kotlin.io.encoding.Base32 import io.ktor.utils.io.core.toByteArray @@ -60,7 +61,7 @@ internal class MigrationManager( } } - suspend fun restore(account: AccountEntity, persistToken: suspend (userId: Long, token: String) -> Unit) { + suspend fun restore(account: AccountEntity, persistToken: suspend (userId: Long, token: SharedApiToken) -> Unit) { val restorer = AccountRestorer( accountsDatabase = accountsDatabase, authenticatorManager = authenticatorManager, @@ -78,7 +79,7 @@ internal class MigrationManager( } /** - * @return false if the backend returned the `access_denied`, which means a correct password is needed (in [authentication]). + * @return null if the backend returned the `access_denied`, which means a correct password is needed (in [authentication]). * * @throws IOException in case of networking or I/O issues * @throws ApiException in case the backend returns a non-successful response (except for "access_denied") @@ -86,9 +87,9 @@ internal class MigrationManager( */ suspend fun tryMigrating( userId: Long, - persistUser: suspend (apiToken: SharedApiToken) -> Unit, + persistUser: suspend (userProfile: SharedUserProfile) -> Unit, authentication: MigrationAuthentication, - ): Boolean { + ): SharedUserProfile? { @OptIn(ExperimentalUuidApi::class) val deviceId = Uuid.random().toHexDashString() val secret = checkNotNull(getSecretFor(userId)) { "Couldn't find the secret for user $userId" } @@ -122,7 +123,7 @@ internal class MigrationManager( }.cancellable().getOrElse { if (it !is ApiException.ApiErrorException) throw it when (it.errorCode) { - "access_denied", "not_authorized" -> return false + "access_denied", "not_authorized" -> return null else -> throw it } } @@ -138,7 +139,12 @@ internal class MigrationManager( clientId = clientId, userId = userId, ).firstOrElse { error("Didn't find the key locally: $it") } - persistUser(apiTokenFromPasskey) + + val userProfile = authenticatorManager.getUserProfile(apiTokenFromPasskey.accessToken).also { + it.apiToken = apiTokenFromPasskey + } + persistUser(userProfile) + webAuthnRepository.completeMigration( token = apiTokenFromPasskey.accessToken, sessionId = migrationOptions.session, @@ -148,7 +154,7 @@ internal class MigrationManager( if (getLegacyAccounts().isEmpty()) deleteLegacyDB() - return true + return userProfile } private fun getOtp(secret: String, timestampSeconds: Long): String { diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/utils/DynamicLazyMapExtensions.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/DynamicLazyMapExtensions.kt new file mode 100644 index 00000000..de52bb4d --- /dev/null +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/DynamicLazyMapExtensions.kt @@ -0,0 +1,35 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program 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. + * + * This program 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 this program. If not, see . + */ +package com.infomaniak.auth.lib.internal.utils + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow + +/** + * Creates a Flow with the passed [block] using the values from this [DynamicLazyMap] corresponding to the passed [keys], + * while it is hot. + */ +internal fun DynamicLazyMap.buildFlowWithElements( + keys: Set, + block: (map: Map) -> Flow +): Flow = flow { + useElements(keys) { + emitAll(block(it)) + } +} diff --git a/multiplatform-lib/src/commonMain/kotlin/models/migration/user/SharedUserProfile.kt b/multiplatform-lib/src/commonMain/kotlin/models/migration/user/SharedUserProfile.kt index 81e3092f..3dbe6797 100644 --- a/multiplatform-lib/src/commonMain/kotlin/models/migration/user/SharedUserProfile.kt +++ b/multiplatform-lib/src/commonMain/kotlin/models/migration/user/SharedUserProfile.kt @@ -44,4 +44,6 @@ data class SharedUserProfile( */ @Transient var apiToken: SharedApiToken = SharedApiToken(accessToken = "", tokenType = "", userId = 0), -) +) { + fun getInitials() = "${firstname.firstOrNull()?.uppercase() ?: ""}${lastname.firstOrNull()?.uppercase() ?: ""}" +} diff --git a/multiplatform-lib/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt b/multiplatform-lib/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt index 1f91fd9e..c4a30d14 100644 --- a/multiplatform-lib/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt +++ b/multiplatform-lib/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt @@ -23,7 +23,7 @@ import kotlinx.serialization.Serializable @Serializable data class Preferences( - var security: SharedSecurity? = null, + var security: SharedSecurity?, @SerialName("account") var organizationPreference: SharedOrganizationPreference, var language: SharedLanguage, diff --git a/multiplatform-lib/src/commonMain/kotlin/network/interfaces/AuthenticatorBridge.kt b/multiplatform-lib/src/commonMain/kotlin/network/interfaces/AuthenticatorBridge.kt index 24b16637..b89aa8ea 100644 --- a/multiplatform-lib/src/commonMain/kotlin/network/interfaces/AuthenticatorBridge.kt +++ b/multiplatform-lib/src/commonMain/kotlin/network/interfaces/AuthenticatorBridge.kt @@ -22,7 +22,7 @@ import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile interface AuthenticatorBridge { suspend fun getTokenFromCrossAppLogin(userId: Long): SharedApiToken? - suspend fun getTokenFromDatabase(userId: Long): String? - suspend fun persistTokenForAccount(userId: Long, token: String) + suspend fun getTokenFromDatabase(userId: Long): SharedApiToken? + suspend fun persistTokenForAccount(userId: Long, token: SharedApiToken) suspend fun persistUserProfile(userProfile: SharedUserProfile) }