1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-22 11:45:59 +01:00

[PM-5085] Create InputPasswordComponent (#9630)

* setup for InputPasswordComponent and basic story

* add all input fields

* add translated error messages

* update validation

* add password-callout

* update hint text

* use PolicyService in component

* setup SetPasswordComponent

* remove div

* add default button text

* add mocks for InputPassword storybook

* simplify ngOnInit

* change param and use PolicyApiService

* check for breaches and validate against policy

* user toastService

* use useValue for mocks

* hash before emitting

* validation cleanup and use PreloadedEnglishI18nModule

* add ngOnDestroy

* create validateFormInputsDoNotMatch fn

* update validateFormInputsComparison and add deprecation jsdocs

* rename validator fn

* fix bugs in validation fn

* cleanup and re-introduce services/logic

* toggle password inputs together

* update hint help text

* remove SetPassword test

* remove master key creation / hashing

* add translations to browser/desktop

* mock basic password-strength functionality

* add check for controls

* hash before emitting

* type the EventEmitter

* use DEFAULT_KDF_CONFIG

* emit master key

* clarify comment

* update password mininum help text to match org policy requirement
This commit is contained in:
rr-bw 2024-06-17 14:56:24 -07:00 committed by GitHub
parent 75615902a3
commit 2a0e21b4bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 534 additions and 3 deletions

View File

@ -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"
},

View File

@ -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"
},

View File

@ -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"
},

View File

@ -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;
}
};
}
}

View File

@ -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

View File

@ -0,0 +1,73 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<auth-password-callout
*ngIf="masterPasswordPolicy"
[policy]="masterPasswordPolicy"
></auth-password-callout>
<div class="tw-mb-6">
<bit-form-field>
<bit-label>{{ "masterPassword" | i18n }}</bit-label>
<input
id="input-password-form_password"
bitInput
type="password"
formControlName="password"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<bit-hint>
<span class="tw-font-bold">{{ "important" | i18n }} </span>
{{ "masterPassImportant" | i18n }}
{{ minPasswordMsg }}.
</bit-hint>
</bit-form-field>
<app-password-strength
[password]="formGroup.controls.password.value"
[email]="email"
[showText]="true"
(passwordStrengthResult)="getPasswordStrengthResult($event)"
></app-password-strength>
</div>
<bit-form-field>
<bit-label>{{ "confirmMasterPassword" | i18n }}</bit-label>
<input
id="input-password-form_confirmed-password"
bitInput
type="password"
formControlName="confirmedPassword"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "masterPassHintLabel" | i18n }}</bit-label>
<input bitInput formControlName="hint" />
<bit-hint>
{{ "masterPassHintText" | i18n: formGroup.value.hint.length : maxHintLength.toString() }}
</bit-hint>
</bit-form-field>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="checkForBreaches" />
<bit-label>{{ "checkForBreaches" | i18n }}</bit-label>
</bit-form-control>
<button type="submit" bitButton bitFormButton buttonType="primary" [block]="true">
{{ buttonText || ("setMasterPassword" | i18n) }}
</button>
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary>
</form>

View File

@ -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<PasswordInputResult>();
@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,
});
};
}

View File

@ -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<AuditService>,
},
{
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<DialogService>,
},
{
provide: PolicyApiServiceAbstraction,
useValue: {
getMasterPasswordPolicyOptsForOrgUser: () => mockMasterPasswordPolicyOptions,
} as Partial<PolicyService>,
},
{
provide: PolicyService,
useValue: {
masterPasswordPolicyOptions$: () => of(mockMasterPasswordPolicyOptions),
evaluateMasterPassword: (score) => {
if (score < 4) {
return false;
}
return true;
},
} as Partial<PolicyService>,
},
{
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<PasswordStrengthServiceAbstraction>,
},
{
provide: ToastService,
useValue: {
showToast: action("ToastService.showToast"),
} as Partial<ToastService>,
},
],
}),
],
} as Meta;
type Story = StoryObj<InputPasswordComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password></auth-input-password>
`,
}),
};