BIT-785: Vault timeout policy (#924)

This commit is contained in:
Shannon Draeker
2024-01-31 23:05:09 -07:00
committed by Álison Fernandes
parent c7f063a306
commit 89dd552908
12 changed files with 498 additions and 37 deletions

View File

@@ -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()
}

View File

@@ -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<PolicyInformation.MasterPassword>(it)
Json.decodeFromStringOrNull<PolicyInformation.MasterPassword>(it)
}
PolicyTypeJson.PASSWORD_GENERATOR -> {
Json.decodeFromString<PolicyInformation.PasswordGenerator>(it)
Json.decodeFromStringOrNull<PolicyInformation.PasswordGenerator>(it)
}
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> {
Json.decodeFromStringOrNull<PolicyInformation.VaultTimeout>(it)
}
else -> null

View File

@@ -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<SyncResponseJson.Policy>,
) {
// 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
}
}
}
}
/**

View File

@@ -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,
)
}

View File

@@ -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(

View File

@@ -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<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>(
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<PolicyInformation.VaultTimeout>?,
) : Internal()
}
}

View File

@@ -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
}