diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 5203edf0a4..ec0fac137d 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -604,6 +604,15 @@ "yourVaultIsLocked": { "message": "Your vault is locked. Verify your identity to continue." }, + "yourVaultIsLockedV2": { + "message": "Your vault is locked" + }, + "yourAccountIsLocked": { + "message": "Your account is locked" + }, + "or": { + "message": "or" + }, "unlock": { "message": "Unlock" }, @@ -1936,6 +1945,9 @@ "unlockWithBiometrics": { "message": "Unlock with biometrics" }, + "unlockWithMasterPassword": { + "message": "Unlock with master password" + }, "awaitDesktop": { "message": "Awaiting confirmation from desktop" }, @@ -3623,6 +3635,9 @@ "typePasskey": { "message": "Passkey" }, + "accessing": { + "message": "Accessing" + }, "passkeyNotCopied": { "message": "Passkey will not be copied" }, diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.ts b/apps/browser/src/auth/popup/account-switching/current-account.component.ts index 6c7c1e7d92..12210b2b45 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.ts +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.ts @@ -59,7 +59,7 @@ export class CurrentAccountComponent { } async currentAccountClicked() { - if (this.route.snapshot.data.state.includes("account-switcher")) { + if (this.route.snapshot.data?.state?.includes("account-switcher")) { this.location.back(); } else { await this.router.navigate(["/account-switcher"]); diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index d540ea39ed..9fd52470c0 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -17,6 +17,8 @@ import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, + LockIcon, + LockV2Component, PasswordHintComponent, RegistrationFinishComponent, RegistrationStartComponent, @@ -181,6 +183,7 @@ const routes: Routes = [ path: "lock", component: LockComponent, canActivate: [lockGuard()], + canMatch: [extensionRefreshRedirect("/lockV2")], data: { state: "lock", doNotSaveUrl: true } satisfies RouteDataProperties, }, ...twofactorRefactorSwap( @@ -438,6 +441,28 @@ const routes: Routes = [ ], }, ), + { + path: "", + component: ExtensionAnonLayoutWrapperComponent, + children: [ + { + path: "lockV2", + canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh), lockGuard()], + data: { + pageIcon: LockIcon, + pageTitle: "yourVaultIsLockedV2", + showReadonlyHostname: true, + showAcctSwitcher: true, + } satisfies ExtensionAnonLayoutWrapperData, + children: [ + { + path: "", + component: LockV2Component, + }, + ], + }, + ], + }, { path: "", component: AnonLayoutWrapperComponent, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 483bf86712..024b4f4631 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -16,7 +16,7 @@ import { CLIENT_TYPE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; -import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; +import { AnonLayoutWrapperDataService, LockComponentService } from "@bitwarden/auth/angular"; import { LockService, PinServiceAbstraction } from "@bitwarden/auth/common"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; @@ -117,6 +117,7 @@ import { ForegroundTaskSchedulerService } from "../../platform/services/task-sch import { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging"; +import { ExtensionLockComponentService } from "../../services/extension-lock-component.service"; import { ForegroundVaultTimeoutService } from "../../services/vault-timeout/foreground-vault-timeout.service"; import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; @@ -536,6 +537,11 @@ const safeProviders: SafeProvider[] = [ provide: CLIENT_TYPE, useValue: ClientType.Browser, }), + safeProvider({ + provide: LockComponentService, + useClass: ExtensionLockComponentService, + deps: [], + }), safeProvider({ provide: Fido2UserVerificationService, useClass: Fido2UserVerificationService, diff --git a/apps/browser/src/services/extension-lock-component.service.spec.ts b/apps/browser/src/services/extension-lock-component.service.spec.ts new file mode 100644 index 0000000000..f537897cf8 --- /dev/null +++ b/apps/browser/src/services/extension-lock-component.service.spec.ts @@ -0,0 +1,325 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/auth/angular"; +import { + PinServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricsService } from "@bitwarden/key-management"; + +import { BrowserRouterService } from "../platform/popup/services/browser-router.service"; + +import { ExtensionLockComponentService } from "./extension-lock-component.service"; + +describe("ExtensionLockComponentService", () => { + let service: ExtensionLockComponentService; + + let userDecryptionOptionsService: MockProxy; + let platformUtilsService: MockProxy; + let biometricsService: MockProxy; + let pinService: MockProxy; + let vaultTimeoutSettingsService: MockProxy; + let cryptoService: MockProxy; + let routerService: MockProxy; + + beforeEach(() => { + userDecryptionOptionsService = mock(); + platformUtilsService = mock(); + biometricsService = mock(); + pinService = mock(); + vaultTimeoutSettingsService = mock(); + cryptoService = mock(); + routerService = mock(); + + TestBed.configureTestingModule({ + providers: [ + ExtensionLockComponentService, + { + provide: UserDecryptionOptionsServiceAbstraction, + useValue: userDecryptionOptionsService, + }, + { + provide: PlatformUtilsService, + useValue: platformUtilsService, + }, + { + provide: BiometricsService, + useValue: biometricsService, + }, + { + provide: PinServiceAbstraction, + useValue: pinService, + }, + { + provide: VaultTimeoutSettingsService, + useValue: vaultTimeoutSettingsService, + }, + { + provide: CryptoService, + useValue: cryptoService, + }, + { + provide: BrowserRouterService, + useValue: routerService, + }, + ], + }); + + service = TestBed.inject(ExtensionLockComponentService); + }); + + it("instantiates", () => { + expect(service).not.toBeFalsy(); + }); + + describe("getPreviousUrl", () => { + it("returns the previous URL", () => { + routerService.getPreviousUrl.mockReturnValue("previousUrl"); + expect(service.getPreviousUrl()).toBe("previousUrl"); + }); + }); + + describe("getBiometricsError", () => { + it("returns a biometric error description when given a valid error type", () => { + expect( + service.getBiometricsError({ + message: "startDesktop", + }), + ).toBe("startDesktopDesc"); + }); + + it("returns null when given an invalid error type", () => { + expect( + service.getBiometricsError({ + message: "invalidError", + }), + ).toBeNull(); + }); + + it("returns null when given a null input", () => { + expect(service.getBiometricsError(null)).toBeNull(); + }); + }); + + describe("isWindowVisible", () => { + it("throws an error", async () => { + await expect(service.isWindowVisible()).rejects.toThrow("Method not implemented."); + }); + }); + + describe("getBiometricsUnlockBtnText", () => { + it("returns the biometric unlock button text", () => { + expect(service.getBiometricsUnlockBtnText()).toBe("unlockWithBiometrics"); + }); + }); + + describe("getAvailableUnlockOptions$", () => { + interface MockInputs { + hasMasterPassword: boolean; + osSupportsBiometric: boolean; + biometricLockSet: boolean; + hasBiometricEncryptedUserKeyStored: boolean; + platformSupportsSecureStorage: boolean; + pinDecryptionAvailable: boolean; + } + + const table: [MockInputs, UnlockOptions][] = [ + [ + // MP + PIN + Biometrics available + { + hasMasterPassword: true, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: true, + }, + { + masterPassword: { + enabled: true, + }, + pin: { + enabled: true, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // PIN + Biometrics available + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: true, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: true, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics available: user key stored with no secure storage + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + platformSupportsSecureStorage: false, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics available: no user key stored with no secure storage + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: false, + platformSupportsSecureStorage: false, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics not available: biometric lock not set + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: false, + hasBiometricEncryptedUserKeyStored: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.EncryptedKeysUnavailable, + }, + }, + ], + [ + // Biometrics not available: user key not stored + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: false, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.EncryptedKeysUnavailable, + }, + }, + ], + [ + // Biometrics not available: OS doesn't support + { + hasMasterPassword: false, + osSupportsBiometric: false, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem, + }, + }, + ], + ]; + + test.each(table)("returns unlock options", async (mockInputs, expectedOutput) => { + const userId = "userId" as UserId; + const userDecryptionOptions = { + hasMasterPassword: mockInputs.hasMasterPassword, + }; + + // MP + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of(userDecryptionOptions), + ); + + // Biometrics + biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric); + vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet); + cryptoService.hasUserKeyStored.mockResolvedValue( + mockInputs.hasBiometricEncryptedUserKeyStored, + ); + platformUtilsService.supportsSecureStorage.mockReturnValue( + mockInputs.platformSupportsSecureStorage, + ); + + // PIN + pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable); + + const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); + + expect(unlockOptions).toEqual(expectedOutput); + }); + }); +}); diff --git a/apps/browser/src/services/extension-lock-component.service.ts b/apps/browser/src/services/extension-lock-component.service.ts new file mode 100644 index 0000000000..58514fa2b1 --- /dev/null +++ b/apps/browser/src/services/extension-lock-component.service.ts @@ -0,0 +1,117 @@ +import { inject } from "@angular/core"; +import { combineLatest, defer, map, Observable } from "rxjs"; + +import { + BiometricsDisableReason, + LockComponentService, + UnlockOptions, +} from "@bitwarden/auth/angular"; +import { + PinServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricsService } from "@bitwarden/key-management"; + +import { BiometricErrors, BiometricErrorTypes } from "../models/biometricErrors"; +import { BrowserRouterService } from "../platform/popup/services/browser-router.service"; + +export class ExtensionLockComponentService implements LockComponentService { + private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); + private readonly platformUtilsService = inject(PlatformUtilsService); + private readonly biometricsService = inject(BiometricsService); + private readonly pinService = inject(PinServiceAbstraction); + private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService); + private readonly cryptoService = inject(CryptoService); + private readonly routerService = inject(BrowserRouterService); + + getPreviousUrl(): string | null { + return this.routerService.getPreviousUrl(); + } + + getBiometricsError(error: any): string | null { + const biometricsError = BiometricErrors[error?.message as BiometricErrorTypes]; + + if (!biometricsError) { + return null; + } + + return biometricsError.description; + } + + async isWindowVisible(): Promise { + throw new Error("Method not implemented."); + } + + getBiometricsUnlockBtnText(): string { + return "unlockWithBiometrics"; + } + + private async isBiometricLockSet(userId: UserId): Promise { + const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId); + const hasBiometricEncryptedUserKeyStored = await this.cryptoService.hasUserKeyStored( + KeySuffixOptions.Biometric, + userId, + ); + const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage(); + + return ( + biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage) + ); + } + + private getBiometricsDisabledReason( + osSupportsBiometric: boolean, + biometricLockSet: boolean, + ): BiometricsDisableReason | null { + if (!osSupportsBiometric) { + return BiometricsDisableReason.NotSupportedOnOperatingSystem; + } else if (!biometricLockSet) { + return BiometricsDisableReason.EncryptedKeysUnavailable; + } + + return null; + } + + getAvailableUnlockOptions$(userId: UserId): Observable { + return combineLatest([ + // Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to + defer(() => this.biometricsService.supportsBiometric()), + defer(() => this.isBiometricLockSet(userId)), + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + defer(() => this.pinService.isPinDecryptionAvailable(userId)), + ]).pipe( + map( + ([ + supportsBiometric, + isBiometricsLockSet, + userDecryptionOptions, + pinDecryptionAvailable, + ]) => { + const disableReason = this.getBiometricsDisabledReason( + supportsBiometric, + isBiometricsLockSet, + ); + + const unlockOpts: UnlockOptions = { + masterPassword: { + enabled: userDecryptionOptions.hasMasterPassword, + }, + pin: { + enabled: pinDecryptionAvailable, + }, + biometrics: { + enabled: supportsBiometric && isBiometricsLockSet, + disableReason: disableReason, + }, + }; + return unlockOpts; + }, + ), + ); + } +} diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 1e13be12a7..86a39163f3 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -11,9 +11,12 @@ import { unauthGuardFn, } from "@bitwarden/angular/auth/guards"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; +import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, + LockIcon, + LockV2Component, PasswordHintComponent, RegistrationFinishComponent, RegistrationStartComponent, @@ -62,6 +65,7 @@ const routes: Routes = [ path: "lock", component: LockComponent, canActivate: [lockGuard()], + canMatch: [extensionRefreshRedirect("/lockV2")], }, { path: "login", @@ -190,6 +194,21 @@ const routes: Routes = [ }, ], }, + { + path: "lockV2", + canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh), lockGuard()], + data: { + pageIcon: LockIcon, + pageTitle: "yourVaultIsLockedV2", + showReadonlyHostname: true, + } satisfies AnonLayoutWrapperData, + children: [ + { + path: "", + component: LockV2Component, + }, + ], + }, { path: "set-password-jit", canActivate: [canAccessFeature(FeatureFlag.EmailVerification)], diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index d3d41d277b..c6b73fbbbc 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -19,7 +19,7 @@ import { CLIENT_TYPE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; -import { SetPasswordJitService } from "@bitwarden/auth/angular"; +import { LockComponentService, SetPasswordJitService } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, PinServiceAbstraction, @@ -86,6 +86,7 @@ import { ElectronRendererStorageService } from "../../platform/services/electron import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; import { fromIpcMessaging } from "../../platform/utils/from-ipc-messaging"; import { fromIpcSystemTheme } from "../../platform/utils/from-ipc-system-theme"; +import { DesktopLockComponentService } from "../../services/desktop-lock-component.service"; import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service"; import { NativeMessageHandlerService } from "../../services/native-message-handler.service"; import { NativeMessagingService } from "../../services/native-messaging.service"; @@ -277,6 +278,11 @@ const safeProviders: SafeProvider[] = [ useClass: NativeMessagingManifestService, deps: [], }), + safeProvider({ + provide: LockComponentService, + useClass: DesktopLockComponentService, + deps: [], + }), safeProvider({ provide: CLIENT_TYPE, useValue: ClientType.Desktop, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 9504ecb1fa..0b7a9c678c 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -918,6 +918,18 @@ "yourVaultIsLocked": { "message": "Your vault is locked. Verify your identity to continue." }, + "yourAccountIsLocked": { + "message": "Your account is locked" + }, + "or": { + "message": "or" + }, + "unlockWithBiometrics": { + "message": "Unlock with biometrics" + }, + "unlockWithMasterPassword": { + "message": "Unlock with master password" + }, "unlock": { "message": "Unlock" }, @@ -2256,6 +2268,9 @@ "locked": { "message": "Locked" }, + "yourVaultIsLockedV2": { + "message": "Your vault is locked" + }, "unlocked": { "message": "Unlocked" }, @@ -2608,6 +2623,9 @@ "important": { "message": "Important:" }, + "accessing": { + "message": "Accessing" + }, "accessTokenUnableToBeDecrypted": { "message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue." }, diff --git a/apps/desktop/src/services/desktop-lock-component.service.spec.ts b/apps/desktop/src/services/desktop-lock-component.service.spec.ts new file mode 100644 index 0000000000..ff1f8328ea --- /dev/null +++ b/apps/desktop/src/services/desktop-lock-component.service.spec.ts @@ -0,0 +1,377 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/auth/angular"; +import { + PinServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { DeviceType } from "@bitwarden/common/enums"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricsService } from "@bitwarden/key-management"; + +import { DesktopLockComponentService } from "./desktop-lock-component.service"; + +// ipc mock global +const isWindowVisibleMock = jest.fn(); +const biometricEnabledMock = jest.fn(); +(global as any).ipc = { + keyManagement: { + biometric: { + enabled: biometricEnabledMock, + }, + }, + platform: { + isWindowVisible: isWindowVisibleMock, + }, +}; + +describe("DesktopLockComponentService", () => { + let service: DesktopLockComponentService; + + let userDecryptionOptionsService: MockProxy; + let platformUtilsService: MockProxy; + let biometricsService: MockProxy; + let pinService: MockProxy; + let vaultTimeoutSettingsService: MockProxy; + let cryptoService: MockProxy; + + beforeEach(() => { + userDecryptionOptionsService = mock(); + platformUtilsService = mock(); + biometricsService = mock(); + pinService = mock(); + vaultTimeoutSettingsService = mock(); + cryptoService = mock(); + + TestBed.configureTestingModule({ + providers: [ + DesktopLockComponentService, + { + provide: UserDecryptionOptionsServiceAbstraction, + useValue: userDecryptionOptionsService, + }, + { + provide: PlatformUtilsService, + useValue: platformUtilsService, + }, + { + provide: BiometricsService, + useValue: biometricsService, + }, + { + provide: PinServiceAbstraction, + useValue: pinService, + }, + { + provide: VaultTimeoutSettingsService, + useValue: vaultTimeoutSettingsService, + }, + { + provide: CryptoService, + useValue: cryptoService, + }, + ], + }); + + service = TestBed.inject(DesktopLockComponentService); + }); + + it("instantiates", () => { + expect(service).not.toBeFalsy(); + }); + + // getBiometricsError + describe("getBiometricsError", () => { + it("returns null when given null", () => { + const result = service.getBiometricsError(null); + expect(result).toBeNull(); + }); + + it("returns null when given an unknown error", () => { + const result = service.getBiometricsError({ message: "unknown" }); + expect(result).toBeNull(); + }); + }); + + describe("getPreviousUrl", () => { + it("returns null", () => { + const result = service.getPreviousUrl(); + expect(result).toBeNull(); + }); + }); + + describe("isWindowVisible", () => { + it("returns the window visibility", async () => { + isWindowVisibleMock.mockReturnValue(true); + const result = await service.isWindowVisible(); + expect(result).toBe(true); + }); + }); + + describe("getBiometricsUnlockBtnText", () => { + it("returns the correct text for Mac OS", () => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.MacOsDesktop); + const result = service.getBiometricsUnlockBtnText(); + expect(result).toBe("unlockWithTouchId"); + }); + + it("returns the correct text for Windows", () => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + const result = service.getBiometricsUnlockBtnText(); + expect(result).toBe("unlockWithWindowsHello"); + }); + + it("returns the correct text for Linux", () => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.LinuxDesktop); + const result = service.getBiometricsUnlockBtnText(); + expect(result).toBe("unlockWithPolkit"); + }); + + it("throws an error for an unsupported platform", () => { + platformUtilsService.getDevice.mockReturnValue("unsupported" as any); + expect(() => service.getBiometricsUnlockBtnText()).toThrowError("Unsupported platform"); + }); + }); + + describe("getAvailableUnlockOptions$", () => { + interface MockInputs { + hasMasterPassword: boolean; + osSupportsBiometric: boolean; + biometricLockSet: boolean; + biometricReady: boolean; + hasBiometricEncryptedUserKeyStored: boolean; + platformSupportsSecureStorage: boolean; + pinDecryptionAvailable: boolean; + } + + const table: [MockInputs, UnlockOptions][] = [ + [ + // MP + PIN + Biometrics available + { + hasMasterPassword: true, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: true, + }, + { + masterPassword: { + enabled: true, + }, + pin: { + enabled: true, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // PIN + Biometrics available + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: true, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: true, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics available: user key stored with no secure storage + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: true, + platformSupportsSecureStorage: false, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics available: no user key stored with no secure storage + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: false, + biometricReady: true, + platformSupportsSecureStorage: false, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics not available: biometric not ready + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: false, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.SystemBiometricsUnavailable, + }, + }, + ], + [ + // Biometrics not available: biometric lock not set + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: false, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.EncryptedKeysUnavailable, + }, + }, + ], + [ + // Biometrics not available: user key not stored + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: false, + biometricReady: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.EncryptedKeysUnavailable, + }, + }, + ], + [ + // Biometrics not available: OS doesn't support + { + hasMasterPassword: false, + osSupportsBiometric: false, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem, + }, + }, + ], + ]; + + test.each(table)("returns unlock options", async (mockInputs, expectedOutput) => { + const userId = "userId" as UserId; + const userDecryptionOptions = { + hasMasterPassword: mockInputs.hasMasterPassword, + }; + + // MP + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of(userDecryptionOptions), + ); + + // Biometrics + biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric); + vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet); + cryptoService.hasUserKeyStored.mockResolvedValue( + mockInputs.hasBiometricEncryptedUserKeyStored, + ); + platformUtilsService.supportsSecureStorage.mockReturnValue( + mockInputs.platformSupportsSecureStorage, + ); + biometricEnabledMock.mockResolvedValue(mockInputs.biometricReady); + + // PIN + pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable); + + const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); + + expect(unlockOptions).toEqual(expectedOutput); + }); + }); +}); diff --git a/apps/desktop/src/services/desktop-lock-component.service.ts b/apps/desktop/src/services/desktop-lock-component.service.ts new file mode 100644 index 0000000000..f31ee93a72 --- /dev/null +++ b/apps/desktop/src/services/desktop-lock-component.service.ts @@ -0,0 +1,129 @@ +import { inject } from "@angular/core"; +import { combineLatest, defer, map, Observable } from "rxjs"; + +import { + BiometricsDisableReason, + LockComponentService, + UnlockOptions, +} from "@bitwarden/auth/angular"; +import { + PinServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { DeviceType } from "@bitwarden/common/enums"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricsService } from "@bitwarden/key-management"; + +export class DesktopLockComponentService implements LockComponentService { + private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); + private readonly platformUtilsService = inject(PlatformUtilsService); + private readonly biometricsService = inject(BiometricsService); + private readonly pinService = inject(PinServiceAbstraction); + private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService); + private readonly cryptoService = inject(CryptoService); + + constructor() {} + + getBiometricsError(error: any): string | null { + return null; + } + + getPreviousUrl(): string | null { + return null; + } + + async isWindowVisible(): Promise { + return ipc.platform.isWindowVisible(); + } + + getBiometricsUnlockBtnText(): string { + switch (this.platformUtilsService.getDevice()) { + case DeviceType.MacOsDesktop: + return "unlockWithTouchId"; + case DeviceType.WindowsDesktop: + return "unlockWithWindowsHello"; + case DeviceType.LinuxDesktop: + return "unlockWithPolkit"; + default: + throw new Error("Unsupported platform"); + } + } + + private async isBiometricLockSet(userId: UserId): Promise { + const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId); + const hasBiometricEncryptedUserKeyStored = await this.cryptoService.hasUserKeyStored( + KeySuffixOptions.Biometric, + userId, + ); + const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage(); + + return ( + biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage) + ); + } + + private async isBiometricsSupportedAndReady( + userId: UserId, + ): Promise<{ supportsBiometric: boolean; biometricReady: boolean }> { + const supportsBiometric = await this.biometricsService.supportsBiometric(); + const biometricReady = await ipc.keyManagement.biometric.enabled(userId); + return { supportsBiometric, biometricReady }; + } + + getAvailableUnlockOptions$(userId: UserId): Observable { + return combineLatest([ + // Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to + defer(() => this.isBiometricsSupportedAndReady(userId)), + defer(() => this.isBiometricLockSet(userId)), + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + defer(() => this.pinService.isPinDecryptionAvailable(userId)), + ]).pipe( + map( + ([biometricsData, isBiometricsLockSet, userDecryptionOptions, pinDecryptionAvailable]) => { + const disableReason = this.getBiometricsDisabledReason( + biometricsData.supportsBiometric, + isBiometricsLockSet, + biometricsData.biometricReady, + ); + + const unlockOpts: UnlockOptions = { + masterPassword: { + enabled: userDecryptionOptions.hasMasterPassword, + }, + pin: { + enabled: pinDecryptionAvailable, + }, + biometrics: { + enabled: + biometricsData.supportsBiometric && + isBiometricsLockSet && + biometricsData.biometricReady, + disableReason: disableReason, + }, + }; + + return unlockOpts; + }, + ), + ); + } + + private getBiometricsDisabledReason( + osSupportsBiometric: boolean, + biometricLockSet: boolean, + biometricReady: boolean, + ): BiometricsDisableReason | null { + if (!osSupportsBiometric) { + return BiometricsDisableReason.NotSupportedOnOperatingSystem; + } else if (!biometricLockSet) { + return BiometricsDisableReason.EncryptedKeysUnavailable; + } else if (!biometricReady) { + return BiometricsDisableReason.SystemBiometricsUnavailable; + } + return null; + } +} diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index c85f0f3204..9e433b87f3 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -1,3 +1,4 @@ export * from "./webauthn-login"; export * from "./set-password-jit"; export * from "./registration"; +export * from "./web-lock-component.service"; diff --git a/apps/web/src/app/auth/core/services/web-lock-component.service.spec.ts b/apps/web/src/app/auth/core/services/web-lock-component.service.spec.ts new file mode 100644 index 0000000000..5eb26a8c76 --- /dev/null +++ b/apps/web/src/app/auth/core/services/web-lock-component.service.spec.ts @@ -0,0 +1,94 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { WebLockComponentService } from "./web-lock-component.service"; + +describe("WebLockComponentService", () => { + let service: WebLockComponentService; + + let userDecryptionOptionsService: MockProxy; + + beforeEach(() => { + userDecryptionOptionsService = mock(); + + TestBed.configureTestingModule({ + providers: [ + WebLockComponentService, + { + provide: UserDecryptionOptionsServiceAbstraction, + useValue: userDecryptionOptionsService, + }, + ], + }); + + service = TestBed.inject(WebLockComponentService); + }); + + it("instantiates", () => { + expect(service).not.toBeFalsy(); + }); + + describe("getBiometricsError", () => { + it("throws an error when given a null input", () => { + expect(() => service.getBiometricsError(null)).toThrow( + "Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$", + ); + }); + it("throws an error when given a non-null input", () => { + expect(() => service.getBiometricsError("error")).toThrow( + "Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$", + ); + }); + }); + + describe("getPreviousUrl", () => { + it("returns null", () => { + expect(service.getPreviousUrl()).toBeNull(); + }); + }); + + describe("isWindowVisible", () => { + it("throws an error", async () => { + await expect(service.isWindowVisible()).rejects.toThrow("Method not implemented."); + }); + }); + + describe("getBiometricsUnlockBtnText", () => { + it("throws an error", () => { + expect(() => service.getBiometricsUnlockBtnText()).toThrow( + "Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$", + ); + }); + }); + + describe("getAvailableUnlockOptions$", () => { + it("returns an observable of unlock options", async () => { + const userId = "user-id" as UserId; + const userDecryptionOptions = { + hasMasterPassword: true, + }; + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValueOnce( + of(userDecryptionOptions), + ); + + const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); + + expect(unlockOptions).toEqual({ + masterPassword: { + enabled: true, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: null, + }, + }); + }); + }); +}); diff --git a/apps/web/src/app/auth/core/services/web-lock-component.service.ts b/apps/web/src/app/auth/core/services/web-lock-component.service.ts new file mode 100644 index 0000000000..e24f299e23 --- /dev/null +++ b/apps/web/src/app/auth/core/services/web-lock-component.service.ts @@ -0,0 +1,55 @@ +import { inject } from "@angular/core"; +import { map, Observable } from "rxjs"; + +import { LockComponentService, UnlockOptions } from "@bitwarden/auth/angular"; +import { + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { UserId } from "@bitwarden/common/types/guid"; + +export class WebLockComponentService implements LockComponentService { + private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); + + constructor() {} + + getBiometricsError(error: any): string | null { + throw new Error( + "Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$", + ); + } + + getPreviousUrl(): string | null { + return null; + } + + async isWindowVisible(): Promise { + throw new Error("Method not implemented."); + } + + getBiometricsUnlockBtnText(): string { + throw new Error( + "Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$", + ); + } + + getAvailableUnlockOptions$(userId: UserId): Observable { + return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId).pipe( + map((userDecryptionOptions: UserDecryptionOptions) => { + const unlockOpts: UnlockOptions = { + masterPassword: { + enabled: userDecryptionOptions.hasMasterPassword, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: null, + }, + }; + return unlockOpts; + }), + ); + } +} diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 419794fe3b..c14c975047 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -20,6 +20,7 @@ import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services. import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; import { RegistrationFinishService as RegistrationFinishServiceAbstraction, + LockComponentService, SetPasswordJitService, } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; @@ -62,7 +63,11 @@ import { VaultTimeout, VaultTimeoutStringType } from "@bitwarden/common/types/va import { BiometricsService } from "@bitwarden/key-management"; import { PolicyListService } from "../admin-console/core/policy-list.service"; -import { WebRegistrationFinishService, WebSetPasswordJitService } from "../auth"; +import { + WebSetPasswordJitService, + WebRegistrationFinishService, + WebLockComponentService, +} from "../auth"; import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; import { HtmlStorageService } from "../core/html-storage.service"; import { I18nService } from "../core/i18n.service"; @@ -197,6 +202,11 @@ const safeProviders: SafeProvider[] = [ PolicyService, ], }), + safeProvider({ + provide: LockComponentService, + useClass: WebLockComponentService, + deps: [], + }), safeProvider({ provide: SetPasswordJitService, useClass: WebSetPasswordJitService, diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index cae73e8159..983067823c 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -10,6 +10,7 @@ import { unauthGuardFn, } from "@bitwarden/angular/auth/guards"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; +import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, @@ -20,6 +21,7 @@ import { RegistrationStartSecondaryComponentData, SetPasswordJitComponent, RegistrationLinkExpiredComponent, + LockV2Component, LockIcon, UserLockIcon, } from "@bitwarden/auth/angular"; @@ -337,21 +339,41 @@ const routes: Routes = [ pageTitle: "logIn", }, }, - { - path: "lock", - canActivate: [deepLinkGuard(), lockGuard()], - children: [ - { - path: "", - component: LockComponent, - }, - ], - data: { - pageTitle: "yourVaultIsLockedV2", - pageIcon: LockIcon, - showReadonlyHostname: true, - } satisfies AnonLayoutWrapperData, - }, + ...extensionRefreshSwap( + LockComponent, + LockV2Component, + { + path: "lock", + canActivate: [deepLinkGuard(), lockGuard()], + children: [ + { + path: "", + component: LockComponent, + }, + ], + data: { + pageTitle: "yourVaultIsLockedV2", + pageIcon: LockIcon, + showReadonlyHostname: true, + } satisfies AnonLayoutWrapperData, + }, + { + path: "lock", + canActivate: [deepLinkGuard(), lockGuard()], + children: [ + { + path: "", + component: LockV2Component, + }, + ], + data: { + pageTitle: "yourAccountIsLocked", + pageIcon: LockIcon, + showReadonlyHostname: true, + } satisfies AnonLayoutWrapperData, + }, + ), + { path: "2fa", canActivate: [unauthGuardFn()], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 8e847dfb63..ab43c3af18 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1099,8 +1099,11 @@ "yourVaultIsLockedV2": { "message": "Your vault is locked" }, - "uuid": { - "message": "UUID" + "yourAccountIsLocked": { + "message": "Your account is locked" + }, + "uuid":{ + "message" : "UUID" }, "unlock": { "message": "Unlock" @@ -3169,6 +3172,10 @@ "incorrectPin": { "message": "Incorrect PIN" }, + "pin": { + "message": "PIN", + "description": "PIN code. Ex. The short code (often numeric) that you use to unlock a device." + }, "exportedVault": { "message": "Vault exported" }, @@ -7463,6 +7470,15 @@ "or": { "message": "or" }, + "unlockWithBiometrics": { + "message": "Unlock with biometrics" + }, + "unlockWithPin": { + "message": "Unlock with PIN" + }, + "unlockWithMasterPassword": { + "message": "Unlock with master password" + }, "licenseAndBillingManagement": { "message": "License and billing management" }, diff --git a/libs/angular/src/auth/guards/auth.guard.ts b/libs/angular/src/auth/guards/auth.guard.ts index b54f114d3d..1486b9b57d 100644 --- a/libs/angular/src/auth/guards/auth.guard.ts +++ b/libs/angular/src/auth/guards/auth.guard.ts @@ -38,6 +38,8 @@ export const authGuard: CanActivateFn = async ( if (routerState != null) { messagingService.send("lockedUrl", { url: routerState.url }); } + // TODO PM-9674: when extension refresh is finished, remove promptBiometric + // as it has been integrated into the component as a default feature. return router.createUrlTree(["lock"], { queryParams: { promptBiometric: true } }); } diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index bfb3a67aed..6de473c33e 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -43,5 +43,9 @@ export * from "./registration/registration-env-selector/registration-env-selecto export * from "./registration/registration-finish/registration-finish.service"; export * from "./registration/registration-finish/default-registration-finish.service"; +// lock +export * from "./lock/lock.component"; +export * from "./lock/lock-component.service"; + // vault timeout export * from "./vault-timeout-input/vault-timeout-input.component"; diff --git a/libs/auth/src/angular/lock/lock-component.service.ts b/libs/auth/src/angular/lock/lock-component.service.ts new file mode 100644 index 0000000000..fe54db21ba --- /dev/null +++ b/libs/auth/src/angular/lock/lock-component.service.ts @@ -0,0 +1,48 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +export enum BiometricsDisableReason { + NotSupportedOnOperatingSystem = "NotSupportedOnOperatingSystem", + EncryptedKeysUnavailable = "BiometricsEncryptedKeysUnavailable", + SystemBiometricsUnavailable = "SystemBiometricsUnavailable", +} + +// ex: type UnlockOptionValue = "masterPassword" | "pin" | "biometrics" +export type UnlockOptionValue = (typeof UnlockOption)[keyof typeof UnlockOption]; + +export const UnlockOption = Object.freeze({ + MasterPassword: "masterPassword", + Pin: "pin", + Biometrics: "biometrics", +}) satisfies { [Prop in keyof UnlockOptions as Capitalize]: Prop }; + +export type UnlockOptions = { + masterPassword: { + enabled: boolean; + }; + pin: { + enabled: boolean; + }; + biometrics: { + enabled: boolean; + disableReason: BiometricsDisableReason | null; + }; +}; + +/** + * The LockComponentService is a service which allows the single libs/auth LockComponent to delegate all + * client specific functionality to client specific services implementations of LockComponentService. + */ +export abstract class LockComponentService { + // Extension + abstract getBiometricsError(error: any): string | null; + abstract getPreviousUrl(): string | null; + + // Desktop only + abstract isWindowVisible(): Promise; + abstract getBiometricsUnlockBtnText(): string; + + // Multi client + abstract getAvailableUnlockOptions$(userId: UserId): Observable; +} diff --git a/libs/auth/src/angular/lock/lock.component.html b/libs/auth/src/angular/lock/lock.component.html new file mode 100644 index 0000000000..5f5991c681 --- /dev/null +++ b/libs/auth/src/angular/lock/lock.component.html @@ -0,0 +1,191 @@ + +
+ +
+
+ + + + + + +
+

{{ "or" | i18n }}

+ + + + + + + + + + +
+
+ + + +
+ + {{ "pin" | i18n }} + + + + +
+ + +

{{ "or" | i18n }}

+ + + + + + + + + + +
+
+
+ + + +
+ + {{ "masterPass" | i18n }} + + + + + + +
+ + +

{{ "or" | i18n }}

+ + + + + + + + + + +
+
+
+
diff --git a/libs/auth/src/angular/lock/lock.component.ts b/libs/auth/src/angular/lock/lock.component.ts new file mode 100644 index 0000000000..7bea14f221 --- /dev/null +++ b/libs/auth/src/angular/lock/lock.component.ts @@ -0,0 +1,638 @@ +import { CommonModule } from "@angular/common"; +import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { Router } from "@angular/router"; +import { BehaviorSubject, firstValueFrom, Subject, switchMap, take, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { + MasterPasswordVerification, + MasterPasswordVerificationResponse, +} from "@bitwarden/common/auth/types/verification"; +import { ClientType } from "@bitwarden/common/enums"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + DialogService, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { BiometricStateService } from "@bitwarden/key-management"; + +import { PinServiceAbstraction } from "../../common/abstractions"; +import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; + +import { + UnlockOption, + LockComponentService, + UnlockOptions, + UnlockOptionValue, +} from "./lock-component.service"; + +const BroadcasterSubscriptionId = "LockComponent"; + +const clientTypeToSuccessRouteRecord: Partial> = { + [ClientType.Web]: "vault", + [ClientType.Desktop]: "vault", + [ClientType.Browser]: "/tabs/current", +}; + +@Component({ + selector: "bit-lock", + templateUrl: "lock.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + ReactiveFormsModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], +}) +export class LockV2Component implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + activeAccount: { id: UserId | undefined } & AccountInfo; + + clientType: ClientType; + ClientType = ClientType; + + unlockOptions: UnlockOptions = null; + + UnlockOption = UnlockOption; + + private _activeUnlockOptionBSubject: BehaviorSubject = + new BehaviorSubject(null); + + activeUnlockOption$ = this._activeUnlockOptionBSubject.asObservable(); + + set activeUnlockOption(value: UnlockOptionValue) { + this._activeUnlockOptionBSubject.next(value); + } + + get activeUnlockOption(): UnlockOptionValue { + return this._activeUnlockOptionBSubject.value; + } + + private invalidPinAttempts = 0; + + biometricUnlockBtnText: string; + + // masterPassword = ""; + showPassword = false; + private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions = undefined; + + forcePasswordResetRoute = "update-temp-password"; + + formGroup: FormGroup; + + // Desktop properties: + private deferFocus: boolean = null; + private biometricAsked = false; + + // Browser extension properties: + private isInitialLockScreen = (window as any).previousPopupUrl == null; + + defaultUnlockOptionSetForUser = false; + + unlockingViaBiometrics = false; + + constructor( + private accountService: AccountService, + private pinService: PinServiceAbstraction, + private userVerificationService: UserVerificationService, + private cryptoService: CryptoService, + private platformUtilsService: PlatformUtilsService, + private router: Router, + private dialogService: DialogService, + private messagingService: MessagingService, + private biometricStateService: BiometricStateService, + private ngZone: NgZone, + private i18nService: I18nService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, + private logService: LogService, + private deviceTrustService: DeviceTrustServiceAbstraction, + private syncService: SyncService, + private policyService: InternalPolicyService, + private passwordStrengthService: PasswordStrengthServiceAbstraction, + private formBuilder: FormBuilder, + private toastService: ToastService, + + private lockComponentService: LockComponentService, + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + + // desktop deps + private broadcasterService: BroadcasterService, + ) {} + + async ngOnInit() { + this.listenForActiveUnlockOptionChanges(); + + // Listen for active account changes + this.listenForActiveAccountChanges(); + + // Identify client + this.clientType = this.platformUtilsService.getClientType(); + + if (this.clientType === "desktop") { + await this.desktopOnInit(); + } + } + + // Base component methods + private listenForActiveUnlockOptionChanges() { + this.activeUnlockOption$ + .pipe(takeUntil(this.destroy$)) + .subscribe((activeUnlockOption: UnlockOptionValue) => { + if (activeUnlockOption === UnlockOption.Pin) { + this.buildPinForm(); + } else if (activeUnlockOption === UnlockOption.MasterPassword) { + this.buildMasterPasswordForm(); + } + }); + } + + private buildMasterPasswordForm() { + this.formGroup = this.formBuilder.group( + { + masterPassword: ["", [Validators.required]], + }, + { updateOn: "submit" }, + ); + } + + private buildPinForm() { + this.formGroup = this.formBuilder.group( + { + pin: ["", [Validators.required]], + }, + { updateOn: "submit" }, + ); + } + + private listenForActiveAccountChanges() { + this.accountService.activeAccount$ + .pipe( + switchMap((account) => { + return this.handleActiveAccountChange(account); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + private async handleActiveAccountChange(activeAccount: { id: UserId | undefined } & AccountInfo) { + this.activeAccount = activeAccount; + + this.resetDataOnActiveAccountChange(); + + this.setEmailAsPageSubtitle(activeAccount.email); + + this.unlockOptions = await firstValueFrom( + this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id), + ); + + this.setDefaultActiveUnlockOption(this.unlockOptions); + + if (this.unlockOptions.biometrics.enabled) { + await this.handleBiometricsUnlockEnabled(); + } + } + + private resetDataOnActiveAccountChange() { + this.defaultUnlockOptionSetForUser = false; + this.unlockOptions = null; + this.activeUnlockOption = null; + this.formGroup = null; // new form group will be created based on new active unlock option + + // Desktop properties: + this.biometricAsked = false; + } + + private setEmailAsPageSubtitle(email: string) { + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageSubtitle: { + subtitle: email, + translate: false, + }, + }); + } + + private setDefaultActiveUnlockOption(unlockOptions: UnlockOptions) { + // Priorities should be Biometrics > Pin > Master Password for speed + if (unlockOptions.biometrics.enabled) { + this.activeUnlockOption = UnlockOption.Biometrics; + } else if (unlockOptions.pin.enabled) { + this.activeUnlockOption = UnlockOption.Pin; + } else if (unlockOptions.masterPassword.enabled) { + this.activeUnlockOption = UnlockOption.MasterPassword; + } + } + + private async handleBiometricsUnlockEnabled() { + this.biometricUnlockBtnText = this.lockComponentService.getBiometricsUnlockBtnText(); + + const autoPromptBiometrics = await firstValueFrom( + this.biometricStateService.promptAutomatically$, + ); + + // TODO: PM-12546 - we need to make our biometric autoprompt experience consistent between the + // desktop and extension. + if (this.clientType === "desktop") { + if (autoPromptBiometrics) { + await this.desktopAutoPromptBiometrics(); + } + } + + if (this.clientType === "browser") { + if ( + this.unlockOptions.biometrics.enabled && + autoPromptBiometrics && + this.isInitialLockScreen // only autoprompt biometrics on initial lock screen + ) { + await this.unlockViaBiometrics(); + } + } + } + + // Note: this submit method is only used for unlock methods that require a form and user input. + // For biometrics unlock, the method is called directly. + submit = async (): Promise => { + if (this.activeUnlockOption === UnlockOption.Pin) { + return await this.unlockViaPin(); + } + + await this.unlockViaMasterPassword(); + }; + + async logOut() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "logOut" }, + content: { key: "logOutConfirmation" }, + acceptButtonText: { key: "logOut" }, + type: "warning", + }); + + if (confirmed) { + this.messagingService.send("logout", { userId: this.activeAccount.id }); + } + } + + async unlockViaBiometrics(): Promise { + this.unlockingViaBiometrics = true; + + if (!this.unlockOptions.biometrics.enabled) { + this.unlockingViaBiometrics = false; + return; + } + + try { + await this.biometricStateService.setUserPromptCancelled(); + const userKey = await this.cryptoService.getUserKeyFromStorage( + KeySuffixOptions.Biometric, + this.activeAccount.id, + ); + + // If user cancels biometric prompt, userKey is undefined. + if (userKey) { + await this.setUserKeyAndContinue(userKey, false); + } + + this.unlockingViaBiometrics = false; + } catch (e) { + // Cancelling is a valid action. + if (e?.message === "canceled") { + this.unlockingViaBiometrics = false; + return; + } + + let biometricTranslatedErrorDesc; + + if (this.clientType === "browser") { + const biometricErrorDescTranslationKey = this.lockComponentService.getBiometricsError(e); + + if (biometricErrorDescTranslationKey) { + biometricTranslatedErrorDesc = this.i18nService.t(biometricErrorDescTranslationKey); + } + } + + // if no translation key found, show generic error message + if (!biometricTranslatedErrorDesc) { + biometricTranslatedErrorDesc = this.i18nService.t("unexpectedError"); + } + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: biometricTranslatedErrorDesc, + acceptButtonText: { key: "tryAgain" }, + type: "danger", + }); + + if (confirmed) { + // try again + await this.unlockViaBiometrics(); + } + + this.unlockingViaBiometrics = false; + } + } + + togglePassword() { + this.showPassword = !this.showPassword; + const input = document.getElementById( + this.unlockOptions.pin.enabled ? "pin" : "masterPassword", + ); + if (this.ngZone.isStable) { + input.focus(); + } else { + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + this.ngZone.onStable.pipe(take(1)).subscribe(() => input.focus()); + } + } + + private validatePin(): boolean { + if (this.formGroup.invalid) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("pinRequired"), + }); + return false; + } + + return true; + } + + private async unlockViaPin() { + if (!this.validatePin()) { + return; + } + + const pin = this.formGroup.controls.pin.value; + + const MAX_INVALID_PIN_ENTRY_ATTEMPTS = 5; + + try { + const userKey = await this.pinService.decryptUserKeyWithPin(pin, this.activeAccount.id); + + if (userKey) { + await this.setUserKeyAndContinue(userKey); + return; // successfully unlocked + } + + // Failure state: invalid PIN or failed decryption + this.invalidPinAttempts++; + + // Log user out if they have entered an invalid PIN too many times + if (this.invalidPinAttempts >= MAX_INVALID_PIN_ENTRY_ATTEMPTS) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("tooManyInvalidPinEntryAttemptsLoggingOut"), + }); + this.messagingService.send("logout"); + return; + } + + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidPin"), + }); + } catch { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("unexpectedError"), + }); + } + } + + private validateMasterPassword(): boolean { + if (this.formGroup.invalid) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("masterPasswordRequired"), + }); + return false; + } + + return true; + } + + private async unlockViaMasterPassword() { + if (!this.validateMasterPassword()) { + return; + } + + const masterPassword = this.formGroup.controls.masterPassword.value; + + const verification = { + type: VerificationType.MasterPassword, + secret: masterPassword, + } as MasterPasswordVerification; + + let passwordValid = false; + let masterPasswordVerificationResponse: MasterPasswordVerificationResponse; + try { + masterPasswordVerificationResponse = + await this.userVerificationService.verifyUserByMasterPassword( + verification, + this.activeAccount.id, + this.activeAccount.email, + ); + + this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse( + masterPasswordVerificationResponse.policyOptions, + ); + passwordValid = true; + } catch (e) { + this.logService.error(e); + } + + if (!passwordValid) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidMasterPassword"), + }); + return; + } + + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( + masterPasswordVerificationResponse.masterKey, + ); + await this.setUserKeyAndContinue(userKey, true); + } + + private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) { + await this.cryptoService.setUserKey(key, this.activeAccount.id); + + // Now that we have a decrypted user key in memory, we can check if we + // need to establish trust on the current device + await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id); + + await this.doContinue(evaluatePasswordAfterUnlock); + } + + private async doContinue(evaluatePasswordAfterUnlock: boolean) { + await this.biometricStateService.resetUserPromptCancelled(); + this.messagingService.send("unlocked"); + + if (evaluatePasswordAfterUnlock) { + try { + // If we do not have any saved policies, attempt to load them from the service + if (this.enforcedMasterPasswordOptions == undefined) { + this.enforcedMasterPasswordOptions = await firstValueFrom( + this.policyService.masterPasswordPolicyOptions$(), + ); + } + + if (this.requirePasswordChange()) { + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); + await this.router.navigate([this.forcePasswordResetRoute]); + return; + } + } catch (e) { + // Do not prevent unlock if there is an error evaluating policies + this.logService.error(e); + } + } + + // Vault can be de-synced since notifications get ignored while locked. Need to check whether sync is required using the sync service. + await this.syncService.fullSync(false); + + if (this.clientType === "browser") { + const previousUrl = this.lockComponentService.getPreviousUrl(); + if (previousUrl) { + await this.router.navigateByUrl(previousUrl); + } + } + + // determine success route based on client type + const successRoute = clientTypeToSuccessRouteRecord[this.clientType]; + await this.router.navigate([successRoute]); + } + + /** + * Checks if the master password meets the enforced policy requirements + * If not, returns false + */ + private requirePasswordChange(): boolean { + if ( + this.enforcedMasterPasswordOptions == undefined || + !this.enforcedMasterPasswordOptions.enforceOnLogin + ) { + return false; + } + + const masterPassword = this.formGroup.controls.masterPassword.value; + + const passwordStrength = this.passwordStrengthService.getPasswordStrength( + masterPassword, + this.activeAccount.email, + )?.score; + + return !this.policyService.evaluateMasterPassword( + passwordStrength, + masterPassword, + this.enforcedMasterPasswordOptions, + ); + } + + // ----------------------------------------------------------------------------------------------- + // Desktop methods: + // ----------------------------------------------------------------------------------------------- + + async desktopOnInit() { + // TODO: move this into a WindowService and subscribe to messages via MessageListener service. + this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { + this.ngZone.run(() => { + switch (message.command) { + case "windowHidden": + this.onWindowHidden(); + break; + case "windowIsFocused": + if (this.deferFocus === null) { + this.deferFocus = !message.windowIsFocused; + if (!this.deferFocus) { + this.focusInput(); + } + } else if (this.deferFocus && message.windowIsFocused) { + this.focusInput(); + this.deferFocus = false; + } + break; + default: + } + }); + }); + this.messagingService.send("getWindowIsFocused"); + } + + private async desktopAutoPromptBiometrics() { + if (!this.unlockOptions?.biometrics?.enabled || this.biometricAsked) { + return; + } + + // prevent the biometric prompt from showing if the user has already cancelled it + if (await firstValueFrom(this.biometricStateService.promptCancelled$)) { + return; + } + + const windowVisible = await this.lockComponentService.isWindowVisible(); + + if (windowVisible) { + this.biometricAsked = true; + await this.unlockViaBiometrics(); + } + } + + onWindowHidden() { + this.showPassword = false; + } + + private focusInput() { + if (this.unlockOptions) { + document.getElementById(this.unlockOptions.pin.enabled ? "pin" : "masterPassword")?.focus(); + } + } + + // ----------------------------------------------------------------------------------------------- + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + + if (this.clientType === "desktop") { + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + } + } +}