diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 79ea4d4856..4d786ddcbb 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -49,6 +49,19 @@ "masterPassHintDesc": { "message": "A master password hint can help you remember your password if you forget it." }, + "masterPassHintText": { + "message": "If you forget your password, the password hint can be sent to your email. $CURRENT$/$MAXIMUM$ character maximum.", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "maximum": { + "content": "$2", + "example": "50" + } + } + }, "reTypeMasterPass": { "message": "Re-type master password" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 73e1bc56e6..d0589116fb 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -526,6 +526,19 @@ "masterPassHint": { "message": "Master password hint (optional)" }, + "masterPassHintText": { + "message": "If you forget your password, the password hint can be sent to your email. $CURRENT$/$MAXIMUM$ character maximum.", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "maximum": { + "content": "$2", + "example": "50" + } + } + }, "settings": { "message": "Settings" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index c248b04dc0..87c66f9b73 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -767,6 +767,19 @@ "masterPassHintLabel": { "message": "Master password hint" }, + "masterPassHintText": { + "message": "If you forget your password, the password hint can be sent to your email. $CURRENT$/$MAXIMUM$ character maximum.", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "maximum": { + "content": "$2", + "example": "50" + } + } + }, "settings": { "message": "Settings" }, diff --git a/libs/angular/src/auth/validators/inputs-field-match.validator.ts b/libs/angular/src/auth/validators/inputs-field-match.validator.ts index 9c91007d0c..5f3acd73bc 100644 --- a/libs/angular/src/auth/validators/inputs-field-match.validator.ts +++ b/libs/angular/src/auth/validators/inputs-field-match.validator.ts @@ -1,9 +1,13 @@ -import { AbstractControl, UntypedFormGroup, ValidatorFn } from "@angular/forms"; +import { AbstractControl, UntypedFormGroup, ValidationErrors, ValidatorFn } from "@angular/forms"; import { FormGroupControls } from "../../platform/abstractions/form-validation-errors.service"; export class InputsFieldMatch { - //check to ensure two fields do not have the same value + /** + * Check to ensure two fields do not have the same value + * + * @deprecated Use compareInputs() instead + */ static validateInputsDoesntMatch(matchTo: string, errorMessage: string): ValidatorFn { return (control: AbstractControl) => { if (control.parent && control.parent.controls) { @@ -37,7 +41,18 @@ export class InputsFieldMatch { }; } - //checks the formGroup if two fields have the same value and validation is controlled from either field + /** + * Checks the formGroup if two fields have the same value and validation is controlled from either field + * + * @deprecated + * Use compareInputs() instead. + * + * For more info on deprecation + * - Do not use untyped `options` object in formBuilder.group() {@link https://angular.dev/api/forms/UntypedFormBuilder} + * - Use formBuilder.group() overload with AbstractControlOptions type instead {@link https://angular.dev/api/forms/AbstractControlOptions} + * + * Remove this method after deprecated instances are replaced + */ static validateFormInputsMatch(field: string, fieldMatchTo: string, errorMessage: string) { return (formGroup: UntypedFormGroup) => { const fieldCtrl = formGroup.controls[field]; @@ -54,4 +69,99 @@ export class InputsFieldMatch { } }; } + + /** + * Checks whether two form controls do or do not have the same input value (except for empty string values). + * + * - Validation is controlled from either form control. + * - The error message is displayed under controlB by default, but can be set to controlA. + * + * @param validationGoal Whether you want to verify that the form control input values match or do not match + * @param controlNameA The name of the first form control to compare. + * @param controlNameB The name of the second form control to compare. + * @param errorMessage The error message to display if there is an error. This will probably + * be an i18n translated string. + * @param showErrorOn The control under which you want to display the error (default is controlB). + */ + static compareInputs( + validationGoal: "match" | "doNotMatch", + controlNameA: string, + controlNameB: string, + errorMessage: string, + showErrorOn: "controlA" | "controlB" = "controlB", + ): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const controlA = control.get(controlNameA); + const controlB = control.get(controlNameB); + + if (!controlA || !controlB) { + return null; + } + + const controlThatShowsError = showErrorOn === "controlA" ? controlA : controlB; + + // Don't compare empty strings + if (controlA.value === "" && controlB.value === "") { + return pass(); + } + + const controlValuesMatch = controlA.value === controlB.value; + + if (validationGoal === "match") { + if (controlValuesMatch) { + return pass(); + } else { + return fail(); + } + } + + if (validationGoal === "doNotMatch") { + if (!controlValuesMatch) { + return pass(); + } else { + return fail(); + } + } + + return null; // default return + + function fail() { + controlThatShowsError.setErrors({ + // Preserve any pre-existing errors + ...controlThatShowsError.errors, + // Add new inputMatchError + inputMatchError: { + message: errorMessage, + }, + }); + + return { + inputMatchError: { + message: errorMessage, + }, + }; + } + + function pass(): null { + // Get the current errors object + const errorsObj = controlThatShowsError?.errors; + + if (errorsObj != null) { + // Remove any inputMatchError if it exists, since that is the sole error we are targeting with this validator + if (errorsObj?.inputMatchError) { + delete errorsObj.inputMatchError; + } + + // Check if the errorsObj is now empty + const isEmptyObj = Object.keys(errorsObj).length === 0; + + // If the errorsObj is empty, set errors to null, otherwise set the errors to an object of pre-existing errors (other than inputMatchError) + controlThatShowsError.setErrors(isEmptyObj ? null : errorsObj); + } + + // Return null for this validator + return null; + } + }; + } } diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 9c660413cd..6cb08db6f7 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -8,6 +8,7 @@ export * from "./icons"; export * from "./anon-layout/anon-layout.component"; export * from "./anon-layout/anon-layout-wrapper.component"; export * from "./fingerprint-dialog/fingerprint-dialog.component"; +export * from "./input-password/input-password.component"; export * from "./password-callout/password-callout.component"; // user verification diff --git a/libs/auth/src/angular/input-password/input-password.component.html b/libs/auth/src/angular/input-password/input-password.component.html new file mode 100644 index 0000000000..e6c36914cf --- /dev/null +++ b/libs/auth/src/angular/input-password/input-password.component.html @@ -0,0 +1,73 @@ +
+ + +
+ + {{ "masterPassword" | i18n }} + + + + {{ "important" | i18n }} + {{ "masterPassImportant" | i18n }} + {{ minPasswordMsg }}. + + + + +
+ + + {{ "confirmMasterPassword" | i18n }} + + + + + + {{ "masterPassHintLabel" | i18n }} + + + {{ "masterPassHintText" | i18n: formGroup.value.hint.length : maxHintLength.toString() }} + + + + + + {{ "checkForBreaches" | i18n }} + + + + + +
diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts new file mode 100644 index 0000000000..cc91e2a255 --- /dev/null +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -0,0 +1,192 @@ +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { PBKDF2KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { MasterKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + CheckboxModule, + DialogService, + FormFieldModule, + IconButtonModule, + InputModule, + ToastService, +} from "@bitwarden/components"; + +import { InputsFieldMatch } from "../../../../angular/src/auth/validators/inputs-field-match.validator"; +import { SharedModule } from "../../../../components/src/shared"; +import { PasswordCalloutComponent } from "../password-callout/password-callout.component"; + +export interface PasswordInputResult { + masterKey: MasterKey; + masterKeyHash: string; + kdfConfig: PBKDF2KdfConfig; + hint: string; +} + +@Component({ + standalone: true, + selector: "auth-input-password", + templateUrl: "./input-password.component.html", + imports: [ + AsyncActionsModule, + ButtonModule, + CheckboxModule, + FormFieldModule, + IconButtonModule, + InputModule, + ReactiveFormsModule, + SharedModule, + PasswordCalloutComponent, + JslibModule, + ], +}) +export class InputPasswordComponent implements OnInit { + @Output() onPasswordFormSubmit = new EventEmitter(); + + @Input({ required: true }) email: string; + @Input() protected buttonText: string; + @Input() private orgId: string; + + private minHintLength = 0; + protected maxHintLength = 50; + + protected minPasswordLength = Utils.minimumPasswordLength; + protected minPasswordMsg = ""; + protected masterPasswordPolicy: MasterPasswordPolicyOptions; + protected passwordStrengthResult: any; + protected showErrorSummary = false; + protected showPassword = false; + + protected formGroup = this.formBuilder.group( + { + password: ["", [Validators.required, Validators.minLength(this.minPasswordLength)]], + confirmedPassword: ["", Validators.required], + hint: [ + "", // must be string (not null) because we check length in validation + [Validators.minLength(this.minHintLength), Validators.maxLength(this.maxHintLength)], + ], + checkForBreaches: true, + }, + { + validators: [ + InputsFieldMatch.compareInputs( + "match", + "password", + "confirmedPassword", + this.i18nService.t("masterPassDoesntMatch"), + ), + InputsFieldMatch.compareInputs( + "doNotMatch", + "password", + "hint", + this.i18nService.t("hintEqualsPassword"), + ), + ], + }, + ); + + constructor( + private auditService: AuditService, + private cryptoService: CryptoService, + private dialogService: DialogService, + private formBuilder: FormBuilder, + private i18nService: I18nService, + private policyService: PolicyService, + private toastService: ToastService, + private policyApiService: PolicyApiServiceAbstraction, + ) {} + + async ngOnInit() { + this.masterPasswordPolicy = await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser( + this.orgId, + ); + + if (this.masterPasswordPolicy != null && this.masterPasswordPolicy.minLength > 0) { + this.minPasswordMsg = this.i18nService.t( + "characterMinimum", + this.masterPasswordPolicy.minLength, + ); + } else { + this.minPasswordMsg = this.i18nService.t("characterMinimum", this.minPasswordLength); + } + } + + getPasswordStrengthResult(result: any) { + this.passwordStrengthResult = result; + } + + protected submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + this.showErrorSummary = true; + return; + } + + const password = this.formGroup.controls.password.value; + + // Check if password is breached (if breached, user chooses to accept and continue or not) + const passwordIsBreached = + this.formGroup.controls.checkForBreaches.value && + (await this.auditService.passwordLeaked(password)); + + if (passwordIsBreached) { + const userAcceptedDialog = await this.dialogService.openSimpleDialog({ + title: { key: "exposedMasterPassword" }, + content: { key: "exposedMasterPasswordDesc" }, + type: "warning", + }); + + if (!userAcceptedDialog) { + return; + } + } + + // Check if password meets org policy requirements + if ( + this.masterPasswordPolicy != null && + !this.policyService.evaluateMasterPassword( + this.passwordStrengthResult.score, + password, + this.masterPasswordPolicy, + ) + ) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"), + }); + + return; + } + + // Create and hash new master key + const kdfConfig = DEFAULT_KDF_CONFIG; + + const masterKey = await this.cryptoService.makeMasterKey( + password, + this.email.trim().toLowerCase(), + kdfConfig, + ); + + const masterKeyHash = await this.cryptoService.hashMasterKey(password, masterKey); + + this.onPasswordFormSubmit.emit({ + masterKey, + masterKeyHash, + kdfConfig, + hint: this.formGroup.controls.hint.value, + }); + }; +} diff --git a/libs/auth/src/angular/input-password/input-password.stories.ts b/libs/auth/src/angular/input-password/input-password.stories.ts new file mode 100644 index 0000000000..6144e39e64 --- /dev/null +++ b/libs/auth/src/angular/input-password/input-password.stories.ts @@ -0,0 +1,116 @@ +import { importProvidersFrom } from "@angular/core"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { action } from "@storybook/addon-actions"; +import { Meta, StoryObj, applicationConfig } from "@storybook/angular"; +import { of } from "rxjs"; +import { ZXCVBNResult } from "zxcvbn"; + +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { PreloadedEnglishI18nModule } from "../../../../../apps/web/src/app/core/tests"; + +import { InputPasswordComponent } from "./input-password.component"; + +const mockMasterPasswordPolicyOptions = { + minComplexity: 4, + minLength: 14, + requireUpper: true, + requireLower: true, + requireNumbers: true, + requireSpecial: true, +} as MasterPasswordPolicyOptions; + +export default { + title: "Auth/Input Password", + component: InputPasswordComponent, + decorators: [ + applicationConfig({ + providers: [ + importProvidersFrom(PreloadedEnglishI18nModule), + importProvidersFrom(BrowserAnimationsModule), + { + provide: AuditService, + useValue: { + passwordLeaked: () => Promise.resolve(1), + } as Partial, + }, + { + provide: CryptoService, + useValue: { + makeMasterKey: () => Promise.resolve("example-master-key"), + hashMasterKey: () => Promise.resolve("example-master-key-hash"), + }, + }, + { + provide: DialogService, + useValue: { + openSimpleDialog: () => Promise.resolve(true), + } as Partial, + }, + { + provide: PolicyApiServiceAbstraction, + useValue: { + getMasterPasswordPolicyOptsForOrgUser: () => mockMasterPasswordPolicyOptions, + } as Partial, + }, + { + provide: PolicyService, + useValue: { + masterPasswordPolicyOptions$: () => of(mockMasterPasswordPolicyOptions), + evaluateMasterPassword: (score) => { + if (score < 4) { + return false; + } + return true; + }, + } as Partial, + }, + { + provide: PasswordStrengthServiceAbstraction, + useValue: { + getPasswordStrength: (password) => { + let score = 0; + + if (password.length === 0) { + score = null; + } else if (password.length <= 4) { + score = 1; + } else if (password.length <= 8) { + score = 2; + } else if (password.length <= 12) { + score = 3; + } else { + score = 4; + } + + return { score } as ZXCVBNResult; + }, + } as Partial, + }, + { + provide: ToastService, + useValue: { + showToast: action("ToastService.showToast"), + } as Partial, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), +};