diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 62992ed20b9..6c10ea2b8a1 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2006,6 +2006,10 @@ "message": "Your organization requires you to set a master password.", "description": "Used as a card title description on the set password page to explain why the user is there" }, + "verificationRequired" : { + "message": "Verification required", + "description": "Default title for the user verification dialog." + }, "hours": { "message": "Hours" }, @@ -2643,6 +2647,42 @@ } } }, + "tryAgain": { + "message": "Try again" + }, + "verificationRequiredForActionSetPinToContinue": { + "message": "Verification required for this action. Set a PIN to continue." + }, + "setPin": { + "message": "Set PIN" + }, + "verifyWithBiometrics": { + "message": "Verify with biometrics" + }, + "awaitingConfirmation": { + "message": "Awaiting confirmation" + }, + "couldNotCompleteBiometrics": { + "message": "Could not complete biometrics." + }, + "needADifferentMethod": { + "message": "Need a different method?" + }, + "useMasterPassword": { + "message": "Use master password" + }, + "usePin": { + "message": "Use PIN" + }, + "useBiometrics": { + "message": "Use biometrics" + }, + "enterVerificationCodeSentToEmail": { + "message": "Enter the verification code that was sent to your email." + }, + "resendCode": { + "message": "Resend code" + }, "total": { "message": "Total" }, @@ -2786,6 +2826,15 @@ "incorrectUsernameOrPassword": { "message": "Incorrect username or password" }, + "incorrectPassword": { + "message": "Incorrect password" + }, + "incorrectCode": { + "message": "Incorrect code" + }, + "incorrectPin": { + "message": "Incorrect PIN" + }, "multifactorAuthenticationFailed": { "message": "Multifactor authentication failed" }, diff --git a/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts b/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts index 9fa0f4069c7..ff08ddf689f 100644 --- a/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts @@ -1,6 +1,10 @@ import { UserVerificationService as AbstractUserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; +import { + VaultTimeoutSettingsServiceInitOptions, + vaultTimeoutSettingsServiceFactory, +} from "../../../background/service-factories/vault-timeout-settings-service.factory"; import { CryptoServiceInitOptions, cryptoServiceFactory, @@ -18,6 +22,10 @@ import { LogServiceInitOptions, logServiceFactory, } from "../../../platform/background/service-factories/log-service.factory"; +import { + platformUtilsServiceFactory, + PlatformUtilsServiceInitOptions, +} from "../../../platform/background/service-factories/platform-utils-service.factory"; import { StateServiceInitOptions, stateServiceFactory, @@ -37,7 +45,9 @@ export type UserVerificationServiceInitOptions = UserVerificationServiceFactoryO I18nServiceInitOptions & UserVerificationApiServiceInitOptions & PinCryptoServiceInitOptions & - LogServiceInitOptions; + LogServiceInitOptions & + VaultTimeoutSettingsServiceInitOptions & + PlatformUtilsServiceInitOptions; export function userVerificationServiceFactory( cache: { userVerificationService?: AbstractUserVerificationService } & CachedServices, @@ -55,6 +65,8 @@ export function userVerificationServiceFactory( await userVerificationApiServiceFactory(cache, opts), await pinCryptoServiceFactory(cache, opts), await logServiceFactory(cache, opts), + await vaultTimeoutSettingsServiceFactory(cache, opts), + await platformUtilsServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index ebac08a0ab0..d4d9b2f6e15 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -489,22 +489,6 @@ export default class MainBackground { this.userVerificationApiService = new UserVerificationApiService(this.apiService); - this.pinCryptoService = new PinCryptoService( - this.stateService, - this.cryptoService, - this.vaultTimeoutSettingsService, - this.logService, - ); - - this.userVerificationService = new UserVerificationService( - this.stateService, - this.cryptoService, - this.i18nService, - this.userVerificationApiService, - this.pinCryptoService, - this.logService, - ); - this.configApiService = new ConfigApiService(this.apiService, this.authService); this.configService = new BrowserConfigService( @@ -542,6 +526,24 @@ export default class MainBackground { this.stateService, ); + this.pinCryptoService = new PinCryptoService( + this.stateService, + this.cryptoService, + this.vaultTimeoutSettingsService, + this.logService, + ); + + this.userVerificationService = new UserVerificationService( + this.stateService, + this.cryptoService, + this.i18nService, + this.userVerificationApiService, + this.pinCryptoService, + this.logService, + this.vaultTimeoutSettingsService, + this.platformUtilsService, + ); + this.vaultFilterService = new VaultFilterService( this.stateService, this.organizationService, diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index d3f4c63356d..614691a92b0 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -306,8 +306,11 @@ export class NativeMessagingBackground { type: "danger", }); break; + } else if (message.response === "canceled") { + break; } + // Check for initial setup of biometric unlock const enabled = await this.stateService.getBiometricUnlock(); if (enabled === null || enabled === false) { if (message.response === "unlocked") { diff --git a/apps/browser/src/platform/services/browser-crypto.service.ts b/apps/browser/src/platform/services/browser-crypto.service.ts index 88311a5cbba..36ee4c6717d 100644 --- a/apps/browser/src/platform/services/browser-crypto.service.ts +++ b/apps/browser/src/platform/services/browser-crypto.service.ts @@ -16,13 +16,19 @@ export class BrowserCryptoService extends CryptoService { /** * Browser doesn't store biometric keys, so we retrieve them from the desktop and return * if we successfully saved it into memory as the User Key + * @returns the `UserKey` if the user passes a biometrics prompt, otherwise return `null`. */ protected override async getKeyFromStorage( keySuffix: KeySuffixOptions, userId?: UserId, ): Promise { if (keySuffix === KeySuffixOptions.Biometric) { - await this.platformUtilService.authenticateBiometric(); + const biometricsResult = await this.platformUtilService.authenticateBiometric(); + + if (!biometricsResult) { + return null; + } + const userKey = await this.stateService.getUserKey({ userId: userId }); if (userKey) { return new SymmetricCryptoKey(Utils.fromB64ToArray(userKey.keyB64)) as UserKey; diff --git a/apps/browser/src/popup/components/user-verification.component.ts b/apps/browser/src/popup/components/user-verification.component.ts index 87b514c416e..6befc8973b0 100644 --- a/apps/browser/src/popup/components/user-verification.component.ts +++ b/apps/browser/src/popup/components/user-verification.component.ts @@ -3,7 +3,10 @@ import { Component } from "@angular/core"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/auth/components/user-verification.component"; - +/** + * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead. + * Each client specific component should eventually be converted over to use one of these new components. + */ @Component({ selector: "app-user-verification", templateUrl: "user-verification.component.html", diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 850fec07879..cd62c10c001 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -437,6 +437,13 @@ export class Main { const lockedCallback = async (userId?: string) => await this.cryptoService.clearStoredUserKey(KeySuffixOptions.Auto); + this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService( + this.cryptoService, + this.tokenService, + this.policyService, + this.stateService, + ); + this.pinCryptoService = new PinCryptoService( this.stateService, this.cryptoService, @@ -451,13 +458,8 @@ export class Main { this.userVerificationApiService, this.pinCryptoService, this.logService, - ); - - this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService( - this.cryptoService, - this.tokenService, - this.policyService, - this.stateService, + this.vaultTimeoutSettingsService, + this.platformUtilsService, ); this.vaultTimeoutService = new VaultTimeoutService( diff --git a/apps/desktop/src/app/components/user-verification.component.ts b/apps/desktop/src/app/components/user-verification.component.ts index 0a0e075e098..2a005f636f3 100644 --- a/apps/desktop/src/app/components/user-verification.component.ts +++ b/apps/desktop/src/app/components/user-verification.component.ts @@ -7,6 +7,10 @@ import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/a import { JslibModule } from "@bitwarden/angular/jslib.module"; import { FormFieldModule } from "@bitwarden/components"; +/** + * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead. + * Each client specific component should eventually be converted over to use one of these new components. + */ @Component({ selector: "app-user-verification", standalone: true, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 47b31a57a47..2319bc3e41d 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1547,6 +1547,10 @@ "message": "Your organization requires you to set a master password.", "description": "Used as a card title description on the set password page to explain why the user is there" }, + "verificationRequired" : { + "message": "Verification required", + "description": "Default title for the user verification dialog." + }, "currentMasterPass": { "message": "Current master password" }, @@ -1872,6 +1876,42 @@ "updateWeakMasterPasswordWarning": { "message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." }, + "tryAgain": { + "message": "Try again" + }, + "verificationRequiredForActionSetPinToContinue": { + "message": "Verification required for this action. Set a PIN to continue." + }, + "setPin": { + "message": "Set PIN" + }, + "verifyWithBiometrics": { + "message": "Verify with biometrics" + }, + "awaitingConfirmation": { + "message": "Awaiting confirmation" + }, + "couldNotCompleteBiometrics": { + "message": "Could not complete biometrics." + }, + "needADifferentMethod": { + "message": "Need a different method?" + }, + "useMasterPassword": { + "message": "Use master password" + }, + "usePin": { + "message": "Use PIN" + }, + "useBiometrics": { + "message": "Use biometrics" + }, + "enterVerificationCodeSentToEmail": { + "message": "Enter the verification code that was sent to your email." + }, + "resendCode": { + "message": "Resend code" + }, "hours": { "message": "Hours" }, @@ -2565,6 +2605,15 @@ "incorrectUsernameOrPassword": { "message": "Incorrect username or password" }, + "incorrectPassword": { + "message": "Incorrect password" + }, + "incorrectCode": { + "message": "Incorrect code" + }, + "incorrectPin": { + "message": "Incorrect PIN" + }, "multifactorAuthenticationFailed": { "message": "Multifactor authentication failed" }, diff --git a/apps/desktop/src/services/native-messaging.service.ts b/apps/desktop/src/services/native-messaging.service.ts index 2f99e9908d9..886d87e5556 100644 --- a/apps/desktop/src/services/native-messaging.service.ts +++ b/apps/desktop/src/services/native-messaging.service.ts @@ -146,26 +146,30 @@ export class NativeMessagingService { ); } - const userKey = await this.cryptoService.getUserKeyFromStorage( - KeySuffixOptions.Biometric, - message.userId, - ); - const masterKey = await this.cryptoService.getMasterKey(message.userId); - - if (userKey != null) { - // we send the master key still for backwards compatibility - // with older browser extensions - // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3472) - this.send( - { - command: "biometricUnlock", - response: "unlocked", - keyB64: masterKey?.keyB64, - userKeyB64: userKey.keyB64, - }, - appId, + try { + const userKey = await this.cryptoService.getUserKeyFromStorage( + KeySuffixOptions.Biometric, + message.userId, ); - } else { + const masterKey = await this.cryptoService.getMasterKey(message.userId); + + if (userKey != null) { + // we send the master key still for backwards compatibility + // with older browser extensions + // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3472) + this.send( + { + command: "biometricUnlock", + response: "unlocked", + keyB64: masterKey?.keyB64, + userKeyB64: userKey.keyB64, + }, + appId, + ); + } else { + this.send({ command: "biometricUnlock", response: "canceled" }, appId); + } + } catch (e) { this.send({ command: "biometricUnlock", response: "canceled" }, appId); } diff --git a/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts b/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts index 89b751b5186..cd4ac2db356 100644 --- a/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts +++ b/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts @@ -11,6 +11,9 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; +/** + * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent instead. + */ @Component({ templateUrl: "user-verification-prompt.component.html", }) diff --git a/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts b/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts index 87b514c416e..94d319524f7 100644 --- a/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts +++ b/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts @@ -4,6 +4,10 @@ import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/auth/components/user-verification.component"; +/** + * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead. + * Each client specific component should eventually be converted over to use one of these new components. + */ @Component({ selector: "app-user-verification", templateUrl: "user-verification.component.html", diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 46e340c1f10..2fb5f002f79 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -2816,6 +2816,9 @@ "incorrectCode": { "message": "Incorrect code" }, + "incorrectPin": { + "message": "Incorrect PIN" + }, "exportedVault": { "message": "Vault exported" }, @@ -6604,6 +6607,39 @@ } } }, + "verificationRequiredForActionSetPinToContinue": { + "message": "Verification required for this action. Set a PIN to continue." + }, + "setPin": { + "message": "Set PIN" + }, + "verifyWithBiometrics": { + "message": "Verify with biometrics" + }, + "awaitingConfirmation": { + "message": "Awaiting confirmation" + }, + "couldNotCompleteBiometrics": { + "message": "Could not complete biometrics." + }, + "needADifferentMethod": { + "message": "Need a different method?" + }, + "useMasterPassword": { + "message": "Use master password" + }, + "usePin": { + "message": "Use PIN" + }, + "useBiometrics": { + "message": "Use biometrics" + }, + "enterVerificationCodeSentToEmail": { + "message": "Enter the verification code that was sent to your email." + }, + "resendCode": { + "message": "Resend code" + }, "membersColumnHeader": { "message": "Member/Group" }, @@ -7086,6 +7122,10 @@ } } }, + "verificationRequired" : { + "message": "Verification required", + "description": "Default title for the user verification dialog." + }, "recoverAccount": { "message": "Recover account" }, diff --git a/libs/angular/src/auth/components/user-verification-prompt.component.ts b/libs/angular/src/auth/components/user-verification-prompt.component.ts index 4c0a49ddfc3..d999042722d 100644 --- a/libs/angular/src/auth/components/user-verification-prompt.component.ts +++ b/libs/angular/src/auth/components/user-verification-prompt.component.ts @@ -16,6 +16,7 @@ export interface UserVerificationPromptParams { /** * Used to verify the user's identity (using their master password or email-based OTP for Key Connector users). You can customize all of the text in the modal. + * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent instead. */ @Directive() export class UserVerificationPromptComponent { diff --git a/libs/angular/src/auth/components/user-verification.component.ts b/libs/angular/src/auth/components/user-verification.component.ts index ce0ee41f630..7fe866a9f6e 100644 --- a/libs/angular/src/auth/components/user-verification.component.ts +++ b/libs/angular/src/auth/components/user-verification.component.ts @@ -14,6 +14,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; * Collects the user's master password, or if they are not using a password, prompts for an OTP via email. * This is exposed to the parent component via the ControlValueAccessor interface (e.g. bind it to a FormControl). * Use UserVerificationService to verify the user's input. + * + * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead. + * Each client specific component should eventually be converted over to use one of these new components. */ @Directive({ selector: "app-user-verification", diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index e1e8e937491..57e606964bf 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -633,6 +633,8 @@ import { ModalService } from "./modal.service"; UserVerificationApiServiceAbstraction, PinCryptoServiceAbstraction, LogService, + VaultTimeoutSettingsServiceAbstraction, + PlatformUtilsServiceAbstraction, ], }, { diff --git a/libs/auth/src/angular/icons/index.ts b/libs/auth/src/angular/icons/index.ts new file mode 100644 index 00000000000..7bb3f575796 --- /dev/null +++ b/libs/auth/src/angular/icons/index.ts @@ -0,0 +1 @@ +export * from "./user-verification-biometrics-fingerprint.icon"; diff --git a/libs/auth/src/angular/icons/user-verification-biometrics-fingerprint.icon.ts b/libs/auth/src/angular/icons/user-verification-biometrics-fingerprint.icon.ts new file mode 100644 index 00000000000..1fb994fda1f --- /dev/null +++ b/libs/auth/src/angular/icons/user-verification-biometrics-fingerprint.icon.ts @@ -0,0 +1,12 @@ +import { svgIcon } from "@bitwarden/components"; + +export const UserVerificationBiometricsIcon = svgIcon` + + + + + + + + +`; diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 7fd35ea8b72..c93bf1c1d3e 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -1,5 +1,14 @@ /** * This barrel file should only contain Angular exports */ + +// icons +export * from "./icons"; + export * from "./fingerprint-dialog/fingerprint-dialog.component"; export * from "./password-callout/password-callout.component"; + +// user verification +export * from "./user-verification/user-verification-dialog.component"; +export * from "./user-verification/user-verification-dialog.types"; +export * from "./user-verification/user-verification-form-input.component"; diff --git a/libs/auth/src/angular/user-verification/active-client-verification-option.enum.ts b/libs/auth/src/angular/user-verification/active-client-verification-option.enum.ts new file mode 100644 index 00000000000..bceccc7f965 --- /dev/null +++ b/libs/auth/src/angular/user-verification/active-client-verification-option.enum.ts @@ -0,0 +1,6 @@ +export enum ActiveClientVerificationOption { + MasterPassword = "masterPassword", + Pin = "pin", + Biometrics = "biometrics", + None = "none", +} diff --git a/libs/auth/src/angular/user-verification/user-verification-dialog.component.html b/libs/auth/src/angular/user-verification/user-verification-dialog.component.html new file mode 100644 index 00000000000..66e006d9453 --- /dev/null +++ b/libs/auth/src/angular/user-verification/user-verification-dialog.component.html @@ -0,0 +1,103 @@ +
+ + + {{ + dialogOptions.title ? (dialogOptions.title | i18n) : ("verificationRequired" | i18n) + }} + + + +

+ {{ dialogOptions.bodyText | i18n }} +

+ + + {{ dialogOptions.calloutOptions.text | i18n }} + +
+ + + +

+ {{ "verificationRequiredForActionSetPinToContinue" | i18n }} +

+
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+
diff --git a/libs/auth/src/angular/user-verification/user-verification-dialog.component.ts b/libs/auth/src/angular/user-verification/user-verification-dialog.component.ts new file mode 100644 index 00000000000..d3a89473525 --- /dev/null +++ b/libs/auth/src/angular/user-verification/user-verification-dialog.component.ts @@ -0,0 +1,247 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + DialogService, +} from "@bitwarden/components"; + +import { ActiveClientVerificationOption } from "./active-client-verification-option.enum"; +import { + UserVerificationDialogOptions, + UserVerificationDialogResult, +} from "./user-verification-dialog.types"; +import { UserVerificationFormInputComponent } from "./user-verification-form-input.component"; + +@Component({ + templateUrl: "user-verification-dialog.component.html", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + JslibModule, + ButtonModule, + DialogModule, + AsyncActionsModule, + UserVerificationFormInputComponent, + ], +}) +export class UserVerificationDialogComponent { + verificationForm = this.formBuilder.group({ + secret: this.formBuilder.control(null), + }); + + get secret() { + return this.verificationForm.controls.secret; + } + + invalidSecret = false; + activeClientVerificationOption: ActiveClientVerificationOption; + readonly ActiveClientVerificationOption = ActiveClientVerificationOption; + + constructor( + @Inject(DIALOG_DATA) public dialogOptions: UserVerificationDialogOptions, + private dialogRef: DialogRef, + private formBuilder: FormBuilder, + private userVerificationService: UserVerificationService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + ) {} + + /** + * Opens the user verification dialog. + * + * @param {DialogService} dialogService - The service used to open the dialog. + * @param {UserVerificationDialogOptions} data - Parameters for configuring the dialog. + * @returns {Promise} A promise that resolves to the result of the user verification process. + * + * @example + * // Example 1: Default, simple scenario + * const result = await UserVerificationDialogComponent.open( + * this.dialogService, + * {} + * ); + * + * // Handle the result of the dialog based on user action and verification success + * if (result.userAction === 'cancel') { + * // User cancelled the dialog + * return; + * } + * + * // User confirmed the dialog so check verification success + * if (!result.verificationSuccess) { + * // verification failed + * return; + * } + * + * ---------------------------------------------------------- + * + * @example + * // Example 2: Custom scenario + * const result = await UserVerificationDialogComponent.open( + * this.dialogService, + * { + * title: 'customTitle', + * bodyText: 'customBodyText', + * calloutOptions: { + * text: 'customCalloutText', + * type: 'warning', + * }, + * confirmButtonOptions: { + * text: 'customConfirmButtonText', + * type: 'danger', + * } + * } + * ); + * + * // Handle the result of the dialog based on user action and verification success + * if (result.userAction === 'cancel') { + * // User cancelled the dialog + * return; + * } + * + * // User confirmed the dialog so check verification success + * if (!result.verificationSuccess) { + * // verification failed + * return; + * } + * + * ---------------------------------------------------------- + * + * @example + * // Example 3: Client side verification scenario only + * const result = await UserVerificationDialogComponent.open( + * this.dialogService, + * { clientSideOnlyVerification: true } + * ); + * + * // Handle the result of the dialog based on user action and verification success + * if (result.userAction === 'cancel') { + * // User cancelled the dialog + * return; + * } + * + * // User confirmed the dialog so check verification success + * if (!result.verificationSuccess) { + * if (result.noAvailableClientVerificationMethods) { + * // No client-side verification methods are available + * // Could send user to configure a verification method like PIN or biometrics + * } + * return; + * } + * + */ + static async open( + dialogService: DialogService, + data: UserVerificationDialogOptions, + ): Promise { + const dialogRef = dialogService.open( + UserVerificationDialogComponent, + { + data, + }, + ); + + const dialogResult = await firstValueFrom(dialogRef.closed); + + // An empty string is returned when the user hits the x to close the dialog. + // Undefined is returned when the users hits the escape key to close the dialog. + if (typeof dialogResult === "string" || dialogResult === undefined) { + // User used x to close dialog + return { + userAction: "cancel", + verificationSuccess: false, + }; + } else { + return dialogResult; + } + } + + handleActiveClientVerificationOptionChange( + activeClientVerificationOption: ActiveClientVerificationOption, + ) { + this.activeClientVerificationOption = activeClientVerificationOption; + } + + handleBiometricsVerificationResultChange(biometricsVerificationResult: boolean) { + if (biometricsVerificationResult) { + this.close({ + userAction: "confirm", + verificationSuccess: true, + noAvailableClientVerificationMethods: false, + }); + } + } + + submit = async () => { + if (this.activeClientVerificationOption === ActiveClientVerificationOption.None) { + this.close({ + userAction: "confirm", + verificationSuccess: false, + noAvailableClientVerificationMethods: true, + }); + return; + } + + this.verificationForm.markAllAsTouched(); + + if (this.verificationForm.invalid) { + return; + } + + try { + // TODO: once we migrate all user verification scenarios to use this new implementation, + // we should consider refactoring the user verification service handling of the + // OTP and MP flows to not throw errors on verification failure. + const verificationResult = await this.userVerificationService.verifyUser(this.secret.value); + + if (verificationResult) { + this.invalidSecret = false; + this.close({ + userAction: "confirm", + verificationSuccess: true, + noAvailableClientVerificationMethods: false, + }); + } else { + this.invalidSecret = true; + + // Only pin should ever get here, but added this check to be safe. + if (this.activeClientVerificationOption === ActiveClientVerificationOption.Pin) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("error"), + this.i18nService.t("invalidPin"), + ); + } else { + this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError")); + } + } + } catch (e) { + // Catch handles OTP and MP verification scenarios as those throw errors on verification failure instead of returning false like PIN and biometrics. + this.invalidSecret = true; + this.platformUtilsService.showToast("error", this.i18nService.t("error"), e.message); + return; + } + }; + + cancel() { + this.close({ + userAction: "cancel", + verificationSuccess: false, + }); + } + + close(dialogResult: UserVerificationDialogResult) { + this.dialogRef.close(dialogResult); + } +} diff --git a/libs/auth/src/angular/user-verification/user-verification-dialog.types.ts b/libs/auth/src/angular/user-verification/user-verification-dialog.types.ts new file mode 100644 index 00000000000..2480162f867 --- /dev/null +++ b/libs/auth/src/angular/user-verification/user-verification-dialog.types.ts @@ -0,0 +1,90 @@ +import { ButtonType } from "@bitwarden/components"; + +/** + * @typedef {Object} UserVerificationCalloutOptions - Configuration options for the callout displayed in the dialog body. + */ +export type UserVerificationCalloutOptions = { + /** + * The translation key for the text of the callout. + */ + text: string; + + /** + * The type of the callout. + * Can be "warning", "danger", "error", or "tip". + */ + type: "warning" | "danger" | "error" | "tip"; +}; + +/** + * @typedef {Object} UserVerificationConfirmButtonOptions - Configuration options for the confirm button in the User Verification Dialog. + */ +export type UserVerificationConfirmButtonOptions = { + /** + * The translation key for the text of the confirm button. + */ + text: string; + + /** + * The type of the confirm button. + * It should be a valid ButtonType. + */ + type: ButtonType; +}; + +/** + * @typedef {Object} UserVerificationDialogOptions - Configuration parameters for the user verification dialog. + */ +export type UserVerificationDialogOptions = { + /** + * The translation key for the title of the dialog. + * This is optional and defaults to "Verification required" if not provided. + */ + title?: string; + + /** + * The translation key for the body text of the dialog. + * Optional. + */ + bodyText?: string; + + /** + * Options for a callout to be displayed in the dialog body below the body text. + * Optional. + */ + calloutOptions?: UserVerificationCalloutOptions; + + /** + * Options for the confirm button. + * Optional. The default text is "Submit" and the default type is "primary". + */ + confirmButtonOptions?: UserVerificationConfirmButtonOptions; + + /** + * Indicates whether the verification is only performed client-side. Includes local MP verification, PIN, and Biometrics. + * Optional. + * **Important:** Only for use on desktop and browser platforms as when there are no client verification methods, the user is instructed to set a pin (which is not supported on web) + */ + clientSideOnlyVerification?: boolean; +}; + +/** + * @typedef {Object} UserVerificationDialogResult - The result of the user verification dialog. + */ +export type UserVerificationDialogResult = { + /** + * The user's action. + */ + userAction: "confirm" | "cancel"; + + /** + * Indicates whether the verification was successful. + */ + verificationSuccess: boolean; + + /** + * Indicates whether there are no available client verification methods. + * Optional and only relevant when the dialog is configured to only perform client-side verification. + */ + noAvailableClientVerificationMethods?: boolean; +}; diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html new file mode 100644 index 00000000000..afc99b32cb7 --- /dev/null +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html @@ -0,0 +1,165 @@ + +
+ + + + + + + + + {{ "pin" | i18n }} + + + {{ "confirmIdentity" | i18n }} + + + + + +
+ +

{{ "verifyWithBiometrics" | i18n }}

+
+ + {{ "awaitingConfirmation" | i18n }} +
+
+ + + {{ "couldNotCompleteBiometrics" | i18n }} + + +
+ + +
+

{{ "needADifferentMethod" | i18n }}

+ + + + + + +
+
+
+ + + + + + +
+ +
+ +
+ {{ "enterVerificationCodeSentToEmail" | i18n }} + +

+ + + + + {{ "codeSent" | i18n }} + +

+
+ + + {{ "verificationCode" | i18n }} + + {{ "confirmIdentity" | i18n }} + +
+
+ + + + {{ "masterPass" | i18n }} + + + {{ "confirmIdentity" | i18n }} + + diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts new file mode 100644 index 00000000000..cb4f813f8df --- /dev/null +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts @@ -0,0 +1,340 @@ +import { animate, style, transition, trigger } from "@angular/animations"; +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { + ControlValueAccessor, + FormControl, + Validators, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, +} from "@angular/forms"; +import { BehaviorSubject, Subject, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; +import { UserVerificationOptions } from "@bitwarden/common/auth/types/user-verification-options"; +import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + IconModule, + LinkModule, +} from "@bitwarden/components"; + +import { UserVerificationBiometricsIcon } from "../icons"; + +import { ActiveClientVerificationOption } from "./active-client-verification-option.enum"; + +/** + * Used for general-purpose user verification throughout the app. + * Collects the user's master password, or if they are not using a password, prompts for an OTP via email. + * This is exposed to the parent component via the ControlValueAccessor interface (e.g. bind it to a FormControl). + * Use UserVerificationService to verify the user's input. + */ +@Component({ + selector: "app-user-verification-form-input", + templateUrl: "user-verification-form-input.component.html", + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: UserVerificationFormInputComponent, + }, + ], + animations: [ + trigger("sent", [ + transition(":enter", [style({ opacity: 0 }), animate("100ms", style({ opacity: 1 }))]), + ]), + ], + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + JslibModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + IconModule, + LinkModule, + ButtonModule, + ], +}) +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class UserVerificationFormInputComponent implements ControlValueAccessor, OnInit, OnDestroy { + @Input() verificationType: "server" | "client" = "server"; // server represents original behavior + private _invalidSecret = false; + @Input() + get invalidSecret() { + return this._invalidSecret; + } + set invalidSecret(value: boolean) { + this._invalidSecret = value; + this.invalidSecretChange.emit(value); + + // ISSUE: This is pretty hacky but unfortunately there is no way of knowing if the parent + // control has been marked as touched, see: https://github.com/angular/angular/issues/10887 + // When that functionality has been added we should also look into forwarding reactive form + // controls errors so that we don't need a separate input/output `invalidSecret`. + if (value) { + this.secret.markAsTouched(); + } + this.secret.updateValueAndValidity({ emitEvent: false }); + } + @Output() invalidSecretChange = new EventEmitter(); + + @Output() activeClientVerificationOptionChange = + new EventEmitter(); + + @Output() biometricsVerificationResultChange = new EventEmitter(); + + readonly Icons = { UserVerificationBiometricsIcon }; + + // default to false to avoid null checks in template + userVerificationOptions: UserVerificationOptions = { + client: { + masterPassword: false, + pin: false, + biometrics: false, + }, + server: { + masterPassword: false, + otp: false, + }, + }; + + ActiveClientVerificationOption = ActiveClientVerificationOption; + + private _activeClientVerificationOptionSubject = + new BehaviorSubject(null); + + activeClientVerificationOption$ = this._activeClientVerificationOptionSubject.asObservable(); + + set activeClientVerificationOption(value: ActiveClientVerificationOption) { + this._activeClientVerificationOptionSubject.next(value); + } + + get activeClientVerificationOption(): ActiveClientVerificationOption { + return this._activeClientVerificationOptionSubject.getValue(); + } + + get hasMultipleClientVerificationOptions(): boolean { + let optionsCount = 0; + if (this.userVerificationOptions.client.masterPassword) { + optionsCount++; + } + if (this.userVerificationOptions.client.pin) { + optionsCount++; + } + if (this.userVerificationOptions.client.biometrics) { + optionsCount++; + } + return optionsCount >= 2; + } + + biometricsVerificationFailed = false; + + disableRequestOTP = false; + sentInitialCode = false; + sentCode = false; + + secret = new FormControl("", [ + Validators.required, + () => { + if (this.invalidSecret) { + return { + invalidSecret: { + message: this.getInvalidSecretErrorMessage(), + }, + }; + } + }, + ]); + + private getInvalidSecretErrorMessage(): string { + // must determine client or server + if (this.verificationType === "server") { + return this.userVerificationOptions.server.masterPassword + ? this.i18nService.t("incorrectPassword") + : this.i18nService.t("incorrectCode"); + } else { + // client + if (this.activeClientVerificationOption === ActiveClientVerificationOption.MasterPassword) { + return this.i18nService.t("incorrectPassword"); + } else if (this.activeClientVerificationOption === ActiveClientVerificationOption.Pin) { + return this.i18nService.t("incorrectPin"); + } + } + } + + private onChange: (value: VerificationWithSecret) => void; + private destroy$ = new Subject(); + + constructor( + private userVerificationService: UserVerificationService, + private i18nService: I18nService, + ) {} + + async ngOnInit() { + this.userVerificationOptions = + await this.userVerificationService.getAvailableVerificationOptions(this.verificationType); + + if (this.verificationType === "client") { + this.setDefaultActiveClientVerificationOption(); + this.setupClientVerificationOptionChangeHandler(); + } else { + if (this.userVerificationOptions.server.otp) { + // New design requires requesting on load to prevent user from having to click send code + this.requestOTP(); + } + } + + // Don't bother executing secret changes if biometrics verification is active. + if (this.activeClientVerificationOption === ActiveClientVerificationOption.Biometrics) { + this.processSecretChanges(this.secret.value); + } + + this.secret.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((secret: string) => this.processSecretChanges(secret)); + } + + private setDefaultActiveClientVerificationOption(): void { + // Priorities should be Bio > Pin > Master Password for speed based on design + if (this.userVerificationOptions.client.biometrics) { + this.activeClientVerificationOption = ActiveClientVerificationOption.Biometrics; + } else if (this.userVerificationOptions.client.pin) { + this.activeClientVerificationOption = ActiveClientVerificationOption.Pin; + } else if (this.userVerificationOptions.client.masterPassword) { + this.activeClientVerificationOption = ActiveClientVerificationOption.MasterPassword; + } else { + this.activeClientVerificationOption = ActiveClientVerificationOption.None; + } + } + + private setupClientVerificationOptionChangeHandler(): void { + this.activeClientVerificationOption$ + .pipe(takeUntil(this.destroy$)) + .subscribe((activeClientVerificationOption: ActiveClientVerificationOption) => { + this.handleActiveClientVerificationOptionChange(activeClientVerificationOption); + }); + } + + private async handleActiveClientVerificationOptionChange( + activeClientVerificationOption: ActiveClientVerificationOption, + ): Promise { + // Emit to parent component so it can implement behavior if needed. + this.activeClientVerificationOptionChange.emit(activeClientVerificationOption); + + // clear secret value when switching verification methods + this.secret.setValue(null); + + // Reset validation errors when swapping active client verification options + this.secret.markAsUntouched(); + this.secret.updateValueAndValidity({ emitEvent: false }); + + // if changing to biometrics, we need to prompt for biometrics + if (activeClientVerificationOption === "biometrics") { + // reset biometrics failed + this.biometricsVerificationFailed = false; + await this.verifyUserViaBiometrics(); + } + } + + async verifyUserViaBiometrics() { + this.biometricsVerificationFailed = false; + + const biometricsResult = await this.userVerificationService.verifyUser({ + type: VerificationType.Biometrics, + }); + + this.biometricsVerificationResultChange.emit(biometricsResult); + + this.biometricsVerificationFailed = !biometricsResult; + } + + requestOTP = async () => { + if (!this.userVerificationOptions.server.masterPassword) { + this.disableRequestOTP = true; + try { + await this.userVerificationService.requestOTP(); + this.sentCode = true; + this.sentInitialCode = true; + + // after 3 seconds reset sentCode to false + setTimeout(() => { + this.sentCode = false; + }, 3000); + } finally { + this.disableRequestOTP = false; + } + } + }; + + writeValue(obj: any): void { + this.secret.setValue(obj); + } + + /** Required for NG_VALUE_ACCESSOR */ + registerOnChange(fn: any): void { + this.onChange = fn; + } + + /** Required for NG_VALUE_ACCESSOR */ + registerOnTouched(fn: any): void { + // Not implemented + } + + setDisabledState?(isDisabled: boolean): void { + this.disableRequestOTP = isDisabled; + if (isDisabled) { + this.secret.disable(); + } else { + this.secret.enable(); + } + } + + processSecretChanges(secret: string) { + this.invalidSecret = false; + + // Short circuit secret change handling when biometrics is chosen as biometrics has no secret + if (this.activeClientVerificationOption === ActiveClientVerificationOption.Biometrics) { + return; + } + + if (this.onChange == null) { + return; + } + + this.onChange({ + type: this.determineVerificationWithSecretType(), + secret: Utils.isNullOrWhitespace(secret) ? null : secret, + }); + } + + private determineVerificationWithSecretType(): + | VerificationType.MasterPassword + | VerificationType.OTP + | VerificationType.PIN { + if (this.verificationType === "server") { + return this.userVerificationOptions.server.masterPassword + ? VerificationType.MasterPassword + : VerificationType.OTP; + } else { + // client + return this.userVerificationOptions.client.masterPassword && + this.activeClientVerificationOption === ActiveClientVerificationOption.MasterPassword + ? VerificationType.MasterPassword + : VerificationType.PIN; + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/libs/auth/src/common/services/pin-crypto/pin-crypto.service.implementation.ts b/libs/auth/src/common/services/pin-crypto/pin-crypto.service.implementation.ts index c1f81a89407..149d5d9a53d 100644 --- a/libs/auth/src/common/services/pin-crypto/pin-crypto.service.implementation.ts +++ b/libs/auth/src/common/services/pin-crypto/pin-crypto.service.implementation.ts @@ -48,12 +48,12 @@ export class PinCryptoService implements PinCryptoServiceAbstraction { } if (!userKey) { - this.logService.error(`User key null after pin key decryption.`); + this.logService.warning(`User key null after pin key decryption.`); return null; } if (!(await this.validatePin(userKey, pin))) { - this.logService.error(`Pin key decryption successful but pin validation failed.`); + this.logService.warning(`Pin key decryption successful but pin validation failed.`); return null; } diff --git a/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts b/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts index 28ab5604eb0..11fe537919e 100644 --- a/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts @@ -1,4 +1,5 @@ import { SecretVerificationRequest } from "../../models/request/secret-verification.request"; +import { UserVerificationOptions } from "../../types/user-verification-options"; import { Verification } from "../../types/verification"; export abstract class UserVerificationService { @@ -21,4 +22,8 @@ export abstract class UserVerificationService { * @returns True if the user has a master password and has used it in the current session */ hasMasterPasswordAndMasterKeyHash: (userId?: string) => Promise; + + getAvailableVerificationOptions: ( + verificationType: keyof UserVerificationOptions, + ) => Promise; } diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.ts b/libs/common/src/auth/services/user-verification/user-verification.service.ts index 45f38945252..431348c7fc9 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.ts @@ -1,7 +1,9 @@ import { PinCryptoServiceAbstraction } from "../../../../../auth/src/common/abstractions/pin-crypto.service.abstraction"; +import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../../abstractions/vault-timeout/vault-timeout-settings.service"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { LogService } from "../../../platform/abstractions/log.service"; +import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { KeySuffixOptions } from "../../../platform/enums/key-suffix-options.enum"; import { UserKey } from "../../../types/key"; @@ -10,6 +12,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from ". import { VerificationType } from "../../enums/verification-type"; import { SecretVerificationRequest } from "../../models/request/secret-verification.request"; import { VerifyOTPRequest } from "../../models/request/verify-otp.request"; +import { UserVerificationOptions } from "../../types/user-verification-options"; import { MasterPasswordVerification, OtpVerification, @@ -32,8 +35,54 @@ export class UserVerificationService implements UserVerificationServiceAbstracti private userVerificationApiService: UserVerificationApiServiceAbstraction, private pinCryptoService: PinCryptoServiceAbstraction, private logService: LogService, + private vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction, + private platformUtilsService: PlatformUtilsService, ) {} + async getAvailableVerificationOptions( + verificationType: keyof UserVerificationOptions, + ): Promise { + if (verificationType === "client") { + const [userHasMasterPassword, pinLockType, biometricsLockSet, biometricsUserKeyStored] = + await Promise.all([ + this.hasMasterPasswordAndMasterKeyHash(), + this.vaultTimeoutSettingsService.isPinLockSet(), + this.vaultTimeoutSettingsService.isBiometricLockSet(), + this.cryptoService.hasUserKeyStored(KeySuffixOptions.Biometric), + ]); + + // note: we do not need to check this.platformUtilsService.supportsBiometric() because + // we can just use the logic below which works for both desktop & the browser extension. + + return { + client: { + masterPassword: userHasMasterPassword, + pin: pinLockType !== "DISABLED", + biometrics: + biometricsLockSet && + (biometricsUserKeyStored || !this.platformUtilsService.supportsSecureStorage()), + }, + server: { + masterPassword: false, + otp: false, + }, + }; + } else { + // server + // Don't check if have MP hash locally, because we are going to send the secret to the server to be verified. + const userHasMasterPassword = await this.hasMasterPassword(); + + return { + client: { + masterPassword: false, + pin: false, + biometrics: false, + }, + server: { masterPassword: userHasMasterPassword, otp: !userHasMasterPassword }, + }; + } + } + /** * Create a new request model to be used for server-side verification * @param verification User-supplied verification data (Master Password or OTP) diff --git a/libs/common/src/auth/types/user-verification-options.ts b/libs/common/src/auth/types/user-verification-options.ts new file mode 100644 index 00000000000..c6d5efac8db --- /dev/null +++ b/libs/common/src/auth/types/user-verification-options.ts @@ -0,0 +1,14 @@ +/** + * @typedef {Object} UserVerificationOptions - The available verification options for a user. + */ +export type UserVerificationOptions = { + server: { + otp: boolean; + masterPassword: boolean; + }; + client: { + masterPassword: boolean; + pin: boolean; + biometrics: boolean; + }; +}; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 790fea828f7..6e8180bf76d 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -5,6 +5,7 @@ export * from "./badge"; export * from "./banner"; export * from "./breadcrumbs"; export * from "./button"; +export { ButtonType } from "./shared/button-like.abstraction"; export * from "./callout"; export * from "./checkbox"; export * from "./color-password";