From 89dd5529084b146fdac91fb6294e8d89c13e172b Mon Sep 17 00:00:00 2001 From: Shannon Draeker <125921730+shannon-livefront@users.noreply.github.com> Date: Wed, 31 Jan 2024 23:05:09 -0700 Subject: [PATCH] BIT-785: Vault timeout policy (#924) --- .../repository/model/PolicyInformation.kt | 12 ++ .../util/SyncResponseJsonExtensions.kt | 10 +- .../repository/SettingsRepositoryImpl.kt | 45 ++++++ .../repository/di/PlatformRepositoryModule.kt | 5 +- .../accountsecurity/AccountSecurityScreen.kt | 100 ++++++++++++- .../AccountSecurityViewModel.kt | 55 ++++++-- .../platform/util/VaultTimeoutExtensions.kt | 22 +++ .../util/SyncResponseJsonExtensionsTest.kt | 71 +++++++--- .../repository/SettingsRepositoryTest.kt | 11 ++ .../AccountSecurityScreenTest.kt | 133 ++++++++++++++++++ .../AccountSecurityViewModelTest.kt | 49 +++++++ .../util/VaultTimeoutExtensionsTest.kt | 22 +++ 12 files changed, 498 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PolicyInformation.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PolicyInformation.kt index 90991cce71..2a34e6dd14 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PolicyInformation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/PolicyInformation.kt @@ -98,4 +98,16 @@ sealed class PolicyInformation { const val TYPE_PASSPHRASE: String = "passphrase" } } + + /** + * Represents a policy enforcing rules on the user's vault timeout settings. + */ + @Serializable + data class VaultTimeout( + @SerialName("minutes") + val minutes: Int?, + + @SerialName("action") + val action: String?, + ) : PolicyInformation() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt index 9d492c0cc5..121f547f2a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.util import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation +import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import kotlinx.serialization.json.Json @@ -29,10 +30,15 @@ val SyncResponseJson.Policy.policyInformation: PolicyInformation? get() = data?.toString()?.let { when (type) { PolicyTypeJson.MASTER_PASSWORD -> { - Json.decodeFromString(it) + Json.decodeFromStringOrNull(it) } + PolicyTypeJson.PASSWORD_GENERATOR -> { - Json.decodeFromString(it) + Json.decodeFromStringOrNull(it) + } + + PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> { + Json.decodeFromStringOrNull(it) } else -> null diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index 2b90ed7d7e..1bbec19cb9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -3,16 +3,21 @@ package com.x8bit.bitwarden.data.platform.repository import android.view.autofill.AutofillManager import com.x8bit.bitwarden.BuildConfig import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult +import com.x8bit.bitwarden.data.auth.repository.util.policyInformation import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager +import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction +import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme @@ -24,7 +29,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.Instant @@ -42,6 +49,7 @@ class SettingsRepositoryImpl( private val settingsDiskSource: SettingsDiskSource, private val vaultSdkSource: VaultSdkSource, private val biometricsEncryptionManager: BiometricsEncryptionManager, + private val policyManager: PolicyManager, private val dispatcherManager: DispatcherManager, ) : SettingsRepository { private val activeUserId: String? get() = authDiskSource.userState?.activeUserId @@ -286,6 +294,13 @@ class SettingsRepositoryImpl( ?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED, ) + init { + policyManager + .getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT) + .onEach { updateVaultUnlockSettingsIfNecessary(it) } + .launchIn(unconfinedScope) + } + override fun disableAutofill() { autofillManager.disableAutofillServices() @@ -451,6 +466,36 @@ class SettingsRepositoryImpl( ) } } + + /** + * Check the parameters of the vault unlock policy against the user's + * settings to determine whether to update the user's settings. + */ + private fun updateVaultUnlockSettingsIfNecessary( + policies: List, + ) { + // The vault timeout policy can only be implemented in organizations that have + // the single organization policy, meaning that if this is enabled, the user is + // only in one organization and hence there is only one result in the list. + val vaultUnlockPolicy = policies + .firstOrNull() + ?.policyInformation as? PolicyInformation.VaultTimeout + ?: return + + // Adjust the user's timeout or method if necessary to meet the policy requirements. + vaultUnlockPolicy.minutes?.let { maxMinutes -> + if ((vaultTimeout.vaultTimeoutInMinutes ?: Int.MAX_VALUE) > maxMinutes) { + vaultTimeout = VaultTimeout.Custom(maxMinutes) + } + } + vaultUnlockPolicy.action?.let { + vaultTimeoutAction = if (it == "lock") { + VaultTimeoutAction.LOCK + } else { + VaultTimeoutAction.LOGOUT + } + } + } } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt index 2b545342df..5cc937b3f9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt @@ -5,8 +5,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager +import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl @@ -44,12 +44,12 @@ object PlatformRepositoryModule { fun provideSettingsRepository( autofillManager: AutofillManager, autofillEnabledManager: AutofillEnabledManager, - appForegroundManager: AppForegroundManager, authDiskSource: AuthDiskSource, settingsDiskSource: SettingsDiskSource, vaultSdkSource: VaultSdkSource, encryptionManager: BiometricsEncryptionManager, dispatcherManager: DispatcherManager, + policyManager: PolicyManager, ): SettingsRepository = SettingsRepositoryImpl( autofillManager = autofillManager, @@ -59,5 +59,6 @@ object PlatformRepositoryModule { vaultSdkSource = vaultSdkSource, biometricsEncryptionManager = encryptionManager, dispatcherManager = dispatcherManager, + policyManager = policyManager, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index 665acf0334..3f53f13e72 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -37,10 +37,14 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState +import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenExternalLinkRow import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenLogoutConfirmationDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenPolicyWarningText import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow @@ -60,6 +64,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography import com.x8bit.bitwarden.ui.platform.theme.LocalPermissionsManager import com.x8bit.bitwarden.ui.platform.util.displayLabel +import com.x8bit.bitwarden.ui.platform.util.minutes import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern import java.time.LocalTime @@ -210,7 +215,15 @@ fun AccountSecurityScreen( .fillMaxWidth() .padding(horizontal = 16.dp), ) + SessionTimeoutPolicyRow( + vaultTimeoutPolicyMinutes = state.vaultTimeoutPolicyMinutes, + vaultTimeoutPolicyAction = state.vaultTimeoutPolicyAction, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) SessionTimeoutRow( + vaultTimeoutPolicyMinutes = state.vaultTimeoutPolicyMinutes, selectedVaultTimeoutType = state.vaultTimeout.type, onVaultTimeoutTypeSelect = remember(viewModel) { { viewModel.trySendAction(AccountSecurityAction.VaultTimeoutTypeSelect(it)) } @@ -219,6 +232,7 @@ fun AccountSecurityScreen( ) (state.vaultTimeout as? VaultTimeout.Custom)?.let { customTimeout -> SessionCustomTimeoutRow( + vaultTimeoutPolicyMinutes = state.vaultTimeoutPolicyMinutes, customVaultTimeout = customTimeout, onCustomVaultTimeoutSelect = remember(viewModel) { { @@ -231,6 +245,7 @@ fun AccountSecurityScreen( ) } SessionTimeoutActionRow( + vaultTimeoutPolicyAction = state.vaultTimeoutPolicyAction, selectedVaultTimeoutAction = state.vaultTimeoutAction, onVaultTimeoutActionSelect = remember(viewModel) { { viewModel.trySendAction(AccountSecurityAction.VaultTimeoutActionSelect(it)) } @@ -468,9 +483,44 @@ private fun UnlockWithPinRow( } } +@Composable +private fun SessionTimeoutPolicyRow( + vaultTimeoutPolicyMinutes: Int?, + vaultTimeoutPolicyAction: String?, + modifier: Modifier = Modifier, +) { + // Show the policy warning if applicable. + if (vaultTimeoutPolicyMinutes != null || !vaultTimeoutPolicyAction.isNullOrBlank()) { + // Calculate the hours and minutes to show in the policy label. + val hours = vaultTimeoutPolicyMinutes?.floorDiv(MINUTES_PER_HOUR) + val minutes = vaultTimeoutPolicyMinutes?.mod(MINUTES_PER_HOUR) + + // Get the localized version of the action. + val action = if (vaultTimeoutPolicyAction == "lock") { + R.string.lock.asText() + } else { + R.string.log_out.asText() + } + + val policyText = if (hours == null || minutes == null) { + R.string.vault_timeout_action_policy_in_effect.asText(action) + } else if (vaultTimeoutPolicyAction.isNullOrBlank()) { + R.string.vault_timeout_policy_in_effect.asText(hours, minutes) + } else { + R.string.vault_timeout_policy_with_action_in_effect.asText(hours, minutes, action) + } + + BitwardenPolicyWarningText( + text = policyText(), + modifier = modifier, + ) + } +} + @Suppress("LongMethod") @Composable private fun SessionTimeoutRow( + vaultTimeoutPolicyMinutes: Int?, selectedVaultTimeoutType: VaultTimeout.Type, onVaultTimeoutTypeSelect: (VaultTimeout.Type) -> Unit, modifier: Modifier = Modifier, @@ -492,6 +542,10 @@ private fun SessionTimeoutRow( when { shouldShowSelectionDialog -> { val vaultTimeoutOptions = VaultTimeout.Type.entries + .filter { + it.minutes <= (vaultTimeoutPolicyMinutes ?: Int.MAX_VALUE) + } + BitwardenSelectionDialog( title = stringResource(id = R.string.session_timeout), onDismissRequest = { shouldShowSelectionDialog = false }, @@ -535,11 +589,13 @@ private fun SessionTimeoutRow( @Suppress("LongMethod") @Composable private fun SessionCustomTimeoutRow( + vaultTimeoutPolicyMinutes: Int?, customVaultTimeout: VaultTimeout.Custom, onCustomVaultTimeoutSelect: (VaultTimeout.Custom) -> Unit, modifier: Modifier = Modifier, ) { var shouldShowTimePickerDialog by rememberSaveable { mutableStateOf(false) } + var shouldShowViolatesPoliciesDialog by remember { mutableStateOf(false) } val vaultTimeoutInMinutes = customVaultTimeout.vaultTimeoutInMinutes BitwardenTextRow( text = stringResource(id = R.string.custom), @@ -564,21 +620,49 @@ private fun SessionCustomTimeoutRow( initialMinute = vaultTimeoutInMinutes.mod(MINUTES_PER_HOUR), onTimeSelect = { hour, minute -> shouldShowTimePickerDialog = false - onCustomVaultTimeoutSelect( - VaultTimeout.Custom( - vaultTimeoutInMinutes = hour * MINUTES_PER_HOUR + minute, - ), - ) + + val totalMinutes = (hour * MINUTES_PER_HOUR) + minute + if (vaultTimeoutPolicyMinutes != null && + totalMinutes > vaultTimeoutPolicyMinutes + ) { + shouldShowViolatesPoliciesDialog = true + } else { + onCustomVaultTimeoutSelect( + VaultTimeout.Custom( + vaultTimeoutInMinutes = totalMinutes, + ), + ) + } }, onDismissRequest = { shouldShowTimePickerDialog = false }, is24Hour = true, ) } + + if (shouldShowViolatesPoliciesDialog) { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.warning.asText(), + message = R.string.vault_timeout_to_large.asText(), + ), + onDismissRequest = { + shouldShowViolatesPoliciesDialog = false + vaultTimeoutPolicyMinutes?.let { + onCustomVaultTimeoutSelect( + VaultTimeout.Custom( + vaultTimeoutInMinutes = it, + ), + ) + } + }, + ) + } } @Suppress("LongMethod") @Composable private fun SessionTimeoutActionRow( + vaultTimeoutPolicyAction: String?, selectedVaultTimeoutAction: VaultTimeoutAction, onVaultTimeoutActionSelect: (VaultTimeoutAction) -> Unit, modifier: Modifier = Modifier, @@ -587,7 +671,11 @@ private fun SessionTimeoutActionRow( var shouldShowLogoutActionConfirmationDialog by rememberSaveable { mutableStateOf(false) } BitwardenTextRow( text = stringResource(id = R.string.session_timeout_action), - onClick = { shouldShowSelectionDialog = true }, + onClick = { + // The option is not selectable if there's a policy in place. + if (vaultTimeoutPolicyAction != null) return@BitwardenTextRow + shouldShowSelectionDialog = true + }, modifier = modifier, ) { Text( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index f96311e35c..9c43778398 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -5,19 +5,24 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult +import com.x8bit.bitwarden.data.auth.repository.util.policyInformation +import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault +import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -36,6 +41,7 @@ class AccountSecurityViewModel @Inject constructor( private val vaultRepository: VaultRepository, private val settingsRepository: SettingsRepository, private val environmentRepository: EnvironmentRepository, + private val policyManager: PolicyManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] @@ -47,6 +53,8 @@ class AccountSecurityViewModel @Inject constructor( isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled, vaultTimeout = settingsRepository.vaultTimeout, vaultTimeoutAction = settingsRepository.vaultTimeoutAction, + vaultTimeoutPolicyMinutes = null, + vaultTimeoutPolicyAction = null, ), ) { private val webSettingsUrl: String @@ -63,6 +71,18 @@ class AccountSecurityViewModel @Inject constructor( .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + policyManager + .getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT) + .map { policies -> + AccountSecurityAction.Internal.PolicyUpdateReceive( + vaultTimeoutPolicies = policies.mapNotNull { + it.policyInformation as? PolicyInformation.VaultTimeout + }, + ) + } + .onEach(::sendAction) + .launchIn(viewModelScope) + viewModelScope.launch { trySendAction( AccountSecurityAction.Internal.FingerprintResultReceive( @@ -268,6 +288,10 @@ class AccountSecurityViewModel @Inject constructor( is AccountSecurityAction.Internal.FingerprintResultReceive -> { handleFingerprintResultReceived(action) } + + is AccountSecurityAction.Internal.PolicyUpdateReceive -> { + handlePolicyUpdateReceive(action) + } } } @@ -308,6 +332,20 @@ class AccountSecurityViewModel @Inject constructor( ) } } + + private fun handlePolicyUpdateReceive( + action: AccountSecurityAction.Internal.PolicyUpdateReceive, + ) { + // The vault timeout policy can only be implemented in organizations that have + // the single organization policy, meaning that if this is enabled, the user is + // only in one organization and hence there is only one result in the list. + mutableStateFlow.update { + it.copy( + vaultTimeoutPolicyMinutes = action.vaultTimeoutPolicies?.firstOrNull()?.minutes, + vaultTimeoutPolicyAction = action.vaultTimeoutPolicies?.firstOrNull()?.action, + ) + } + } } /** @@ -322,6 +360,8 @@ data class AccountSecurityState( val isUnlockWithPinEnabled: Boolean, val vaultTimeout: VaultTimeout, val vaultTimeoutAction: VaultTimeoutAction, + val vaultTimeoutPolicyMinutes: Int?, + val vaultTimeoutPolicyAction: String?, ) : Parcelable /** @@ -349,14 +389,6 @@ sealed class AccountSecurityDialog : Parcelable { ) : AccountSecurityDialog() } -/** - * A representation of the Session timeout action. - */ -enum class SessionTimeoutAction(val text: Text) { - LOCK(text = R.string.lock.asText()), - LOG_OUT(text = R.string.log_out.asText()), -} - /** * Models events for the account security screen. */ @@ -569,5 +601,12 @@ sealed class AccountSecurityAction { data class FingerprintResultReceive( val fingerprintResult: UserFingerprintResult, ) : Internal() + + /** + * A policy update has been received. + */ + data class PolicyUpdateReceive( + val vaultTimeoutPolicies: List?, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensions.kt index 4ff25516c2..0559aa0a32 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensions.kt @@ -22,3 +22,25 @@ val VaultTimeout.Type.displayLabel: Text VaultTimeout.Type.CUSTOM -> R.string.custom } .asText() + +/** + * The value in minutes for the given [VaultTimeout.Type], used as a comparison + * against the maximum timeout allowed by the organization's policy. + */ +@Suppress("MagicNumber") +val VaultTimeout.Type.minutes: Int + get() = when (this) { + VaultTimeout.Type.IMMEDIATELY -> 0 + VaultTimeout.Type.ONE_MINUTE -> 1 + VaultTimeout.Type.FIVE_MINUTES -> 5 + VaultTimeout.Type.FIFTEEN_MINUTES -> 15 + VaultTimeout.Type.THIRTY_MINUTES -> 30 + VaultTimeout.Type.ONE_HOUR -> 60 + VaultTimeout.Type.FOUR_HOURS -> 240 + + VaultTimeout.Type.ON_APP_RESTART, + VaultTimeout.Type.NEVER, + -> Int.MAX_VALUE + + VaultTimeout.Type.CUSTOM -> 0 + } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt index 725df4a3c3..b712aa7c39 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt @@ -5,9 +5,9 @@ import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -46,21 +46,7 @@ class SyncResponseJsonExtensionsTest { } @Test - @OptIn(ExperimentalSerializationApi::class) - fun `policyInformation converts the Json data to policy information`() { - val masterPasswordData = buildJsonObject { - put(key = "minLength", value = 10) - put(key = "minComplexity", value = 3) - put(key = "requireUpper", value = null) - put(key = "requireLower", value = null) - put(key = "requireNumbers", value = true) - put(key = "requireSpecial", value = null) - put(key = "enforceOnLogin", value = true) - } - val masterPasswordPolicy = createMockPolicy( - type = PolicyTypeJson.MASTER_PASSWORD, - data = masterPasswordData, - ) + fun `policyInformation converts the MasterPassword Json data to policy information`() { val policyInformation = PolicyInformation.MasterPassword( minLength = 10, minComplexity = 3, @@ -70,10 +56,57 @@ class SyncResponseJsonExtensionsTest { requireSpecial = null, enforceOnLogin = true, ) + val policy = createMockPolicy( + type = PolicyTypeJson.MASTER_PASSWORD, + data = Json.encodeToJsonElement(policyInformation).jsonObject, + ) assertEquals( policyInformation, - masterPasswordPolicy.policyInformation, + policy.policyInformation, + ) + } + + @Test + fun `policyInformation converts the PasswordGenerator Json data to policy information`() { + val policyInformation = PolicyInformation.PasswordGenerator( + defaultType = "password", + minLength = null, + useUpper = true, + useLower = true, + useNumbers = null, + useSpecial = null, + minNumbers = null, + minSpecial = null, + minNumberWords = 4, + capitalize = true, + includeNumber = null, + ) + val policy = createMockPolicy( + type = PolicyTypeJson.PASSWORD_GENERATOR, + data = Json.encodeToJsonElement(policyInformation).jsonObject, + ) + + assertEquals( + policyInformation, + policy.policyInformation, + ) + } + + @Test + fun `policyInformation converts the VaultTimeout Json data to policy information`() { + val policyInformation = PolicyInformation.VaultTimeout( + minutes = 10, + action = "lock", + ) + val policy = createMockPolicy( + type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT, + data = Json.encodeToJsonElement(policyInformation).jsonObject, + ) + + assertEquals( + policyInformation, + policy.policyInformation, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index 4ba9151918..078c892280 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -11,13 +11,17 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManagerImpl import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager +import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asSuccess +import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme @@ -46,6 +50,12 @@ class SettingsRepositoryTest { private val fakeSettingsDiskSource = FakeSettingsDiskSource() private val vaultSdkSource: VaultSdkSource = mockk() private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk() + private val mutableActivePolicyFlow = bufferedMutableSharedFlow>() + private val policyManager: PolicyManager = mockk { + every { + getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT) + } returns mutableActivePolicyFlow + } private val settingsRepository = SettingsRepositoryImpl( autofillManager = autofillManager, @@ -55,6 +65,7 @@ class SettingsRepositoryTest { vaultSdkSource = vaultSdkSource, biometricsEncryptionManager = biometricsEncryptionManager, dispatcherManager = FakeDispatcherManager(), + policyManager = policyManager, ) @Test diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt index d471752dd6..dae632dbbe 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasTextExactly import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -634,6 +635,35 @@ class AccountSecurityScreenTest : BaseComposeTest() { composeTestRule.onNodeWithText("Unlock with PIN code").assertIsOn() } + @Test + fun `session timeout policy warning should update according to state`() { + mutableStateFlow.update { + it.copy( + vaultTimeoutPolicyMinutes = 100, + ) + } + val timeOnlyText = "Your organization policies have set your maximum allowed " + + "vault timeout to 1 hour(s) and 40 minute(s)." + composeTestRule + .onNodeWithText(timeOnlyText) + .performScrollTo() + .assertIsDisplayed() + + mutableStateFlow.update { + it.copy( + vaultTimeoutPolicyMinutes = 100, + vaultTimeoutPolicyAction = "lock", + ) + } + val bothText = "Your organization policies are affecting your vault timeout. " + + "Maximum allowed vault timeout is 1 hour(s) and 40 minute(s). Your vault " + + "timeout action is set to Lock." + composeTestRule + .onNodeWithText(bothText) + .performScrollTo() + .assertIsDisplayed() + } + @Test fun `session timeout should be updated on or off according to state`() { composeTestRule @@ -708,6 +738,66 @@ class AccountSecurityScreenTest : BaseComposeTest() { .assertIsDisplayed() } + @Test + fun `on session timeout click should update according to state`() { + composeTestRule.assertNoDialogExists() + + mutableStateFlow.update { + it.copy( + vaultTimeoutPolicyMinutes = 100, + ) + } + + composeTestRule + .onAllNodesWithText("Session timeout") + .filterToOne(hasClickAction()) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Immediately") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("1 minute") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("5 minutes") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("15 minutes") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("30 minutes") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("1 hour") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("4 hours") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("On app restart") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("Never") + .assertDoesNotExist() + composeTestRule + .onAllNodesWithText("Custom") + .filterToOne(hasAnyAncestor(isDialog())) + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + @Test fun `on session timeout selection dialog cancel click should close the dialog`() { composeTestRule.assertNoDialogExists() @@ -960,6 +1050,47 @@ class AccountSecurityScreenTest : BaseComposeTest() { composeTestRule.assertNoDialogExists() } + @Suppress("MaxLineLength") + @Test + fun `custom session timeout dialog Ok click should dismiss the dialog and show an error if value exceeds policy limit`() { + composeTestRule.assertNoDialogExists() + + mutableStateFlow.update { + it.copy( + vaultTimeout = VaultTimeout.Custom(vaultTimeoutInMinutes = 123), + vaultTimeoutPolicyMinutes = 100, + ) + } + composeTestRule + .onNode(hasTextExactly("Custom", "02:03")) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule + .onNodeWithText("Your vault timeout exceeds the restrictions set by your organization.") + .assert(hasAnyAncestor(isDialog())) + .isDisplayed() + + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + AccountSecurityAction.CustomVaultTimeoutSelect( + VaultTimeout.Custom(vaultTimeoutInMinutes = 100), + ), + ) + } + composeTestRule.assertNoDialogExists() + } + @Test fun `on session timeout action click should show a selection dialog`() { composeTestRule.assertNoDialogExists() @@ -1354,6 +1485,8 @@ class AccountSecurityScreenTest : BaseComposeTest() { isUnlockWithPinEnabled = false, vaultTimeout = VaultTimeout.ThirtyMinutes, vaultTimeoutAction = VaultTimeoutAction.LOCK, + vaultTimeoutPolicyMinutes = null, + vaultTimeoutPolicyAction = null, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index 81bb9c4a16..107c9e097a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -4,7 +4,9 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult +import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult @@ -12,6 +14,10 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -23,6 +29,9 @@ import io.mockk.mockk import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -41,6 +50,12 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { every { vaultTimeoutAction } returns VaultTimeoutAction.LOCK coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) } + private val mutableActivePolicyFlow = bufferedMutableSharedFlow>() + private val policyManager: PolicyManager = mockk { + every { + getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT) + } returns mutableActivePolicyFlow + } @Test fun `initial state should be correct when saved state is set`() { @@ -60,6 +75,35 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { coVerify { settingsRepository.getUserFingerprint() } } + @Test + fun `state updates when policies change`() = runTest { + val viewModel = createViewModel() + + val policyInformation = PolicyInformation.VaultTimeout( + minutes = 10, + action = "lock", + ) + mutableActivePolicyFlow.emit( + listOf( + createMockPolicy( + isEnabled = true, + type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT, + data = Json.encodeToJsonElement(policyInformation).jsonObject, + ), + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy( + vaultTimeoutPolicyMinutes = 10, + vaultTimeoutPolicyAction = "lock", + ), + awaitItem(), + ) + } + } + @Test fun `on FingerprintResultReceive should update the fingerprint phrase`() = runTest { val fingerprint = "fingerprint" @@ -488,17 +532,20 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { } } + @Suppress("LongParameterList") private fun createViewModel( initialState: AccountSecurityState? = DEFAULT_STATE, authRepository: AuthRepository = this.authRepository, vaultRepository: VaultRepository = this.vaultRepository, environmentRepository: EnvironmentRepository = this.fakeEnvironmentRepository, settingsRepository: SettingsRepository = this.settingsRepository, + policyManager: PolicyManager = this.policyManager, ): AccountSecurityViewModel = AccountSecurityViewModel( authRepository = authRepository, vaultRepository = vaultRepository, settingsRepository = settingsRepository, environmentRepository = environmentRepository, + policyManager = policyManager, savedStateHandle = SavedStateHandle().apply { set("state", initialState) }, @@ -515,4 +562,6 @@ private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState( isUnlockWithPinEnabled = false, vaultTimeout = VaultTimeout.ThirtyMinutes, vaultTimeoutAction = VaultTimeoutAction.LOCK, + vaultTimeoutPolicyMinutes = null, + vaultTimeoutPolicyAction = null, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensionsTest.kt index b1dcf3d7b4..46ed56993e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensionsTest.kt @@ -28,4 +28,26 @@ class VaultTimeoutExtensionsTest { ) } } + + @Test + fun `minutes should return the correct value for each type`() { + mapOf( + VaultTimeout.Type.IMMEDIATELY to 0, + VaultTimeout.Type.ONE_MINUTE to 1, + VaultTimeout.Type.FIVE_MINUTES to 5, + VaultTimeout.Type.FIFTEEN_MINUTES to 15, + VaultTimeout.Type.THIRTY_MINUTES to 30, + VaultTimeout.Type.ONE_HOUR to 60, + VaultTimeout.Type.FOUR_HOURS to 240, + VaultTimeout.Type.ON_APP_RESTART to Int.MAX_VALUE, + VaultTimeout.Type.NEVER to Int.MAX_VALUE, + VaultTimeout.Type.CUSTOM to 0, + ) + .forEach { (type, value) -> + assertEquals( + value, + type.minutes, + ) + } + } }