[Shared Unlock] [PM-34073] Implement vault timeout supression (#19934)

PM-32622-Defect-Keepass-importer-is-returning-an-error-when-importing-into-individual-vault
Bernd Schoolmann 1 month ago committed by GitHub
parent 2ab17d84d4
commit 3d2e552e68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -6151,6 +6151,9 @@
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
"message": "Set an unlock method to change your timeout action"
},
"sessionTimeoutSuppressedByConnectedDevice": {
"message": "Managed by the Bitwarden desktop app. Open the desktop app to edit."
},
"upgrade": {
"message": "Upgrade"
},

@ -55,6 +55,13 @@ export default class IdleBackground {
// Need to check if any of the current users have their timeout set to `onLocked`
const allUsers = await firstValueFrom(this.accountService.accounts$);
for (const userId in allUsers) {
// Skip if vault timeout is suppressed by shared unlock
if (
await this.vaultTimeoutSettingsService.isVaultTimeoutSuppressed(userId as UserId)
) {
continue;
}
// If the screen is locked or the screensaver activates
const timeout = await firstValueFrom(
this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(userId),

@ -833,6 +833,11 @@ export class AppComponent implements OnInit, OnDestroy {
if (userId == null) {
continue;
}
// Skip if vault timeout is suppressed by shared unlock
if (await this.vaultTimeoutSettingsService.isVaultTimeoutSuppressed(userId as UserId)) {
continue;
}
const options = await this.getVaultTimeoutOptions(userId);
if (options[0] === timeout) {
options[1] === "logOut"

@ -7119,6 +7119,9 @@
"sessionTimeoutAction": {
"message": "Session timeout action"
},
"sessionTimeoutSuppressedByConnectedDevice": {
"message": "Managed by the Bitwarden browser extension app. Open the browser app to edit."
},
"immediately": {
"message": "Immediately"
},

@ -55,4 +55,33 @@ export abstract class VaultTimeoutSettingsService {
* @returns boolean true if biometric lock is set
*/
abstract isBiometricLockSet(userId?: UserId): Promise<boolean>;
/**
* Observable that emits the epoch timestamp (ms) until which vault timeout is suppressed,
* or null when not suppressed. Used by shared unlock to prevent timeout during active sessions.
*/
abstract vaultTimeoutSuppressedUntil$(userId: UserId): Observable<number | null>;
/**
* Observable that emits true if vault timeout is currently suppressed for the given user
* (i.e. suppression timestamp exists and has not yet elapsed).
*/
abstract isVaultTimeoutSuppressed$(userId: UserId): Observable<boolean>;
/**
* Returns true if vault timeout is currently suppressed for the given user
* (i.e. suppression timestamp exists and has not yet elapsed).
*/
abstract isVaultTimeoutSuppressed(userId: UserId): Promise<boolean>;
/**
* Suppress vault timeout until the given epoch timestamp (ms).
* While suppressed, the vault timeout service will not lock or log out users.
*/
abstract suppressVaultTimeout(until: number, userId: UserId): Promise<void>;
/**
* Clear vault timeout suppression for the user, allowing vault timeout to occur as normal.
*/
abstract clearVaultTimeoutSuppression(userId: UserId): Promise<void>;
}

@ -42,7 +42,11 @@ import {
VaultTimeoutStringType,
} from "../types/vault-timeout.type";
import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state";
import {
VAULT_TIMEOUT,
VAULT_TIMEOUT_ACTION,
VAULT_TIMEOUT_SUPPRESSED_UNTIL,
} from "./vault-timeout-settings.state";
export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceAbstraction {
constructor(
@ -393,4 +397,38 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null),
);
}
vaultTimeoutSuppressedUntil$(userId: UserId): Observable<number | null> {
if (!userId) {
throw new Error("User id required. Cannot get vault timeout suppressed until.");
}
return this.stateProvider.getUserState$(VAULT_TIMEOUT_SUPPRESSED_UNTIL, userId);
}
isVaultTimeoutSuppressed$(userId: UserId): Observable<boolean> {
return this.vaultTimeoutSuppressedUntil$(userId).pipe(
map((until) => until != null && Date.now() < until),
);
}
async isVaultTimeoutSuppressed(userId: UserId): Promise<boolean> {
return await firstValueFrom(this.isVaultTimeoutSuppressed$(userId));
}
async suppressVaultTimeout(until: number, userId: UserId): Promise<void> {
if (!userId) {
throw new Error("User id required. Cannot suppress vault timeout.");
}
await this.stateProvider.getUser(userId, VAULT_TIMEOUT_SUPPRESSED_UNTIL).update(() => until);
}
async clearVaultTimeoutSuppression(userId: UserId): Promise<void> {
if (!userId) {
throw new Error("User id required. Cannot clear vault timeout suppression.");
}
await this.stateProvider.getUser(userId, VAULT_TIMEOUT_SUPPRESSED_UNTIL).update(() => null);
}
}

@ -1,4 +1,8 @@
import { UserKeyDefinition, VAULT_TIMEOUT_SETTINGS_DISK_LOCAL } from "../../../platform/state";
import {
UserKeyDefinition,
VAULT_TIMEOUT_SETTINGS_DISK_LOCAL,
VAULT_TIMEOUT_SETTINGS_MEMORY,
} from "../../../platform/state";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
import { VaultTimeout } from "../types/vault-timeout.type";
@ -25,3 +29,12 @@ export const VAULT_TIMEOUT = new UserKeyDefinition<VaultTimeout>(
clearOn: [], // persisted on logout
},
);
export const VAULT_TIMEOUT_SUPPRESSED_UNTIL = new UserKeyDefinition<number | null>(
VAULT_TIMEOUT_SETTINGS_MEMORY,
"vaultTimeoutSuppressedUntil",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
);

@ -54,6 +54,7 @@ describe("VaultTimeoutService", () => {
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
vaultTimeoutActionSubject,
);
vaultTimeoutSettingsService.isVaultTimeoutSuppressed.mockResolvedValue(false);
availableVaultTimeoutActionsSubject = new BehaviorSubject<VaultTimeoutAction[]>([]);
@ -152,6 +153,8 @@ describe("VaultTimeoutService", () => {
],
);
});
vaultTimeoutSettingsService.isVaultTimeoutSuppressed.mockResolvedValue(false);
};
const expectUserToHaveLocked = (userId: string) => {
@ -333,5 +336,54 @@ describe("VaultTimeoutService", () => {
expectNoAction("1");
});
it("should not run timeout actions for a user while vault timeout is suppressed", async () => {
setupAccounts(
{
1: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
lastActive: new Date().getTime() - 120 * 1000, // Last active 2 minutes ago
vaultTimeout: 1, // Vault timeout of 1 minute
},
2: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
lastActive: new Date().getTime() - 120 * 1000, // Last active 2 minutes ago
vaultTimeout: 1, // Vault timeout of 1 minute
},
},
{ isViewFocused: false },
);
vaultTimeoutSettingsService.isVaultTimeoutSuppressed.mockImplementation((userId) =>
Promise.resolve(userId === "1"),
);
await vaultTimeoutService.checkVaultTimeout();
expectNoAction("1");
expectUserToHaveLocked("2");
});
it("should run timeout action when suppression has expired", async () => {
setupAccounts(
{
1: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
lastActive: new Date().getTime() - 120 * 1000, // Last active 2 minutes ago
vaultTimeout: 1, // Vault timeout of 1 minute
},
},
{ isViewFocused: false },
);
vaultTimeoutSettingsService.isVaultTimeoutSuppressed.mockResolvedValue(false);
await vaultTimeoutService.checkVaultTimeout();
expectUserToHaveLocked("1");
});
});
});

@ -93,6 +93,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
return false;
}
// Check if vault timeout is suppressed by shared unlock
if (await this.vaultTimeoutSettingsService.isVaultTimeoutSuppressed(userId as UserId)) {
return false;
}
const authStatus = await this.authService.getAuthStatus(userId);
if (
authStatus === AuthenticationStatus.Locked ||

@ -243,8 +243,12 @@ export class SessionTimeoutInputComponent implements ControlValueAccessor, Valid
// Empty
}
setDisabledState?(_isDisabled: boolean): void {
// Empty
setDisabledState?(isDisabled: boolean): void {
if (isDisabled) {
this.form.disable({ emitEvent: false });
} else {
this.form.enable({ emitEvent: false });
}
}
validate(_: AbstractControl): ValidationErrors | null {

@ -19,7 +19,9 @@
}
</bit-select>
@if (!canLock && supportsLock) {
@if (isTimeoutSuppressed()) {
<bit-hint>{{ "sessionTimeoutSuppressedByConnectedDevice" | i18n }}</bit-hint>
} @else if (!canLock && supportsLock) {
<bit-hint>{{ "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction" | i18n }}</bit-hint>
} @else if ((sessionTimeoutActionFromPolicy$ | async) != null) {
<bit-hint>{{ "sessionTimeoutSettingsManagedByOrganization" | i18n }}</bit-hint>

@ -88,6 +88,7 @@ describe("SessionTimeoutSettingsComponent", () => {
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
);
mockVaultTimeoutSettingsService.isVaultTimeoutSuppressed$.mockImplementation(() => of(false));
mockSessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$.mockImplementation(
(userId) => availableTimeoutOptions$.asObservable(),
);

@ -128,6 +128,13 @@ export class SessionTimeoutSettingsComponent implements OnInit {
protected readonly sessionTimeoutActionFromPolicy = toSignal(
this.sessionTimeoutActionFromPolicy$,
);
protected readonly isTimeoutSuppressed = toSignal(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.vaultTimeoutSettingsService.isVaultTimeoutSuppressed$(userId)),
),
{ initialValue: false },
);
private userId!: UserId;
@ -184,19 +191,25 @@ export class SessionTimeoutSettingsComponent implements OnInit {
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(this.userId),
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId),
this.sessionTimeoutActionFromPolicy$,
this.vaultTimeoutSettingsService.isVaultTimeoutSuppressed$(this.userId),
]),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(([availableActions, action, sessionTimeoutActionFromPolicy]) => {
.subscribe(([availableActions, action, sessionTimeoutActionFromPolicy, isSuppressed]) => {
this.availableTimeoutActions.set(availableActions);
this.formGroup.controls.timeoutAction.setValue(action, { emitEvent: false });
// Enable/disable the action control based on policy or available actions
if (sessionTimeoutActionFromPolicy != null || availableActions.length <= 1) {
this.formGroup.controls.timeoutAction.disable({ emitEvent: false });
// Disable the entire form when vault timeout is suppressed by shared unlock
if (isSuppressed) {
this.formGroup.disable({ emitEvent: false });
} else {
this.formGroup.controls.timeoutAction.enable({ emitEvent: false });
this.formGroup.enable({ emitEvent: false });
// Enable/disable the action control based on policy or available actions
if (sessionTimeoutActionFromPolicy != null || availableActions.length <= 1) {
this.formGroup.controls.timeoutAction.disable({ emitEvent: false });
}
}
});

@ -81,6 +81,7 @@ export const VAULT_TIMEOUT_SETTINGS_DISK_LOCAL = new StateDefinition(
web: "disk-local",
},
);
export const VAULT_TIMEOUT_SETTINGS_MEMORY = new StateDefinition("vaultTimeoutSettings", "memory");
// Autofill

Loading…
Cancel
Save