diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index cdce2a692c..9771006c8a 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -94,7 +94,7 @@ export class WindowMain { // down the application. app.on("before-quit", async () => { // Allow biometric to auto-prompt on reload - await this.biometricStateService.resetPromptCancelled(); + await this.biometricStateService.resetAllPromptCancelled(); this.isQuitting = true; }); diff --git a/libs/angular/src/auth/components/lock.component.ts b/libs/angular/src/auth/components/lock.component.ts index 7c5d3aace5..c21ba1a75a 100644 --- a/libs/angular/src/auth/components/lock.component.ts +++ b/libs/angular/src/auth/components/lock.component.ts @@ -119,7 +119,7 @@ export class LockComponent implements OnInit, OnDestroy { return; } - await this.biometricStateService.setPromptCancelled(); + await this.biometricStateService.setUserPromptCancelled(); const userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric); if (userKey) { @@ -276,7 +276,7 @@ export class LockComponent implements OnInit, OnDestroy { private async doContinue(evaluatePasswordAfterUnlock: boolean) { await this.stateService.setEverBeenUnlocked(true); - await this.biometricStateService.resetPromptCancelled(); + await this.biometricStateService.resetUserPromptCancelled(); this.messagingService.send("unlocked"); if (evaluatePasswordAfterUnlock) { diff --git a/libs/common/spec/fake-account-service.ts b/libs/common/spec/fake-account-service.ts index 1364127f65..2f33d9cf02 100644 --- a/libs/common/spec/fake-account-service.ts +++ b/libs/common/spec/fake-account-service.ts @@ -70,6 +70,9 @@ export class FakeAccountService implements AccountService { } async switchAccount(userId: UserId): Promise { + const next = + userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] }; + this.activeAccountSubject.next(next); await this.mock.switchAccount(userId); } } diff --git a/libs/common/src/platform/biometrics/biometric-state.service.spec.ts b/libs/common/src/platform/biometrics/biometric-state.service.spec.ts index 716ad627c1..097428e16a 100644 --- a/libs/common/src/platform/biometrics/biometric-state.service.spec.ts +++ b/libs/common/src/platform/biometrics/biometric-state.service.spec.ts @@ -1,8 +1,8 @@ import { firstValueFrom } from "rxjs"; -import { makeEncString } from "../../../spec"; -import { mockAccountServiceWith } from "../../../spec/fake-account-service"; -import { FakeSingleUserState } from "../../../spec/fake-state"; +import { makeEncString, trackEmissions } from "../../../spec"; +import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; +import { FakeGlobalState, FakeSingleUserState } from "../../../spec/fake-state"; import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { UserId } from "../../types/guid"; import { EncryptedString } from "../models/domain/enc-string"; @@ -23,10 +23,11 @@ describe("BiometricStateService", () => { const userId = "userId" as UserId; const encClientKeyHalf = makeEncString(); const encryptedClientKeyHalf = encClientKeyHalf.encryptedString; - const accountService = mockAccountServiceWith(userId); + let accountService: FakeAccountService; let stateProvider: FakeStateProvider; beforeEach(() => { + accountService = mockAccountServiceWith(userId); stateProvider = new FakeStateProvider(accountService); sut = new DefaultBiometricStateService(stateProvider); @@ -145,19 +146,89 @@ describe("BiometricStateService", () => { }); describe("setPromptCancelled", () => { + let existingState: Record; + + beforeEach(() => { + existingState = { ["otherUser" as UserId]: false }; + stateProvider.global.getFake(PROMPT_CANCELLED).stateSubject.next(existingState); + }); + test("observable is updated", async () => { - await sut.setPromptCancelled(); + await sut.setUserPromptCancelled(); expect(await firstValueFrom(sut.promptCancelled$)).toBe(true); }); it("updates state", async () => { - await sut.setPromptCancelled(); + await sut.setUserPromptCancelled(); - const nextMock = stateProvider.activeUser.getFake(PROMPT_CANCELLED).nextMock; - expect(nextMock).toHaveBeenCalledWith([userId, true]); + const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock; + expect(nextMock).toHaveBeenCalledWith({ ...existingState, [userId]: true }); expect(nextMock).toHaveBeenCalledTimes(1); }); + + it("throws when called with no active user", async () => { + await accountService.switchAccount(null); + await expect(sut.setUserPromptCancelled()).rejects.toThrow( + "Cannot update biometric prompt cancelled state without an active user", + ); + const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock; + expect(nextMock).not.toHaveBeenCalled(); + }); + }); + + describe("resetAllPromptCancelled", () => { + it("deletes all prompt cancelled state", async () => { + await sut.resetAllPromptCancelled(); + + const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock; + expect(nextMock).toHaveBeenCalledWith(null); + expect(nextMock).toHaveBeenCalledTimes(1); + }); + + it("updates observable to false", async () => { + const emissions = trackEmissions(sut.promptCancelled$); + + await sut.setUserPromptCancelled(); + + await sut.resetAllPromptCancelled(); + + expect(emissions).toEqual([false, true, false]); + }); + }); + + describe("resetUserPromptCancelled", () => { + let existingState: Record; + let state: FakeGlobalState>; + + beforeEach(async () => { + await accountService.switchAccount(userId); + existingState = { [userId]: true, ["otherUser" as UserId]: false }; + state = stateProvider.global.getFake(PROMPT_CANCELLED); + state.stateSubject.next(existingState); + }); + + it("deletes specified user prompt cancelled state", async () => { + await sut.resetUserPromptCancelled("otherUser" as UserId); + + expect(state.nextMock).toHaveBeenCalledWith({ [userId]: true }); + expect(state.nextMock).toHaveBeenCalledTimes(1); + }); + + it("deletes active user when called with no user", async () => { + await sut.resetUserPromptCancelled(); + + expect(state.nextMock).toHaveBeenCalledWith({ ["otherUser" as UserId]: false }); + expect(state.nextMock).toHaveBeenCalledTimes(1); + }); + + it("updates observable to false", async () => { + const emissions = trackEmissions(sut.promptCancelled$); + + await sut.resetUserPromptCancelled(); + + expect(emissions).toEqual([true, false]); + }); }); describe("setPromptAutomatically", () => { diff --git a/libs/common/src/platform/biometrics/biometric-state.service.ts b/libs/common/src/platform/biometrics/biometric-state.service.ts index 2047d137b5..82c05542b4 100644 --- a/libs/common/src/platform/biometrics/biometric-state.service.ts +++ b/libs/common/src/platform/biometrics/biometric-state.service.ts @@ -1,4 +1,4 @@ -import { Observable, firstValueFrom, map } from "rxjs"; +import { Observable, firstValueFrom, map, combineLatest } from "rxjs"; import { UserId } from "../../types/guid"; import { EncryptedString, EncString } from "../models/domain/enc-string"; @@ -81,13 +81,18 @@ export abstract class BiometricStateService { */ abstract setDismissedRequirePasswordOnStartCallout(): Promise; /** - * Updates the active user's state to reflect that they've cancelled the biometric prompt this lock. + * Updates the active user's state to reflect that they've cancelled the biometric prompt. */ - abstract setPromptCancelled(): Promise; + abstract setUserPromptCancelled(): Promise; /** - * Resets the active user's state to reflect that they haven't cancelled the biometric prompt this lock. + * Resets the given user's state to reflect that they haven't cancelled the biometric prompt. + * @param userId the user to reset the prompt cancelled state for. If not provided, the currently active user will be used. */ - abstract resetPromptCancelled(): Promise; + abstract resetUserPromptCancelled(userId?: UserId): Promise; + /** + * Resets all user's state to reflect that they haven't cancelled the biometric prompt. + */ + abstract resetAllPromptCancelled(): Promise; /** * Updates the currently active user's setting for auto prompting for biometrics on application start and lock * @param prompt Whether or not to prompt for biometrics on application start. @@ -107,7 +112,7 @@ export class DefaultBiometricStateService implements BiometricStateService { private requirePasswordOnStartState: ActiveUserState; private encryptedClientKeyHalfState: ActiveUserState; private dismissedRequirePasswordOnStartCalloutState: ActiveUserState; - private promptCancelledState: ActiveUserState; + private promptCancelledState: GlobalState>; private promptAutomaticallyState: ActiveUserState; private fingerprintValidatedState: GlobalState; biometricUnlockEnabled$: Observable; @@ -138,8 +143,15 @@ export class DefaultBiometricStateService implements BiometricStateService { this.dismissedRequirePasswordOnStartCallout$ = this.dismissedRequirePasswordOnStartCalloutState.state$.pipe(map(Boolean)); - this.promptCancelledState = this.stateProvider.getActive(PROMPT_CANCELLED); - this.promptCancelled$ = this.promptCancelledState.state$.pipe(map(Boolean)); + this.promptCancelledState = this.stateProvider.getGlobal(PROMPT_CANCELLED); + this.promptCancelled$ = combineLatest([ + this.stateProvider.activeUserId$, + this.promptCancelledState.state$, + ]).pipe( + map(([userId, record]) => { + return record?.[userId] ?? false; + }), + ); this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY); this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map(Boolean)); @@ -202,7 +214,7 @@ export class DefaultBiometricStateService implements BiometricStateService { async logout(userId: UserId): Promise { await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null); - await this.stateProvider.getUser(userId, PROMPT_CANCELLED).update(() => null); + await this.resetUserPromptCancelled(userId); // Persist auto prompt setting through logout // Persist dismissed require password on start callout through logout } @@ -211,11 +223,41 @@ export class DefaultBiometricStateService implements BiometricStateService { await this.dismissedRequirePasswordOnStartCalloutState.update(() => true); } - async setPromptCancelled(): Promise { - await this.promptCancelledState.update(() => true); + async resetUserPromptCancelled(userId: UserId): Promise { + await this.stateProvider.getGlobal(PROMPT_CANCELLED).update( + (data, activeUserId) => { + delete data[userId ?? activeUserId]; + return data; + }, + { + combineLatestWith: this.stateProvider.activeUserId$, + shouldUpdate: (data, activeUserId) => data?.[userId ?? activeUserId] != null, + }, + ); } - async resetPromptCancelled(): Promise { + async setUserPromptCancelled(): Promise { + await this.promptCancelledState.update( + (record, userId) => { + record ??= {}; + record[userId] = true; + return record; + }, + { + combineLatestWith: this.stateProvider.activeUserId$, + shouldUpdate: (_, userId) => { + if (userId == null) { + throw new Error( + "Cannot update biometric prompt cancelled state without an active user", + ); + } + return true; + }, + }, + ); + } + + async resetAllPromptCancelled(): Promise { await this.promptCancelledState.update(() => null); } diff --git a/libs/common/src/platform/biometrics/biometric.state.spec.ts b/libs/common/src/platform/biometrics/biometric.state.spec.ts index a3b110c77c..420a0fb86e 100644 --- a/libs/common/src/platform/biometrics/biometric.state.spec.ts +++ b/libs/common/src/platform/biometrics/biometric.state.spec.ts @@ -14,7 +14,7 @@ import { describe.each([ [ENCRYPTED_CLIENT_KEY_HALF, "encryptedClientKeyHalf"], [DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, true], - [PROMPT_CANCELLED, true], + [PROMPT_CANCELLED, { userId1: true, userId2: false }], [PROMPT_AUTOMATICALLY, true], [REQUIRE_PASSWORD_ON_START, true], [BIOMETRIC_UNLOCK_ENABLED, true], diff --git a/libs/common/src/platform/biometrics/biometric.state.ts b/libs/common/src/platform/biometrics/biometric.state.ts index a5041ca8d0..aa16e14baa 100644 --- a/libs/common/src/platform/biometrics/biometric.state.ts +++ b/libs/common/src/platform/biometrics/biometric.state.ts @@ -1,3 +1,4 @@ +import { UserId } from "../../types/guid"; import { EncryptedString } from "../models/domain/enc-string"; import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state"; @@ -56,7 +57,7 @@ export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new KeyDefinition( +export const PROMPT_CANCELLED = KeyDefinition.record( BIOMETRIC_SETTINGS_DISK, "promptCancelled", { diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 7fc4fb6de2..35a53c2341 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -41,6 +41,7 @@ import { EnableFaviconMigrator } from "./migrations/42-move-enable-favicon-to-do import { AutoConfirmFingerPrintsMigrator } from "./migrations/43-move-auto-confirm-finger-prints-to-state-provider"; import { UserDecryptionOptionsMigrator } from "./migrations/44-move-user-decryption-options-to-state-provider"; import { MergeEnvironmentState } from "./migrations/45-merge-environment-state"; +import { DeleteBiometricPromptCancelledData } from "./migrations/46-delete-orphaned-biometric-prompt-data"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; @@ -49,8 +50,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 45; - +export const CURRENT_VERSION = 46; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -97,7 +97,8 @@ export function createMigrationBuilder() { .with(EnableFaviconMigrator, 41, 42) .with(AutoConfirmFingerPrintsMigrator, 42, 43) .with(UserDecryptionOptionsMigrator, 43, 44) - .with(MergeEnvironmentState, 44, CURRENT_VERSION); + .with(MergeEnvironmentState, 44, 45) + .with(DeleteBiometricPromptCancelledData, 45, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts b/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts new file mode 100644 index 0000000000..744f39709d --- /dev/null +++ b/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts @@ -0,0 +1,28 @@ +import { runMigrator } from "../migration-helper.spec"; +import { IRREVERSIBLE } from "../migrator"; + +import { DeleteBiometricPromptCancelledData } from "./46-delete-orphaned-biometric-prompt-data"; + +describe("MoveThemeToStateProviders", () => { + const sut = new DeleteBiometricPromptCancelledData(45, 46); + + describe("migrate", () => { + it("deletes promptCancelled from all users", async () => { + const output = await runMigrator(sut, { + authenticatedAccounts: ["user-1", "user-2"], + "user_user-1_biometricSettings_promptCancelled": true, + "user_user-2_biometricSettings_promptCancelled": false, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user-1", "user-2"], + }); + }); + }); + + describe("rollback", () => { + it("is irreversible", async () => { + await expect(runMigrator(sut, {}, "rollback")).rejects.toThrow(IRREVERSIBLE); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts b/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts new file mode 100644 index 0000000000..a919e999e1 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts @@ -0,0 +1,23 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { IRREVERSIBLE, Migrator } from "../migrator"; + +export const PROMPT_CANCELLED: KeyDefinitionLike = { + key: "promptCancelled", + stateDefinition: { name: "biometricSettings" }, +}; + +export class DeleteBiometricPromptCancelledData extends Migrator<45, 46> { + async migrate(helper: MigrationHelper): Promise { + await Promise.all( + (await helper.getAccounts()).map(async ({ userId }) => { + if (helper.getFromUser(userId, PROMPT_CANCELLED) != null) { + await helper.removeFromUser(userId, PROMPT_CANCELLED); + } + }), + ); + } + + async rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } +}