mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 17:53:39 +00:00
[PM-12606] Move Vault Timeout and Vault Timeout Settings to KM (#13405)
* move vault timeout and vault timeout settings to km * move browser vault timeout service to km * fix cli import * fix imports * fix some relative imports * use relative imports within common * fix imports * fix new imports * Fix new imports * fix spec imports
This commit is contained in:
@@ -11,7 +11,6 @@ import {
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
|
||||
import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service";
|
||||
import { VaultTimeoutSettingsService } from "../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { OrganizationConnectionType } from "../admin-console/enums";
|
||||
import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request";
|
||||
import { OrganizationSponsorshipRedeemRequest } from "../admin-console/models/request/organization/organization-sponsorship-redeem.request";
|
||||
@@ -105,7 +104,8 @@ import { PlanResponse } from "../billing/models/response/plan.response";
|
||||
import { SubscriptionResponse } from "../billing/models/response/subscription.response";
|
||||
import { TaxInfoResponse } from "../billing/models/response/tax-info.response";
|
||||
import { DeviceType } from "../enums";
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
import { VaultTimeoutSettingsService } from "../key-management/vault-timeout";
|
||||
import { VaultTimeoutAction } from "../key-management/vault-timeout/enums/vault-timeout-action.enum";
|
||||
import { CollectionBulkDeleteRequest } from "../models/request/collection-bulk-delete.request";
|
||||
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
|
||||
import { EventRequest } from "../models/request/event.request";
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, map, of } from "rxjs";
|
||||
|
||||
import {
|
||||
PinServiceAbstraction,
|
||||
FakeUserDecryptionOptions as UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { BiometricStateService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith, FakeStateProvider } from "../../../spec";
|
||||
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Policy } from "../../admin-console/models/domain/policy";
|
||||
import { TokenService } from "../../auth/abstractions/token.service";
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import {
|
||||
VAULT_TIMEOUT,
|
||||
VAULT_TIMEOUT_ACTION,
|
||||
} from "../../services/vault-timeout/vault-timeout-settings.state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";
|
||||
|
||||
import { VaultTimeoutSettingsService } from "./vault-timeout-settings.service";
|
||||
|
||||
describe("VaultTimeoutSettingsService", () => {
|
||||
let accountService: FakeAccountService;
|
||||
let pinService: MockProxy<PinServiceAbstraction>;
|
||||
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let tokenService: MockProxy<TokenService>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
const biometricStateService = mock<BiometricStateService>();
|
||||
let vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction;
|
||||
|
||||
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
pinService = mock<PinServiceAbstraction>();
|
||||
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
||||
keyService = mock<KeyService>();
|
||||
tokenService = mock<TokenService>();
|
||||
policyService = mock<PolicyService>();
|
||||
|
||||
userDecryptionOptionsSubject = new BehaviorSubject(null);
|
||||
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
|
||||
userDecryptionOptionsService.hasMasterPassword$ = userDecryptionOptionsSubject.pipe(
|
||||
map((options) => options?.hasMasterPassword ?? false),
|
||||
);
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
userDecryptionOptionsSubject,
|
||||
);
|
||||
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
logService = mock<LogService>();
|
||||
|
||||
const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout
|
||||
vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout);
|
||||
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("availableVaultTimeoutActions$", () => {
|
||||
it("always returns LogOut", async () => {
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has a master password", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => {
|
||||
pinService.isPinSet.mockResolvedValue(true);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has biometrics configured", async () => {
|
||||
biometricStateService.biometricUnlockEnabled$ = of(true);
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false }));
|
||||
pinService.isPinSet.mockResolvedValue(false);
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canLock", () => {
|
||||
it("returns true if the user can lock", async () => {
|
||||
jest
|
||||
.spyOn(vaultTimeoutSettingsService, "availableVaultTimeoutActions$")
|
||||
.mockReturnValue(of([VaultTimeoutAction.Lock]));
|
||||
|
||||
const result = await vaultTimeoutSettingsService.canLock("userId" as UserId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if the user only has the log out vault timeout action", async () => {
|
||||
jest
|
||||
.spyOn(vaultTimeoutSettingsService, "availableVaultTimeoutActions$")
|
||||
.mockReturnValue(of([VaultTimeoutAction.LogOut]));
|
||||
|
||||
const result = await vaultTimeoutSettingsService.canLock("userId" as UserId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if the user has no vault timeout actions", async () => {
|
||||
jest
|
||||
.spyOn(vaultTimeoutSettingsService, "availableVaultTimeoutActions$")
|
||||
.mockReturnValue(of([]));
|
||||
|
||||
const result = await vaultTimeoutSettingsService.canLock("userId" as UserId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVaultTimeoutActionByUserId$", () => {
|
||||
it("should throw an error if no user id is provided", async () => {
|
||||
expect(() => vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(null)).toThrow(
|
||||
"User id required. Cannot get vault timeout action.",
|
||||
);
|
||||
});
|
||||
|
||||
describe("given the user has a master password", () => {
|
||||
it.each`
|
||||
policy | userPreference | expected
|
||||
${null} | ${null} | ${VaultTimeoutAction.Lock}
|
||||
${null} | ${VaultTimeoutAction.LogOut} | ${VaultTimeoutAction.LogOut}
|
||||
${VaultTimeoutAction.LogOut} | ${null} | ${VaultTimeoutAction.LogOut}
|
||||
${VaultTimeoutAction.LogOut} | ${VaultTimeoutAction.Lock} | ${VaultTimeoutAction.LogOut}
|
||||
`(
|
||||
"returns $expected when policy is $policy, and user preference is $userPreference",
|
||||
async ({ policy, userPreference, expected }) => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.getAll$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT_ACTION, userPreference, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("given the user does not have a master password", () => {
|
||||
it.each`
|
||||
hasPinUnlock | hasBiometricUnlock | policy | userPreference | expected
|
||||
${false} | ${false} | ${null} | ${null} | ${VaultTimeoutAction.LogOut}
|
||||
${false} | ${false} | ${null} | ${VaultTimeoutAction.Lock} | ${VaultTimeoutAction.LogOut}
|
||||
${false} | ${false} | ${VaultTimeoutAction.Lock} | ${null} | ${VaultTimeoutAction.LogOut}
|
||||
${false} | ${true} | ${null} | ${null} | ${VaultTimeoutAction.Lock}
|
||||
${false} | ${true} | ${null} | ${VaultTimeoutAction.Lock} | ${VaultTimeoutAction.Lock}
|
||||
${false} | ${true} | ${VaultTimeoutAction.Lock} | ${null} | ${VaultTimeoutAction.Lock}
|
||||
${false} | ${true} | ${VaultTimeoutAction.Lock} | ${VaultTimeoutAction.LogOut} | ${VaultTimeoutAction.Lock}
|
||||
${true} | ${false} | ${null} | ${null} | ${VaultTimeoutAction.Lock}
|
||||
${true} | ${false} | ${null} | ${VaultTimeoutAction.Lock} | ${VaultTimeoutAction.Lock}
|
||||
${true} | ${false} | ${VaultTimeoutAction.Lock} | ${null} | ${VaultTimeoutAction.Lock}
|
||||
${true} | ${false} | ${VaultTimeoutAction.Lock} | ${VaultTimeoutAction.LogOut} | ${VaultTimeoutAction.Lock}
|
||||
`(
|
||||
"returns $expected when policy is $policy, has PIN unlock method: $hasPinUnlock or Biometric unlock method: $hasBiometricUnlock, and user preference is $userPreference",
|
||||
async ({ hasPinUnlock, hasBiometricUnlock, policy, userPreference, expected }) => {
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(hasBiometricUnlock);
|
||||
pinService.isPinSet.mockResolvedValue(hasPinUnlock);
|
||||
|
||||
userDecryptionOptionsSubject.next(
|
||||
new UserDecryptionOptions({ hasMasterPassword: false }),
|
||||
);
|
||||
policyService.getAll$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT_ACTION, userPreference, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVaultTimeoutByUserId$", () => {
|
||||
it("should throw an error if no user id is provided", async () => {
|
||||
expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow(
|
||||
"User id required. Cannot get vault timeout.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// policy, vaultTimeout, expected
|
||||
[null, null, 15], // no policy, no vault timeout, falls back to default
|
||||
[30, 90, 30], // policy overrides vault timeout
|
||||
[30, 15, 15], // policy doesn't override vault timeout when it's within acceptable range
|
||||
[90, VaultTimeoutStringType.Never, 90], // policy overrides vault timeout when it's "never"
|
||||
[null, VaultTimeoutStringType.Never, VaultTimeoutStringType.Never], // no policy, persist "never" vault timeout
|
||||
[90, 0, 0], // policy doesn't override vault timeout when it's 0 (immediate)
|
||||
[null, 0, 0], // no policy, persist 0 (immediate) vault timeout
|
||||
[90, VaultTimeoutStringType.OnRestart, 90], // policy overrides vault timeout when it's "onRestart"
|
||||
[null, VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.OnRestart], // no policy, persist "onRestart" vault timeout
|
||||
[90, VaultTimeoutStringType.OnLocked, 90], // policy overrides vault timeout when it's "onLocked"
|
||||
[null, VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnLocked], // no policy, persist "onLocked" vault timeout
|
||||
[90, VaultTimeoutStringType.OnSleep, 90], // policy overrides vault timeout when it's "onSleep"
|
||||
[null, VaultTimeoutStringType.OnSleep, VaultTimeoutStringType.OnSleep], // no policy, persist "onSleep" vault timeout
|
||||
[90, VaultTimeoutStringType.OnIdle, 90], // policy overrides vault timeout when it's "onIdle"
|
||||
[null, VaultTimeoutStringType.OnIdle, VaultTimeoutStringType.OnIdle], // no policy, persist "onIdle" vault timeout
|
||||
])(
|
||||
"when policy is %s, and vault timeout is %s, returns %s",
|
||||
async (policy, vaultTimeout, expected) => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.getAll$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { minutes: policy } }] as unknown as Policy[])),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("setVaultTimeoutOptions", () => {
|
||||
const mockAccessToken = "mockAccessToken";
|
||||
const mockRefreshToken = "mockRefreshToken";
|
||||
const mockClientId = "mockClientId";
|
||||
const mockClientSecret = "mockClientSecret";
|
||||
|
||||
it("should throw an error if no user id is provided", async () => {
|
||||
// note: don't await here because we want to test the error
|
||||
const result = vaultTimeoutSettingsService.setVaultTimeoutOptions(null, null, null);
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("User id required. Cannot set vault timeout settings.");
|
||||
});
|
||||
|
||||
it("should not throw an error if 0 is provided as the timeout", async () => {
|
||||
// note: don't await here because we want to test the error
|
||||
const result = vaultTimeoutSettingsService.setVaultTimeoutOptions(
|
||||
mockUserId,
|
||||
0,
|
||||
VaultTimeoutAction.Lock,
|
||||
);
|
||||
// Assert
|
||||
await expect(result).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw an error if a null vault timeout is provided", async () => {
|
||||
// note: don't await here because we want to test the error
|
||||
const result = vaultTimeoutSettingsService.setVaultTimeoutOptions(mockUserId, null, null);
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("Vault Timeout cannot be null.");
|
||||
});
|
||||
|
||||
it("should throw an error if a null vault timout action is provided", async () => {
|
||||
// note: don't await here because we want to test the error
|
||||
const result = vaultTimeoutSettingsService.setVaultTimeoutOptions(mockUserId, 30, null);
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("Vault Timeout Action cannot be null.");
|
||||
});
|
||||
|
||||
it("should set the vault timeout options for the given user", async () => {
|
||||
// Arrange
|
||||
tokenService.getAccessToken.mockResolvedValue(mockAccessToken);
|
||||
tokenService.getRefreshToken.mockResolvedValue(mockRefreshToken);
|
||||
tokenService.getClientId.mockResolvedValue(mockClientId);
|
||||
tokenService.getClientSecret.mockResolvedValue(mockClientSecret);
|
||||
|
||||
const action = VaultTimeoutAction.Lock;
|
||||
const timeout = 30;
|
||||
|
||||
// Act
|
||||
await vaultTimeoutSettingsService.setVaultTimeoutOptions(mockUserId, timeout, action);
|
||||
|
||||
// Assert
|
||||
expect(tokenService.setTokens).toHaveBeenCalledWith(
|
||||
mockAccessToken,
|
||||
action,
|
||||
timeout,
|
||||
mockRefreshToken,
|
||||
[mockClientId, mockClientSecret],
|
||||
);
|
||||
|
||||
expect(
|
||||
stateProvider.singleUser.getFake(mockUserId, VAULT_TIMEOUT_ACTION).nextMock,
|
||||
).toHaveBeenCalledWith(action);
|
||||
|
||||
expect(
|
||||
stateProvider.singleUser.getFake(mockUserId, VAULT_TIMEOUT).nextMock,
|
||||
).toHaveBeenCalledWith(timeout);
|
||||
|
||||
expect(keyService.refreshAdditionalKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should clear the tokens when the timeout is not never and the action is log out", async () => {
|
||||
// Arrange
|
||||
const action = VaultTimeoutAction.LogOut;
|
||||
const timeout = 30;
|
||||
|
||||
// Act
|
||||
await vaultTimeoutSettingsService.setVaultTimeoutOptions(mockUserId, timeout, action);
|
||||
|
||||
// Assert
|
||||
expect(tokenService.clearTokens).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not clear the tokens when the timeout is never and the action is log out", async () => {
|
||||
// Arrange
|
||||
const action = VaultTimeoutAction.LogOut;
|
||||
const timeout = VaultTimeoutStringType.Never;
|
||||
|
||||
// Act
|
||||
await vaultTimeoutSettingsService.setVaultTimeoutOptions(mockUserId, timeout, action);
|
||||
|
||||
// Assert
|
||||
expect(tokenService.clearTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createVaultTimeoutSettingsService(
|
||||
defaultVaultTimeout: VaultTimeout,
|
||||
): VaultTimeoutSettingsService {
|
||||
return new VaultTimeoutSettingsService(
|
||||
accountService,
|
||||
pinService,
|
||||
userDecryptionOptionsService,
|
||||
keyService,
|
||||
tokenService,
|
||||
policyService,
|
||||
biometricStateService,
|
||||
stateProvider,
|
||||
logService,
|
||||
defaultVaultTimeout,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
EMPTY,
|
||||
Observable,
|
||||
catchError,
|
||||
combineLatest,
|
||||
defer,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
PinServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { BiometricStateService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "../../admin-console/enums";
|
||||
import { Policy } from "../../admin-console/models/domain/policy";
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { TokenService } from "../../auth/abstractions/token.service";
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { StateProvider } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";
|
||||
|
||||
import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state";
|
||||
|
||||
export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceAbstraction {
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private pinService: PinServiceAbstraction,
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private keyService: KeyService,
|
||||
private tokenService: TokenService,
|
||||
private policyService: PolicyService,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private stateProvider: StateProvider,
|
||||
private logService: LogService,
|
||||
private defaultVaultTimeout: VaultTimeout,
|
||||
) {}
|
||||
|
||||
async setVaultTimeoutOptions(
|
||||
userId: UserId,
|
||||
timeout: VaultTimeout,
|
||||
action: VaultTimeoutAction,
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("User id required. Cannot set vault timeout settings.");
|
||||
}
|
||||
|
||||
if (timeout == null) {
|
||||
throw new Error("Vault Timeout cannot be null.");
|
||||
}
|
||||
|
||||
if (action == null) {
|
||||
throw new Error("Vault Timeout Action cannot be null.");
|
||||
}
|
||||
|
||||
// We swap these tokens from being on disk for lock actions, and in memory for logout actions
|
||||
// Get them here to set them to their new location after changing the timeout action and clearing if needed
|
||||
const accessToken = await this.tokenService.getAccessToken();
|
||||
const refreshToken = await this.tokenService.getRefreshToken();
|
||||
const clientId = await this.tokenService.getClientId();
|
||||
const clientSecret = await this.tokenService.getClientSecret();
|
||||
|
||||
await this.setVaultTimeout(userId, timeout);
|
||||
|
||||
if (timeout != VaultTimeoutStringType.Never && action === VaultTimeoutAction.LogOut) {
|
||||
// if we have a vault timeout and the action is log out, reset tokens
|
||||
// as the tokens were stored on disk and now should be stored in memory
|
||||
await this.tokenService.clearTokens();
|
||||
}
|
||||
|
||||
await this.setVaultTimeoutAction(userId, action);
|
||||
|
||||
await this.tokenService.setTokens(accessToken, action, timeout, refreshToken, [
|
||||
clientId,
|
||||
clientSecret,
|
||||
]);
|
||||
|
||||
await this.keyService.refreshAdditionalKeys();
|
||||
}
|
||||
|
||||
availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]> {
|
||||
return defer(() => this.getAvailableVaultTimeoutActions(userId));
|
||||
}
|
||||
|
||||
async canLock(userId: UserId): Promise<boolean> {
|
||||
const availableVaultTimeoutActions: VaultTimeoutAction[] = await firstValueFrom(
|
||||
this.availableVaultTimeoutActions$(userId),
|
||||
);
|
||||
return availableVaultTimeoutActions?.includes(VaultTimeoutAction.Lock) || false;
|
||||
}
|
||||
|
||||
async isBiometricLockSet(userId?: string): Promise<boolean> {
|
||||
const biometricUnlockPromise =
|
||||
userId == null
|
||||
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
|
||||
: this.biometricStateService.getBiometricUnlockEnabled(userId as UserId);
|
||||
return await biometricUnlockPromise;
|
||||
}
|
||||
|
||||
private async setVaultTimeout(userId: UserId, timeout: VaultTimeout): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("User id required. Cannot set vault timeout.");
|
||||
}
|
||||
|
||||
if (timeout == null) {
|
||||
throw new Error("Vault Timeout cannot be null.");
|
||||
}
|
||||
|
||||
await this.stateProvider.setUserState(VAULT_TIMEOUT, timeout, userId);
|
||||
}
|
||||
|
||||
getVaultTimeoutByUserId$(userId: UserId): Observable<VaultTimeout> {
|
||||
if (!userId) {
|
||||
throw new Error("User id required. Cannot get vault timeout.");
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.stateProvider.getUserState$(VAULT_TIMEOUT, userId),
|
||||
this.getMaxVaultTimeoutPolicyByUserId$(userId),
|
||||
]).pipe(
|
||||
switchMap(([currentVaultTimeout, maxVaultTimeoutPolicy]) => {
|
||||
return from(this.determineVaultTimeout(currentVaultTimeout, maxVaultTimeoutPolicy)).pipe(
|
||||
tap((vaultTimeout: VaultTimeout) => {
|
||||
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
|
||||
if (vaultTimeout !== currentVaultTimeout) {
|
||||
return this.stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, userId);
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
// Protect outer observable from canceling on error by catching and returning EMPTY
|
||||
this.logService.error(`Error getting vault timeout: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(), // Avoid having the set side effect trigger a new emission of the same action
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
private async determineVaultTimeout(
|
||||
currentVaultTimeout: VaultTimeout | null,
|
||||
maxVaultTimeoutPolicy: Policy | null,
|
||||
): Promise<VaultTimeout | null> {
|
||||
// if current vault timeout is null, apply the client specific default
|
||||
currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout;
|
||||
|
||||
// If no policy applies, return the current vault timeout
|
||||
if (!maxVaultTimeoutPolicy) {
|
||||
return currentVaultTimeout;
|
||||
}
|
||||
|
||||
// User is subject to a max vault timeout policy
|
||||
const maxVaultTimeoutPolicyData = maxVaultTimeoutPolicy.data;
|
||||
|
||||
// If the current vault timeout is not numeric, change it to the policy compliant value
|
||||
if (typeof currentVaultTimeout === "string") {
|
||||
return maxVaultTimeoutPolicyData.minutes;
|
||||
}
|
||||
|
||||
// For numeric vault timeouts, ensure they are smaller than maximum allowed value according to policy
|
||||
const policyCompliantTimeout = Math.min(currentVaultTimeout, maxVaultTimeoutPolicyData.minutes);
|
||||
|
||||
return policyCompliantTimeout;
|
||||
}
|
||||
|
||||
private async setVaultTimeoutAction(userId: UserId, action: VaultTimeoutAction): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("User id required. Cannot set vault timeout action.");
|
||||
}
|
||||
|
||||
if (!action) {
|
||||
throw new Error("Vault Timeout Action cannot be null");
|
||||
}
|
||||
|
||||
await this.stateProvider.setUserState(VAULT_TIMEOUT_ACTION, action, userId);
|
||||
}
|
||||
|
||||
getVaultTimeoutActionByUserId$(userId: UserId): Observable<VaultTimeoutAction> {
|
||||
if (!userId) {
|
||||
throw new Error("User id required. Cannot get vault timeout action.");
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId),
|
||||
this.getMaxVaultTimeoutPolicyByUserId$(userId),
|
||||
]).pipe(
|
||||
switchMap(([currentVaultTimeoutAction, maxVaultTimeoutPolicy]) => {
|
||||
return from(
|
||||
this.determineVaultTimeoutAction(
|
||||
userId,
|
||||
currentVaultTimeoutAction,
|
||||
maxVaultTimeoutPolicy,
|
||||
),
|
||||
).pipe(
|
||||
tap((vaultTimeoutAction: VaultTimeoutAction) => {
|
||||
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
|
||||
// We want to avoid having a null timeout action always so we set it to the default if it is null
|
||||
// and if the user becomes subject to a policy that requires a specific action, we set it to that
|
||||
if (vaultTimeoutAction !== currentVaultTimeoutAction) {
|
||||
return this.stateProvider.setUserState(
|
||||
VAULT_TIMEOUT_ACTION,
|
||||
vaultTimeoutAction,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
// Protect outer observable from canceling on error by catching and returning EMPTY
|
||||
this.logService.error(`Error getting vault timeout: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(), // Avoid having the set side effect trigger a new emission of the same action
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
private async determineVaultTimeoutAction(
|
||||
userId: string,
|
||||
currentVaultTimeoutAction: VaultTimeoutAction | null,
|
||||
maxVaultTimeoutPolicy: Policy | null,
|
||||
): Promise<VaultTimeoutAction> {
|
||||
const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId);
|
||||
if (availableVaultTimeoutActions.length === 1) {
|
||||
return availableVaultTimeoutActions[0];
|
||||
}
|
||||
|
||||
if (
|
||||
maxVaultTimeoutPolicy?.data?.action &&
|
||||
availableVaultTimeoutActions.includes(maxVaultTimeoutPolicy.data.action)
|
||||
) {
|
||||
// return policy defined vault timeout action
|
||||
return maxVaultTimeoutPolicy.data.action;
|
||||
}
|
||||
|
||||
// No policy applies from here on
|
||||
// If the current vault timeout is null and lock is an option, set it as the default
|
||||
if (
|
||||
currentVaultTimeoutAction == null &&
|
||||
availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)
|
||||
) {
|
||||
return VaultTimeoutAction.Lock;
|
||||
}
|
||||
|
||||
return currentVaultTimeoutAction;
|
||||
}
|
||||
|
||||
private getMaxVaultTimeoutPolicyByUserId$(userId: UserId): Observable<Policy | null> {
|
||||
if (!userId) {
|
||||
throw new Error("User id required. Cannot get max vault timeout policy.");
|
||||
}
|
||||
|
||||
return this.policyService
|
||||
.getAll$(PolicyType.MaximumVaultTimeout, userId)
|
||||
.pipe(map((policies) => policies[0] ?? null));
|
||||
}
|
||||
|
||||
private async getAvailableVaultTimeoutActions(userId?: string): Promise<VaultTimeoutAction[]> {
|
||||
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
|
||||
const availableActions = [VaultTimeoutAction.LogOut];
|
||||
|
||||
const canLock =
|
||||
(await this.userHasMasterPassword(userId)) ||
|
||||
(await this.pinService.isPinSet(userId as UserId)) ||
|
||||
(await this.isBiometricLockSet(userId));
|
||||
|
||||
if (canLock) {
|
||||
availableActions.push(VaultTimeoutAction.Lock);
|
||||
}
|
||||
|
||||
return availableActions;
|
||||
}
|
||||
|
||||
async clear(userId?: string): Promise<void> {
|
||||
await this.keyService.clearPinKeys(userId);
|
||||
}
|
||||
|
||||
private async userHasMasterPassword(userId: string): Promise<boolean> {
|
||||
if (userId) {
|
||||
const decryptionOptions = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
);
|
||||
|
||||
return !!decryptionOptions?.hasMasterPassword;
|
||||
} else {
|
||||
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { UserKeyDefinition } from "../../platform/state";
|
||||
import { VaultTimeout } from "../../types/vault-timeout.type";
|
||||
|
||||
import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state";
|
||||
|
||||
describe.each([
|
||||
[VAULT_TIMEOUT_ACTION, VaultTimeoutAction.Lock],
|
||||
[VAULT_TIMEOUT, 5],
|
||||
])(
|
||||
"deserializes state key definitions",
|
||||
(
|
||||
keyDefinition: UserKeyDefinition<VaultTimeoutAction> | UserKeyDefinition<VaultTimeout>,
|
||||
state: VaultTimeoutAction | VaultTimeout | boolean,
|
||||
) => {
|
||||
function getTypeDescription(value: any): string {
|
||||
if (Array.isArray(value)) {
|
||||
return "array";
|
||||
} else if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
// Fallback for primitive types
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function testDeserialization<T>(keyDefinition: UserKeyDefinition<T>, state: T) {
|
||||
const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state)));
|
||||
expect(deserialized).toEqual(state);
|
||||
}
|
||||
|
||||
it(`should deserialize state for KeyDefinition<${getTypeDescription(state)}>: "${keyDefinition.key}"`, () => {
|
||||
testDeserialization(keyDefinition, state);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,27 +0,0 @@
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { UserKeyDefinition, VAULT_TIMEOUT_SETTINGS_DISK_LOCAL } from "../../platform/state";
|
||||
import { VaultTimeout } from "../../types/vault-timeout.type";
|
||||
|
||||
/**
|
||||
* Settings use disk storage and local storage on web so settings can persist after logout
|
||||
* in order for us to know if the user's chose to never lock their vault or not.
|
||||
* When the user has never lock selected, we have to set the user key in memory
|
||||
* from the user auto unlock key stored on disk on client bootstrap.
|
||||
*/
|
||||
export const VAULT_TIMEOUT_ACTION = new UserKeyDefinition<VaultTimeoutAction>(
|
||||
VAULT_TIMEOUT_SETTINGS_DISK_LOCAL,
|
||||
"vaultTimeoutAction",
|
||||
{
|
||||
deserializer: (vaultTimeoutAction) => vaultTimeoutAction,
|
||||
clearOn: [], // persisted on logout
|
||||
},
|
||||
);
|
||||
|
||||
export const VAULT_TIMEOUT = new UserKeyDefinition<VaultTimeout>(
|
||||
VAULT_TIMEOUT_SETTINGS_DISK_LOCAL,
|
||||
"vaultTimeout",
|
||||
{
|
||||
deserializer: (vaultTimeout) => vaultTimeout,
|
||||
clearOn: [], // persisted on logout
|
||||
},
|
||||
);
|
||||
@@ -1,454 +0,0 @@
|
||||
import { MockProxy, any, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, from, of } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { SearchService } from "../../abstractions/search.service";
|
||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { AccountInfo } from "../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service";
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { TaskSchedulerService } from "../../platform/scheduling";
|
||||
import { StateEventRunnerService } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";
|
||||
import { CipherService } from "../../vault/abstractions/cipher.service";
|
||||
import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
|
||||
|
||||
import { VaultTimeoutService } from "./vault-timeout.service";
|
||||
|
||||
describe("VaultTimeoutService", () => {
|
||||
let accountService: FakeAccountService;
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
let cipherService: MockProxy<CipherService>;
|
||||
let folderService: MockProxy<FolderService>;
|
||||
let collectionService: MockProxy<CollectionService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let searchService: MockProxy<SearchService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
|
||||
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let biometricsService: MockProxy<BiometricsService>;
|
||||
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
|
||||
let loggedOutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: string]>;
|
||||
|
||||
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
|
||||
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
|
||||
|
||||
let vaultTimeoutService: VaultTimeoutService;
|
||||
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
cipherService = mock();
|
||||
folderService = mock();
|
||||
collectionService = mock();
|
||||
platformUtilsService = mock();
|
||||
messagingService = mock();
|
||||
searchService = mock();
|
||||
stateService = mock();
|
||||
authService = mock();
|
||||
vaultTimeoutSettingsService = mock();
|
||||
stateEventRunnerService = mock();
|
||||
taskSchedulerService = mock<TaskSchedulerService>();
|
||||
logService = mock<LogService>();
|
||||
biometricsService = mock<BiometricsService>();
|
||||
|
||||
lockedCallback = jest.fn();
|
||||
loggedOutCallback = jest.fn();
|
||||
|
||||
vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock);
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
|
||||
vaultTimeoutActionSubject,
|
||||
);
|
||||
|
||||
availableVaultTimeoutActionsSubject = new BehaviorSubject<VaultTimeoutAction[]>([]);
|
||||
|
||||
vaultTimeoutService = new VaultTimeoutService(
|
||||
accountService,
|
||||
masterPasswordService,
|
||||
cipherService,
|
||||
folderService,
|
||||
collectionService,
|
||||
platformUtilsService,
|
||||
messagingService,
|
||||
searchService,
|
||||
stateService,
|
||||
authService,
|
||||
vaultTimeoutSettingsService,
|
||||
stateEventRunnerService,
|
||||
taskSchedulerService,
|
||||
logService,
|
||||
biometricsService,
|
||||
lockedCallback,
|
||||
loggedOutCallback,
|
||||
);
|
||||
});
|
||||
|
||||
// Helper for setting up mocks for multiple users
|
||||
const setupAccounts = (
|
||||
accounts: Record<
|
||||
string,
|
||||
{
|
||||
authStatus?: AuthenticationStatus;
|
||||
isAuthenticated?: boolean;
|
||||
lastActive?: number;
|
||||
vaultTimeout?: VaultTimeout;
|
||||
timeoutAction?: VaultTimeoutAction;
|
||||
availableTimeoutActions?: VaultTimeoutAction[];
|
||||
}
|
||||
>,
|
||||
globalSetups?: {
|
||||
userId?: string;
|
||||
isViewOpen?: boolean;
|
||||
},
|
||||
) => {
|
||||
// Both are available by default and the specific test can change this per test
|
||||
availableVaultTimeoutActionsSubject.next([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]);
|
||||
|
||||
authService.authStatusFor$.mockImplementation((userId) => {
|
||||
return from([
|
||||
accounts[userId]?.authStatus ?? AuthenticationStatus.LoggedOut,
|
||||
AuthenticationStatus.Locked,
|
||||
]);
|
||||
});
|
||||
|
||||
authService.getAuthStatus.mockImplementation((userId) => {
|
||||
return Promise.resolve(accounts[userId]?.authStatus);
|
||||
});
|
||||
stateService.getIsAuthenticated.mockImplementation((options) => {
|
||||
// Just like actual state service, if no userId is given fallback to active userId
|
||||
return Promise.resolve(accounts[options.userId ?? globalSetups?.userId]?.isAuthenticated);
|
||||
});
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation((userId) => {
|
||||
return new BehaviorSubject<VaultTimeout>(accounts[userId]?.vaultTimeout);
|
||||
});
|
||||
|
||||
// Set desired user active and known users on accounts service : note the only thing that matters here is that the ID are set
|
||||
if (globalSetups?.userId) {
|
||||
accountService.activeAccountSubject.next({
|
||||
id: globalSetups.userId as UserId,
|
||||
email: null,
|
||||
emailVerified: false,
|
||||
name: null,
|
||||
});
|
||||
}
|
||||
accountService.accounts$ = of(
|
||||
Object.entries(accounts).reduce(
|
||||
(agg, [id]) => {
|
||||
agg[id] = {
|
||||
email: "",
|
||||
emailVerified: true,
|
||||
name: "",
|
||||
};
|
||||
return agg;
|
||||
},
|
||||
{} as Record<string, AccountInfo>,
|
||||
),
|
||||
);
|
||||
accountService.accountActivity$ = of(
|
||||
Object.entries(accounts).reduce(
|
||||
(agg, [id, info]) => {
|
||||
agg[id] = info.lastActive ? new Date(info.lastActive) : null;
|
||||
return agg;
|
||||
},
|
||||
{} as Record<string, Date>,
|
||||
),
|
||||
);
|
||||
|
||||
platformUtilsService.isViewOpen.mockResolvedValue(globalSetups?.isViewOpen ?? false);
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockImplementation((userId) => {
|
||||
return new BehaviorSubject<VaultTimeoutAction>(accounts[userId]?.timeoutAction);
|
||||
});
|
||||
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation((userId) => {
|
||||
return new BehaviorSubject<VaultTimeoutAction[]>(
|
||||
// Default to both options if it wasn't supplied at all
|
||||
accounts[userId]?.availableTimeoutActions ?? [
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutAction.LogOut,
|
||||
],
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const expectUserToHaveLocked = (userId: string) => {
|
||||
// This does NOT assert all the things that the lock process does
|
||||
expect(stateService.getIsAuthenticated).toHaveBeenCalledWith({ userId: userId });
|
||||
expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId);
|
||||
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId });
|
||||
expect(masterPasswordService.mock.clearMasterKey).toHaveBeenCalledWith(userId);
|
||||
expect(cipherService.clearCache).toHaveBeenCalledWith(userId);
|
||||
expect(lockedCallback).toHaveBeenCalledWith(userId);
|
||||
};
|
||||
|
||||
const expectUserToHaveLoggedOut = (userId: string) => {
|
||||
expect(loggedOutCallback).toHaveBeenCalledWith("vaultTimeout", userId);
|
||||
};
|
||||
|
||||
const expectNoAction = (userId: string) => {
|
||||
expect(lockedCallback).not.toHaveBeenCalledWith(userId);
|
||||
expect(loggedOutCallback).not.toHaveBeenCalledWith(any(), userId);
|
||||
};
|
||||
|
||||
describe("checkVaultTimeout", () => {
|
||||
it.each([AuthenticationStatus.Locked, AuthenticationStatus.LoggedOut])(
|
||||
"should not try to log out or lock any user that has authStatus === %s.",
|
||||
async (authStatus) => {
|
||||
platformUtilsService.isViewOpen.mockResolvedValue(false);
|
||||
setupAccounts({
|
||||
1: {
|
||||
authStatus: authStatus,
|
||||
isAuthenticated: true,
|
||||
},
|
||||
});
|
||||
|
||||
expectNoAction("1");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
])(
|
||||
"does not log out or lock a user who has %s as their vault timeout",
|
||||
async (vaultTimeout) => {
|
||||
setupAccounts({
|
||||
1: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
vaultTimeout: vaultTimeout as VaultTimeout,
|
||||
isAuthenticated: true,
|
||||
},
|
||||
});
|
||||
|
||||
await vaultTimeoutService.checkVaultTimeout();
|
||||
|
||||
expectNoAction("1");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, null])(
|
||||
"should not log out or lock a user who has %s lastActive value",
|
||||
async (lastActive) => {
|
||||
setupAccounts({
|
||||
1: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
vaultTimeout: 1, // One minute
|
||||
lastActive: lastActive,
|
||||
},
|
||||
});
|
||||
|
||||
await vaultTimeoutService.checkVaultTimeout();
|
||||
|
||||
expectNoAction("1");
|
||||
},
|
||||
);
|
||||
|
||||
it("should lock an account that isn't active and has immediate as their timeout when view is not open", async () => {
|
||||
// Arrange
|
||||
setupAccounts(
|
||||
{
|
||||
1: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
vaultTimeout: 0, // Immediately
|
||||
lastActive: new Date().getTime() - 10 * 1000, // Last active 10 seconds ago
|
||||
},
|
||||
2: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
vaultTimeout: 1, // One minute
|
||||
lastActive: new Date().getTime() - 10 * 1000, // Last active 10 seconds ago
|
||||
},
|
||||
},
|
||||
{
|
||||
isViewOpen: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Act
|
||||
await vaultTimeoutService.checkVaultTimeout();
|
||||
|
||||
// Assert
|
||||
expectUserToHaveLocked("1");
|
||||
expectNoAction("2");
|
||||
});
|
||||
|
||||
it("should run action on an account that hasn't been active for greater than 1 minute and has a vault timeout for 1 minutes", async () => {
|
||||
setupAccounts(
|
||||
{
|
||||
1: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
vaultTimeout: 1, // One minute
|
||||
lastActive: new Date().getTime() - 10 * 1000,
|
||||
},
|
||||
2: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
vaultTimeout: 1, // One minute
|
||||
lastActive: new Date().getTime() - 61 * 1000, // Last active 61 seconds ago
|
||||
},
|
||||
3: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
vaultTimeout: 1, // One minute
|
||||
lastActive: new Date().getTime() - 120 * 1000, // Last active 2 minutes ago
|
||||
timeoutAction: VaultTimeoutAction.LogOut,
|
||||
availableTimeoutActions: [VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut],
|
||||
},
|
||||
4: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
vaultTimeout: 1, // One minute
|
||||
lastActive: new Date().getTime() - 100 * 1000, // Last active 100 seconds ago
|
||||
timeoutAction: VaultTimeoutAction.Lock,
|
||||
availableTimeoutActions: [VaultTimeoutAction.LogOut],
|
||||
},
|
||||
},
|
||||
{ userId: "2", isViewOpen: false }, // Treat user 2 as the active user
|
||||
);
|
||||
|
||||
await vaultTimeoutService.checkVaultTimeout();
|
||||
|
||||
expectNoAction("1");
|
||||
expectUserToHaveLocked("2");
|
||||
|
||||
// Active users should have additional steps ran
|
||||
expect(searchService.clearIndex).toHaveBeenCalled();
|
||||
expect(folderService.clearDecryptedFolderState).toHaveBeenCalled();
|
||||
|
||||
expectUserToHaveLoggedOut("3"); // They have chosen logout as their action and it's available, log them out
|
||||
expectUserToHaveLoggedOut("4"); // They may have had lock as their chosen action but it's not available to them so logout
|
||||
});
|
||||
|
||||
it("should lock an account if they haven't been active passed their vault timeout even if a view is open when they are not the active user.", async () => {
|
||||
setupAccounts(
|
||||
{
|
||||
1: {
|
||||
// Neither of these setup values ever get called
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
lastActive: new Date().getTime() - 80 * 1000, // Last active 80 seconds ago
|
||||
vaultTimeout: 1, // Vault timeout of 1 minute
|
||||
},
|
||||
},
|
||||
{ userId: "2", isViewOpen: true },
|
||||
);
|
||||
|
||||
await vaultTimeoutService.checkVaultTimeout();
|
||||
|
||||
expectUserToHaveLocked("1");
|
||||
});
|
||||
|
||||
it("should not lock an account that is active and we know that a view is open, even if they haven't been active passed their timeout", async () => {
|
||||
setupAccounts(
|
||||
{
|
||||
1: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
lastActive: new Date().getTime() - 80 * 1000, // Last active 80 seconds ago
|
||||
vaultTimeout: 1, // Vault timeout of 1 minute
|
||||
},
|
||||
},
|
||||
{ userId: "1", isViewOpen: true }, // They are the currently active user
|
||||
);
|
||||
|
||||
await vaultTimeoutService.checkVaultTimeout();
|
||||
|
||||
expectNoAction("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lock", () => {
|
||||
const setupLock = () => {
|
||||
setupAccounts(
|
||||
{
|
||||
user1: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
},
|
||||
user2: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: "user1",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
it("should call state event runner with currently active user if no user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
await vaultTimeoutService.lock();
|
||||
|
||||
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
|
||||
});
|
||||
|
||||
it("should call locked callback if no user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
await vaultTimeoutService.lock();
|
||||
|
||||
// Currently these pass `undefined` (or what they were given) as the userId back
|
||||
// but we could change this to give the user that was locked (active) to these methods
|
||||
// so they don't have to get it their own way, but that is a behavioral change that needs
|
||||
// to be tested.
|
||||
expect(lockedCallback).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("should call state event runner with user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
const user2 = "user2" as UserId;
|
||||
|
||||
await vaultTimeoutService.lock(user2);
|
||||
|
||||
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", user2);
|
||||
});
|
||||
|
||||
it("should call messaging service locked message with user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
const user2 = "user2" as UserId;
|
||||
|
||||
await vaultTimeoutService.lock(user2);
|
||||
|
||||
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: user2 });
|
||||
});
|
||||
|
||||
it("should call locked callback with user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
const user2 = "user2" as UserId;
|
||||
|
||||
await vaultTimeoutService.lock(user2);
|
||||
|
||||
expect(lockedCallback).toHaveBeenCalledWith(user2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, concatMap, filter, firstValueFrom, map, timeout } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
import { SearchService } from "../../abstractions/search.service";
|
||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout.service";
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../auth/abstractions/master-password.service.abstraction";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { TaskSchedulerService, ScheduledTaskNames } from "../../platform/scheduling";
|
||||
import { StateEventRunnerService } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherService } from "../../vault/abstractions/cipher.service";
|
||||
import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
|
||||
|
||||
export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
private inited = false;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private cipherService: CipherService,
|
||||
private folderService: FolderService,
|
||||
private collectionService: CollectionService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
private messagingService: MessagingService,
|
||||
private searchService: SearchService,
|
||||
private stateService: StateService,
|
||||
private authService: AuthService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private stateEventRunnerService: StateEventRunnerService,
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
protected logService: LogService,
|
||||
private biometricService: BiometricsService,
|
||||
private lockedCallback: (userId?: string) => Promise<void> = null,
|
||||
private loggedOutCallback: (
|
||||
logoutReason: LogoutReason,
|
||||
userId?: string,
|
||||
) => Promise<void> = null,
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.vaultTimeoutCheckInterval,
|
||||
() => this.checkVaultTimeout(),
|
||||
);
|
||||
}
|
||||
|
||||
async init(checkOnInterval: boolean) {
|
||||
if (this.inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inited = true;
|
||||
if (checkOnInterval) {
|
||||
this.startCheck();
|
||||
}
|
||||
}
|
||||
|
||||
startCheck() {
|
||||
this.checkVaultTimeout().catch((error) => this.logService.error(error));
|
||||
this.taskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.vaultTimeoutCheckInterval,
|
||||
10 * 1000, // check every 10 seconds
|
||||
);
|
||||
}
|
||||
|
||||
async checkVaultTimeout(): Promise<void> {
|
||||
// Get whether or not the view is open a single time so it can be compared for each user
|
||||
const isViewOpen = await this.platformUtilsService.isViewOpen();
|
||||
|
||||
await firstValueFrom(
|
||||
combineLatest([
|
||||
this.accountService.activeAccount$,
|
||||
this.accountService.accountActivity$,
|
||||
]).pipe(
|
||||
concatMap(async ([activeAccount, accountActivity]) => {
|
||||
const activeUserId = activeAccount?.id;
|
||||
for (const userIdString in accountActivity) {
|
||||
const userId = userIdString as UserId;
|
||||
if (
|
||||
userId != null &&
|
||||
(await this.shouldLock(userId, accountActivity[userId], activeUserId, isViewOpen))
|
||||
) {
|
||||
await this.executeTimeoutAction(userId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async lock(userId?: UserId): Promise<void> {
|
||||
await this.biometricService.setShouldAutopromptNow(false);
|
||||
|
||||
const authed = await this.stateService.getIsAuthenticated({ userId: userId });
|
||||
if (!authed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableActions = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
|
||||
);
|
||||
const supportsLock = availableActions.includes(VaultTimeoutAction.Lock);
|
||||
if (!supportsLock) {
|
||||
await this.logOut(userId);
|
||||
}
|
||||
|
||||
const currentUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
const lockingUserId = userId ?? currentUserId;
|
||||
|
||||
// HACK: Start listening for the transition of the locking user from something to the locked state.
|
||||
// This is very much a hack to ensure that the authentication status to retrievable right after
|
||||
// it does its work. Particularly the `lockedCallback` and `"locked"` message. Instead
|
||||
// lockedCallback should be deprecated and people should subscribe and react to `authStatusFor$` themselves.
|
||||
const lockPromise = firstValueFrom(
|
||||
this.authService.authStatusFor$(lockingUserId).pipe(
|
||||
filter((authStatus) => authStatus === AuthenticationStatus.Locked),
|
||||
timeout({
|
||||
first: 5_000,
|
||||
with: () => {
|
||||
throw new Error("The lock process did not complete in a reasonable amount of time.");
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (userId == null || userId === currentUserId) {
|
||||
await this.searchService.clearIndex();
|
||||
await this.collectionService.clearActiveUserCache();
|
||||
}
|
||||
|
||||
await this.folderService.clearDecryptedFolderState(lockingUserId);
|
||||
await this.masterPasswordService.clearMasterKey(lockingUserId);
|
||||
|
||||
await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId });
|
||||
await this.stateService.setCryptoMasterKeyAuto(null, { userId: lockingUserId });
|
||||
|
||||
await this.cipherService.clearCache(lockingUserId);
|
||||
|
||||
await this.stateEventRunnerService.handleEvent("lock", lockingUserId);
|
||||
|
||||
// HACK: Sit here and wait for the the auth status to transition to `Locked`
|
||||
// to ensure the message and lockedCallback will get the correct status
|
||||
// if/when they call it.
|
||||
await lockPromise;
|
||||
|
||||
this.messagingService.send("locked", { userId: lockingUserId });
|
||||
|
||||
if (this.lockedCallback != null) {
|
||||
await this.lockedCallback(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async logOut(userId?: string): Promise<void> {
|
||||
if (this.loggedOutCallback != null) {
|
||||
await this.loggedOutCallback("vaultTimeout", userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async shouldLock(
|
||||
userId: string,
|
||||
lastActive: Date,
|
||||
activeUserId: string,
|
||||
isViewOpen: boolean,
|
||||
): Promise<boolean> {
|
||||
if (isViewOpen && userId === activeUserId) {
|
||||
// We know a view is open and this is the currently active user
|
||||
// which means they are likely looking at their vault
|
||||
// and they should not lock.
|
||||
return false;
|
||||
}
|
||||
|
||||
const authStatus = await this.authService.getAuthStatus(userId);
|
||||
if (
|
||||
authStatus === AuthenticationStatus.Locked ||
|
||||
authStatus === AuthenticationStatus.LoggedOut
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const vaultTimeout = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(userId),
|
||||
);
|
||||
|
||||
if (typeof vaultTimeout === "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lastActive == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const vaultTimeoutSeconds = vaultTimeout * 60;
|
||||
const diffSeconds = (new Date().getTime() - lastActive.getTime()) / 1000;
|
||||
return diffSeconds >= vaultTimeoutSeconds;
|
||||
}
|
||||
|
||||
private async executeTimeoutAction(userId: UserId): Promise<void> {
|
||||
const timeoutAction = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(userId),
|
||||
);
|
||||
timeoutAction === VaultTimeoutAction.LogOut
|
||||
? await this.logOut(userId)
|
||||
: await this.lock(userId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user