1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

refactor(set-change-password): [Auth/PM-18206] Update InputPasswordComponent to handle multiple flows (#13745)

Updates the InputPasswordComponent so that it can eventually be used in multiple set/change password scenarios.

Most importantly, this PR adds an InputPasswordFlow enum and @Input so that parent components can dictate which UI elements to show.
This commit is contained in:
rr-bw
2025-04-07 11:58:50 -07:00
committed by GitHub
parent 7f58cee41b
commit 2267876860
20 changed files with 394 additions and 113 deletions

View File

@@ -2297,6 +2297,9 @@
"privacyPolicy": { "privacyPolicy": {
"message": "Privacy Policy" "message": "Privacy Policy"
}, },
"yourNewPasswordCannotBeTheSameAsYourCurrentPassword": {
"message": "Your new password cannot be the same as your current password."
},
"hintEqualsPassword": { "hintEqualsPassword": {
"message": "Your password hint cannot be the same as your password." "message": "Your password hint cannot be the same as your password."
}, },

View File

@@ -2072,6 +2072,9 @@
"personalOwnershipSubmitError": { "personalOwnershipSubmitError": {
"message": "Due to an enterprise policy, you are restricted from saving items to your individual vault. Change the ownership option to an organization and choose from available collections." "message": "Due to an enterprise policy, you are restricted from saving items to your individual vault. Change the ownership option to an organization and choose from available collections."
}, },
"yourNewPasswordCannotBeTheSameAsYourCurrentPassword": {
"message": "Your new password cannot be the same as your current password."
},
"hintEqualsPassword": { "hintEqualsPassword": {
"message": "Your password hint cannot be the same as your password." "message": "Your password hint cannot be the same as your password."
}, },

View File

@@ -187,11 +187,11 @@ describe("WebRegistrationFinishService", () => {
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
passwordInputResult = { passwordInputResult = {
masterKey: masterKey, masterKey: masterKey,
masterKeyHash: "masterKeyHash", serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash", localMasterKeyHash: "localMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG, kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint", hint: "hint",
password: "password", newPassword: "newPassword",
}; };
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
@@ -239,7 +239,7 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({ expect.objectContaining({
email, email,
emailVerificationToken: emailVerificationToken, emailVerificationToken: emailVerificationToken,
masterPasswordHash: passwordInputResult.masterKeyHash, masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint, masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString, userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: { userAsymmetricKeys: {
@@ -277,7 +277,7 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({ expect.objectContaining({
email, email,
emailVerificationToken: undefined, emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.masterKeyHash, masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint, masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString, userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: { userAsymmetricKeys: {
@@ -320,7 +320,7 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({ expect.objectContaining({
email, email,
emailVerificationToken: undefined, emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.masterKeyHash, masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint, masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString, userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: { userAsymmetricKeys: {
@@ -365,7 +365,7 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({ expect.objectContaining({
email, email,
emailVerificationToken: undefined, emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.masterKeyHash, masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint, masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString, userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: { userAsymmetricKeys: {
@@ -412,7 +412,7 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({ expect.objectContaining({
email, email,
emailVerificationToken: undefined, emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.masterKeyHash, masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint, masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString, userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: { userAsymmetricKeys: {

View File

@@ -1,19 +1,21 @@
<div *ngIf="!useTrialStepper"> <div *ngIf="!useTrialStepper">
<auth-input-password <auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[email]="email" [email]="email"
[masterPasswordPolicyOptions]="enforcedPolicyOptions" [masterPasswordPolicyOptions]="enforcedPolicyOptions"
(onPasswordFormSubmit)="handlePasswordSubmit($event)" (onPasswordFormSubmit)="handlePasswordSubmit($event)"
[buttonText]="'createAccount' | i18n" [primaryButtonText]="{ key: 'createAccount' }"
></auth-input-password> ></auth-input-password>
</div> </div>
<div *ngIf="useTrialStepper"> <div *ngIf="useTrialStepper">
<app-vertical-stepper #stepper linear (selectionChange)="verticalStepChange($event)"> <app-vertical-stepper #stepper linear (selectionChange)="verticalStepChange($event)">
<app-vertical-step label="Create Account" [editable]="false" [subLabel]="email"> <app-vertical-step label="Create Account" [editable]="false" [subLabel]="email">
<auth-input-password <auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[email]="email" [email]="email"
[masterPasswordPolicyOptions]="enforcedPolicyOptions" [masterPasswordPolicyOptions]="enforcedPolicyOptions"
(onPasswordFormSubmit)="handlePasswordSubmit($event)" (onPasswordFormSubmit)="handlePasswordSubmit($event)"
[buttonText]="'createAccount' | i18n" [primaryButtonText]="{ key: 'createAccount' }"
></auth-input-password> ></auth-input-password>
</app-vertical-step> </app-vertical-step>
<app-vertical-step label="Organization Information" [subLabel]="orgInfoSubLabel"> <app-vertical-step label="Organization Information" [subLabel]="orgInfoSubLabel">

View File

@@ -6,7 +6,11 @@ import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, Subject, switchMap, takeUntil } from "rxjs"; import { firstValueFrom, Subject, switchMap, takeUntil } from "rxjs";
import { PasswordInputResult, RegistrationFinishService } from "@bitwarden/auth/angular"; import {
InputPasswordFlow,
PasswordInputResult,
RegistrationFinishService,
} from "@bitwarden/auth/angular";
import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common"; import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; 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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -47,6 +51,8 @@ export type InitiationPath =
export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent; @ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
InputPasswordFlow = InputPasswordFlow;
/** Password Manager or Secrets Manager */ /** Password Manager or Secrets Manager */
product: ProductType; product: ProductType;
/** The tier of product being subscribed to */ /** The tier of product being subscribed to */
@@ -363,7 +369,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
return; return;
} }
await this.logIn(passwordInputResult.password, captchaToken); await this.logIn(passwordInputResult.newPassword, captchaToken);
this.submitting = false; this.submitting = false;

View File

@@ -5707,6 +5707,9 @@
"webAuthnSuccess": { "webAuthnSuccess": {
"message": "WebAuthn verified successfully! You may close this tab." "message": "WebAuthn verified successfully! You may close this tab."
}, },
"yourNewPasswordCannotBeTheSameAsYourCurrentPassword": {
"message": "Your new password cannot be the same as your current password."
},
"hintEqualsPassword": { "hintEqualsPassword": {
"message": "Your password hint cannot be the same as your password." "message": "Your password hint cannot be the same as your password."
}, },

View File

@@ -4,14 +4,36 @@
[policy]="masterPasswordPolicyOptions" [policy]="masterPasswordPolicyOptions"
></auth-password-callout> ></auth-password-callout>
<bit-form-field
*ngIf="
inputPasswordFlow === InputPasswordFlow.ChangePassword ||
inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
"
>
<bit-label>{{ "currentMasterPass" | i18n }}</bit-label>
<input
id="input-password-form_current-password"
bitInput
type="password"
formControlName="currentPassword"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
<div class="tw-mb-6"> <div class="tw-mb-6">
<bit-form-field> <bit-form-field>
<bit-label>{{ "masterPassword" | i18n }}</bit-label> <bit-label>{{ "newMasterPass" | i18n }}</bit-label>
<input <input
id="input-password-form_password" id="input-password-form_new-password"
bitInput bitInput
type="password" type="password"
formControlName="password" formControlName="newPassword"
/> />
<button <button
type="button" type="button"
@@ -30,7 +52,7 @@
<tools-password-strength <tools-password-strength
[showText]="true" [showText]="true"
[email]="email" [email]="email"
[password]="formGroup.controls.password.value" [password]="formGroup.controls.newPassword.value"
(passwordStrengthScore)="getPasswordStrengthScore($event)" (passwordStrengthScore)="getPasswordStrengthScore($event)"
></tools-password-strength> ></tools-password-strength>
</div> </div>
@@ -38,10 +60,10 @@
<bit-form-field> <bit-form-field>
<bit-label>{{ "confirmMasterPassword" | i18n }}</bit-label> <bit-label>{{ "confirmMasterPassword" | i18n }}</bit-label>
<input <input
id="input-password-form_confirmed-password" id="input-password-form_confirm-new-password"
bitInput bitInput
type="password" type="password"
formControlName="confirmedPassword" formControlName="confirmNewPassword"
/> />
<button <button
type="button" type="button"
@@ -65,16 +87,40 @@
<bit-label>{{ "checkForBreaches" | i18n }}</bit-label> <bit-label>{{ "checkForBreaches" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
<button <bit-form-control
type="submit" *ngIf="inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation"
bitButton
bitFormButton
buttonType="primary"
[block]="btnBlock"
[loading]="loading"
> >
{{ buttonText || ("setMasterPassword" | i18n) }} <input type="checkbox" bitCheckbox formControlName="rotateUserKey" />
</button> <bit-label>
{{ "rotateAccountEncKey" | i18n }}
<a
href="https://bitwarden.com/help/account-encryption-key/#rotate-your-encryption-key"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'impactOfRotatingYourEncryptionKey' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
</bit-form-control>
<div class="tw-flex tw-gap-2" [ngClass]="inlineButtons ? 'tw-flex-row' : 'tw-flex-col'">
<button type="submit" bitButton bitFormButton buttonType="primary" [loading]="loading">
{{ primaryButtonTextStr || ("setMasterPassword" | i18n) }}
</button>
<button
*ngIf="secondaryButtonText"
type="button"
bitButton
bitFormButton
buttonType="secondary"
[loading]="loading"
(click)="onSecondaryButtonClick.emit()"
>
{{ secondaryButtonTextStr }}
</button>
</div>
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary> <bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary>
</form> </form>

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
// @ts-strict-ignore import { ReactiveFormsModule, FormBuilder, Validators, FormGroup } from "@angular/forms";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { import {
@@ -23,6 +21,7 @@ import {
IconButtonModule, IconButtonModule,
InputModule, InputModule,
ToastService, ToastService,
Translation,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management"; import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
@@ -36,6 +35,29 @@ import { PasswordCalloutComponent } from "../password-callout/password-callout.c
import { PasswordInputResult } from "./password-input-result"; import { PasswordInputResult } from "./password-input-result";
/**
* Determines which form input elements will be displayed in the UI.
*/
export enum InputPasswordFlow {
/**
* - Input: New password
* - Input: Confirm new password
* - Input: Hint
* - Checkbox: Check for breaches
*/
SetInitialPassword,
/**
* Everything above, plus:
* - Input: Current password (as the first element in the UI)
*/
ChangePassword,
/**
* Everything above, plus:
* - Checkbox: Rotate account encryption key (as the last element in the UI)
*/
ChangePasswordWithOptionalUserKeyRotation,
}
@Component({ @Component({
standalone: true, standalone: true,
selector: "auth-input-password", selector: "auth-input-password",
@@ -54,44 +76,58 @@ import { PasswordInputResult } from "./password-input-result";
JslibModule, JslibModule,
], ],
}) })
export class InputPasswordComponent { export class InputPasswordComponent implements OnInit {
@Output() onPasswordFormSubmit = new EventEmitter<PasswordInputResult>(); @Output() onPasswordFormSubmit = new EventEmitter<PasswordInputResult>();
@Output() onSecondaryButtonClick = new EventEmitter<void>();
@Input({ required: true }) email: string; @Input({ required: true }) inputPasswordFlow!: InputPasswordFlow;
@Input() buttonText: string; @Input({ required: true }) email!: string;
@Input() loading = false;
@Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null; @Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
@Input() loading: boolean = false;
@Input() btnBlock: boolean = true;
@Input() inlineButtons = false;
@Input() primaryButtonText?: Translation;
protected primaryButtonTextStr: string = "";
@Input() secondaryButtonText?: Translation;
protected secondaryButtonTextStr: string = "";
protected InputPasswordFlow = InputPasswordFlow;
private minHintLength = 0; private minHintLength = 0;
protected maxHintLength = 50; protected maxHintLength = 50;
protected minPasswordLength = Utils.minimumPasswordLength; protected minPasswordLength = Utils.minimumPasswordLength;
protected minPasswordMsg = ""; protected minPasswordMsg = "";
protected passwordStrengthScore: PasswordStrengthScore; protected passwordStrengthScore: PasswordStrengthScore = 0;
protected showErrorSummary = false; protected showErrorSummary = false;
protected showPassword = false; protected showPassword = false;
protected formGroup = this.formBuilder.group( protected formGroup = this.formBuilder.nonNullable.group(
{ {
password: ["", [Validators.required, Validators.minLength(this.minPasswordLength)]], newPassword: ["", [Validators.required, Validators.minLength(this.minPasswordLength)]],
confirmedPassword: ["", Validators.required], confirmNewPassword: ["", Validators.required],
hint: [ hint: [
"", // must be string (not null) because we check length in validation "", // must be string (not null) because we check length in validation
[Validators.minLength(this.minHintLength), Validators.maxLength(this.maxHintLength)], [Validators.minLength(this.minHintLength), Validators.maxLength(this.maxHintLength)],
], ],
checkForBreaches: true, checkForBreaches: [true],
}, },
{ {
validators: [ validators: [
InputsFieldMatch.compareInputs(
"doNotMatch",
"currentPassword",
"newPassword",
this.i18nService.t("yourNewPasswordCannotBeTheSameAsYourCurrentPassword"),
),
InputsFieldMatch.compareInputs( InputsFieldMatch.compareInputs(
"match", "match",
"password", "newPassword",
"confirmedPassword", "confirmNewPassword",
this.i18nService.t("masterPassDoesntMatch"), this.i18nService.t("masterPassDoesntMatch"),
), ),
InputsFieldMatch.compareInputs( InputsFieldMatch.compareInputs(
"doNotMatch", "doNotMatch",
"password", "newPassword",
"hint", "hint",
this.i18nService.t("hintEqualsPassword"), this.i18nService.t("hintEqualsPassword"),
), ),
@@ -109,6 +145,41 @@ export class InputPasswordComponent {
private toastService: ToastService, private toastService: ToastService,
) {} ) {}
ngOnInit(): void {
if (
this.inputPasswordFlow === InputPasswordFlow.ChangePassword ||
this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
// https://github.com/angular/angular/issues/48794
(this.formGroup as FormGroup<any>).addControl(
"currentPassword",
this.formBuilder.control("", Validators.required),
);
}
if (this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
// https://github.com/angular/angular/issues/48794
(this.formGroup as FormGroup<any>).addControl(
"rotateUserKey",
this.formBuilder.control(false),
);
}
if (this.primaryButtonText) {
this.primaryButtonTextStr = this.i18nService.t(
this.primaryButtonText.key,
...(this.primaryButtonText?.placeholders ?? []),
);
}
if (this.secondaryButtonText) {
this.secondaryButtonTextStr = this.i18nService.t(
this.secondaryButtonText.key,
...(this.secondaryButtonText?.placeholders ?? []),
);
}
}
get minPasswordLengthMsg() { get minPasswordLengthMsg() {
if ( if (
this.masterPasswordPolicyOptions != null && this.masterPasswordPolicyOptions != null &&
@@ -132,10 +203,10 @@ export class InputPasswordComponent {
return; return;
} }
const password = this.formGroup.controls.password.value; const newPassword = this.formGroup.controls.newPassword.value;
const passwordEvaluatedSuccessfully = await this.evaluatePassword( const passwordEvaluatedSuccessfully = await this.evaluateNewPassword(
password, newPassword,
this.passwordStrengthScore, this.passwordStrengthScore,
this.formGroup.controls.checkForBreaches.value, this.formGroup.controls.checkForBreaches.value,
); );
@@ -152,38 +223,55 @@ export class InputPasswordComponent {
} }
const masterKey = await this.keyService.makeMasterKey( const masterKey = await this.keyService.makeMasterKey(
password, newPassword,
this.email.trim().toLowerCase(), this.email.trim().toLowerCase(),
kdfConfig, kdfConfig,
); );
const masterKeyHash = await this.keyService.hashMasterKey(password, masterKey); const serverMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
masterKey,
HashPurpose.ServerAuthorization,
);
const localMasterKeyHash = await this.keyService.hashMasterKey( const localMasterKeyHash = await this.keyService.hashMasterKey(
password, newPassword,
masterKey, masterKey,
HashPurpose.LocalAuthorization, HashPurpose.LocalAuthorization,
); );
this.onPasswordFormSubmit.emit({ const passwordInputResult: PasswordInputResult = {
masterKey, newPassword,
masterKeyHash,
localMasterKeyHash,
kdfConfig,
hint: this.formGroup.controls.hint.value, hint: this.formGroup.controls.hint.value,
password, kdfConfig,
}); masterKey,
serverMasterKeyHash,
localMasterKeyHash,
};
if (
this.inputPasswordFlow === InputPasswordFlow.ChangePassword ||
this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
passwordInputResult.currentPassword = this.formGroup.get("currentPassword")?.value;
}
if (this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.get("rotateUserKey")?.value;
}
this.onPasswordFormSubmit.emit(passwordInputResult);
}; };
// Returns true if the password passes all checks, false otherwise // Returns true if the password passes all checks, false otherwise
private async evaluatePassword( private async evaluateNewPassword(
password: string, newPassword: string,
passwordStrengthScore: PasswordStrengthScore, passwordStrengthScore: PasswordStrengthScore,
checkForBreaches: boolean, checkForBreaches: boolean,
) { ) {
// Check if the password is breached, weak, or both // Check if the password is breached, weak, or both
const passwordIsBreached = const passwordIsBreached =
checkForBreaches && (await this.auditService.passwordLeaked(password)); checkForBreaches && (await this.auditService.passwordLeaked(newPassword));
const passwordWeak = passwordStrengthScore != null && passwordStrengthScore < 3; const passwordWeak = passwordStrengthScore != null && passwordStrengthScore < 3;
@@ -224,7 +312,7 @@ export class InputPasswordComponent {
this.masterPasswordPolicyOptions != null && this.masterPasswordPolicyOptions != null &&
!this.policyService.evaluateMasterPassword( !this.policyService.evaluateMasterPassword(
this.passwordStrengthScore, this.passwordStrengthScore,
password, newPassword,
this.masterPasswordPolicyOptions, this.masterPasswordPolicyOptions,
) )
) { ) {

View File

@@ -6,9 +6,9 @@ import * as stories from "./input-password.stories.ts";
# InputPassword Component # InputPassword Component
The `InputPasswordComponent` allows a user to enter a master password and hint. On submission it The `InputPasswordComponent` allows a user to enter master password related credentials. On
creates a master key, master key hash, and emits those values to the parent (along with the hint and submission it creates a master key, master key hash, and emits those values to the parent (along
default kdfConfig). with the other values found in `PasswordInputResult`).
The component is intended for re-use in different scenarios throughout the application. Therefore it The component is intended for re-use in different scenarios throughout the application. Therefore it
is mostly presentational and simply emits values rather than acting on them itself. It is the job of is mostly presentational and simply emits values rather than acting on them itself. It is the job of
@@ -18,26 +18,66 @@ the parent component to act on those values as needed.
## `@Input()`'s ## `@Input()`'s
- `email` (**required**) - the parent component must provide an email so that the **Required**
`InputPasswordComponent` can create a master key.
- `buttonText` (optional) - an `i18n` translated string that can be used as button text (default - `inputPasswordFlow` - the parent component must provide the correct flow, which is used to
text is "Set master password"). determine which form input elements will be displayed in the UI.
- `masterPasswordPolicyOptions` (optional) - used to display and enforce master password policy - `email` - the parent component must provide an email so that the `InputPasswordComponent` can
requirements. create a master key.
**Optional**
- `loading` - a boolean used to indicate that the parent component is performing some
long-running/async operation and that the form should be disabled until the operation is complete.
The primary button will also show a spinner if `loading` true.
- `masterPasswordPolicyOptions` - used to display and enforce master password policy requirements.
- `inlineButtons` - takes a boolean that determines if the button(s) should be displayed inline (as
opposed to full-width)
- `primaryButtonText` - takes a `Translation` object that can be used as button text
- `secondaryButtonText` - takes a `Translation` object that can be used as button text
## `@Output()`'s
- `onPasswordFormSubmit` - on form submit, emits a `PasswordInputResult` object
- `onSecondaryButtonClick` - on click, emits a notice that the secondary button has been clicked.
The parent component can listen for this event and take some custom action as needed (go back,
cancel, logout, etc.)
<br /> <br />
## Form Input Fields ## Form Input Fields
The `InputPasswordComponent` allows a user to enter: The `InputPasswordComponent` can handle up to 6 different form input fields, depending on the
`InputPasswordFlow` provided by the parent component.
1. Master password **InputPasswordFlow.SetInitialPassword**
2. Master password confirmation
3. Hint (optional)
4. Chooses whether to check for password breaches (checkbox)
Validation ensures that the master password and confirmed master password are the same, and that the - Input: New password
master password and hint values are not the same. - Input: Confirm new password
- Input: Hint
- Checkbox: Check for breaches
**InputPasswordFlow.ChangePassword**
Includes everything above, plus:
- Input: Current password (as the first element in the UI)
**InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation**
Includes everything above, plus:
- Checkbox: Rotate account encryption key (as the last element in the UI)
<br />
## Validation
Validation ensures that:
- The current password and new password are NOT the same
- The new password and confirmed new password are the same
- The new password and password hint are NOT the same
<br /> <br />
@@ -57,19 +97,23 @@ When the form is submitted, the `InputPasswordComponent` does the following in o
```typescript ```typescript
export interface PasswordInputResult { export interface PasswordInputResult {
masterKey: MasterKey; newPassword: string;
masterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
hint: string; hint: string;
kdfConfig: PBKDF2KdfConfig;
masterKey: MasterKey;
serverMasterKeyHash: string;
localMasterKeyHash: string;
currentPassword?: string; // included if the flow is ChangePassword or ChangePasswordWithOptionalUserKeyRotation
rotateUserKey?: boolean; // included if the flow is ChangePasswordWithOptionalUserKeyRotation
} }
``` ```
# Default Example # Example - InputPasswordFlow.SetInitialPassword
<Story of={stories.Default} /> <Story of={stories.SetInitialPassword} />
<br /> <br />
# With Policy Requrements # Example - With Policy Requrements
<Story of={stories.WithPolicy} /> <Story of={stories.WithPolicies} />

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { importProvidersFrom } from "@angular/core"; import { importProvidersFrom } from "@angular/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { action } from "@storybook/addon-actions"; import { action } from "@storybook/addon-actions";
@@ -18,7 +16,7 @@ import { KeyService } from "@bitwarden/key-management";
// eslint-disable-next-line import/no-restricted-paths, no-restricted-imports // eslint-disable-next-line import/no-restricted-paths, no-restricted-imports
import { PreloadedEnglishI18nModule } from "../../../../../apps/web/src/app/core/tests"; import { PreloadedEnglishI18nModule } from "../../../../../apps/web/src/app/core/tests";
import { InputPasswordComponent } from "./input-password.component"; import { InputPasswordComponent, InputPasswordFlow } from "./input-password.component";
export default { export default {
title: "Auth/Input Password", title: "Auth/Input Password",
@@ -62,7 +60,7 @@ export default {
provide: PasswordStrengthServiceAbstraction, provide: PasswordStrengthServiceAbstraction,
useValue: { useValue: {
getPasswordStrength: (password) => { getPasswordStrength: (password) => {
let score = 0; let score: number | null = null;
if (password.length === 0) { if (password.length === 0) {
score = null; score = null;
} else if (password.length <= 4) { } else if (password.length <= 4) {
@@ -88,6 +86,12 @@ export default {
}), }),
], ],
args: { args: {
InputPasswordFlow: {
SetInitialPassword: InputPasswordFlow.SetInitialPassword,
ChangePassword: InputPasswordFlow.ChangePassword,
ChangePasswordWithOptionalUserKeyRotation:
InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation,
},
masterPasswordPolicyOptions: { masterPasswordPolicyOptions: {
minComplexity: 4, minComplexity: 4,
minLength: 14, minLength: 14,
@@ -96,25 +100,77 @@ export default {
requireNumbers: true, requireNumbers: true,
requireSpecial: true, requireSpecial: true,
} as MasterPasswordPolicyOptions, } as MasterPasswordPolicyOptions,
argTypes: {
onSecondaryButtonClick: { action: "onSecondaryButtonClick" },
},
}, },
} as Meta; } as Meta;
type Story = StoryObj<InputPasswordComponent>; type Story = StoryObj<InputPasswordComponent>;
export const Default: Story = { export const SetInitialPassword: Story = {
render: (args) => ({ render: (args) => ({
props: args, props: args,
template: ` template: `
<auth-input-password></auth-input-password> <auth-input-password [inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"></auth-input-password>
`, `,
}), }),
}; };
export const WithPolicy: Story = { export const ChangePassword: Story = {
render: (args) => ({ render: (args) => ({
props: args, props: args,
template: ` template: `
<auth-input-password [masterPasswordPolicyOptions]="masterPasswordPolicyOptions"></auth-input-password> <auth-input-password [inputPasswordFlow]="InputPasswordFlow.ChangePassword"></auth-input-password>
`,
}),
};
export const ChangePasswordWithOptionalUserKeyRotation: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation"
></auth-input-password>
`,
}),
};
export const WithPolicies: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
></auth-input-password>
`,
}),
};
export const SecondaryButton: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[secondaryButtonText]="{ key: 'cancel' }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
></auth-input-password>
`,
}),
};
export const SecondaryButtonWithPlaceHolderText: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[secondaryButtonText]="{ key: 'backTo', placeholders: ['homepage'] }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
></auth-input-password>
`, `,
}), }),
}; };
@@ -123,7 +179,24 @@ export const InlineButton: Story = {
render: (args) => ({ render: (args) => ({
props: args, props: args,
template: ` template: `
<auth-input-password [btnBlock]="false" [masterPasswordPolicyOptions]="masterPasswordPolicyOptions"></auth-input-password> <auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[inlineButtons]="true"
></auth-input-password>
`,
}),
};
export const InlineButtons: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[secondaryButtonText]="{ key: 'cancel' }"
[inlineButtons]="true"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
></auth-input-password>
`, `,
}), }),
}; };

View File

@@ -2,10 +2,12 @@ import { MasterKey } from "@bitwarden/common/types/key";
import { PBKDF2KdfConfig } from "@bitwarden/key-management"; import { PBKDF2KdfConfig } from "@bitwarden/key-management";
export interface PasswordInputResult { export interface PasswordInputResult {
masterKey: MasterKey; newPassword: string;
masterKeyHash: string;
localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
hint: string; hint: string;
password: string; kdfConfig: PBKDF2KdfConfig;
masterKey: MasterKey;
serverMasterKeyHash: string;
localMasterKeyHash: string;
currentPassword?: string;
rotateUserKey?: boolean;
} }

View File

@@ -60,11 +60,11 @@ describe("DefaultRegistrationFinishService", () => {
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
passwordInputResult = { passwordInputResult = {
masterKey: masterKey, masterKey: masterKey,
masterKeyHash: "masterKeyHash", serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash", localMasterKeyHash: "localMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG, kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint", hint: "hint",
password: "password", newPassword: "password",
}; };
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
@@ -101,7 +101,7 @@ describe("DefaultRegistrationFinishService", () => {
expect.objectContaining({ expect.objectContaining({
email, email,
emailVerificationToken: emailVerificationToken, emailVerificationToken: emailVerificationToken,
masterPasswordHash: passwordInputResult.masterKeyHash, masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint, masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString, userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: { userAsymmetricKeys: {

View File

@@ -81,7 +81,7 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi
const registerFinishRequest = new RegisterFinishRequest( const registerFinishRequest = new RegisterFinishRequest(
email, email,
passwordInputResult.masterKeyHash, passwordInputResult.serverMasterKeyHash,
passwordInputResult.hint, passwordInputResult.hint,
encryptedUserKey, encryptedUserKey,
userAsymmetricKeysRequest, userAsymmetricKeysRequest,

View File

@@ -5,8 +5,9 @@
<auth-input-password <auth-input-password
*ngIf="!loading" *ngIf="!loading"
[email]="email" [email]="email"
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions" [masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[loading]="submitting" [loading]="submitting"
[primaryButtonText]="{ key: 'createAccount' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)" (onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
[buttonText]="'createAccount' | i18n"
></auth-input-password> ></auth-input-password>

View File

@@ -22,7 +22,10 @@ import {
PasswordLoginCredentials, PasswordLoginCredentials,
} from "../../../common"; } from "../../../common";
import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service"; import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service";
import { InputPasswordComponent } from "../../input-password/input-password.component"; import {
InputPasswordComponent,
InputPasswordFlow,
} from "../../input-password/input-password.component";
import { PasswordInputResult } from "../../input-password/password-input-result"; import { PasswordInputResult } from "../../input-password/password-input-result";
import { RegistrationFinishService } from "./registration-finish.service"; import { RegistrationFinishService } from "./registration-finish.service";
@@ -36,6 +39,8 @@ import { RegistrationFinishService } from "./registration-finish.service";
export class RegistrationFinishComponent implements OnInit, OnDestroy { export class RegistrationFinishComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
InputPasswordFlow = InputPasswordFlow;
loading = true; loading = true;
submitting = false; submitting = false;
email: string; email: string;
@@ -176,7 +181,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
try { try {
const credentials = new PasswordLoginCredentials( const credentials = new PasswordLoginCredentials(
this.email, this.email,
passwordInputResult.password, passwordInputResult.newPassword,
captchaBypassToken, captchaBypassToken,
null, null,
); );

View File

@@ -112,11 +112,11 @@ describe("DefaultSetPasswordJitService", () => {
passwordInputResult = { passwordInputResult = {
masterKey: masterKey, masterKey: masterKey,
masterKeyHash: "masterKeyHash", serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash", localMasterKeyHash: "localMasterKeyHash",
hint: "hint", hint: "hint",
kdfConfig: DEFAULT_KDF_CONFIG, kdfConfig: DEFAULT_KDF_CONFIG,
password: "password", newPassword: "password",
}; };
credentials = { credentials = {
@@ -131,7 +131,7 @@ describe("DefaultSetPasswordJitService", () => {
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject; userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
setPasswordRequest = new SetPasswordRequest( setPasswordRequest = new SetPasswordRequest(
passwordInputResult.masterKeyHash, passwordInputResult.serverMasterKeyHash,
protectedUserKey[1].encryptedString, protectedUserKey[1].encryptedString,
passwordInputResult.hint, passwordInputResult.hint,
orgSsoIdentifier, orgSsoIdentifier,

View File

@@ -44,7 +44,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
async setPassword(credentials: SetPasswordCredentials): Promise<void> { async setPassword(credentials: SetPasswordCredentials): Promise<void> {
const { const {
masterKey, masterKey,
masterKeyHash, serverMasterKeyHash,
localMasterKeyHash, localMasterKeyHash,
hint, hint,
kdfConfig, kdfConfig,
@@ -70,7 +70,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey); const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey);
const request = new SetPasswordRequest( const request = new SetPasswordRequest(
masterKeyHash, serverMasterKeyHash,
protectedUserKey[1].encryptedString, protectedUserKey[1].encryptedString,
hint, hint,
orgSsoIdentifier, orgSsoIdentifier,
@@ -92,7 +92,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId); await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
if (resetPasswordAutoEnroll) { if (resetPasswordAutoEnroll) {
await this.handleResetPasswordAutoEnroll(masterKeyHash, orgId, userId); await this.handleResetPasswordAutoEnroll(serverMasterKeyHash, orgId, userId);
} }
} }

View File

@@ -13,7 +13,8 @@
</app-callout> </app-callout>
<auth-input-password <auth-input-password
[buttonText]="'createAccount' | i18n" [inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[primaryButtonText]="{ key: 'createAccount' }"
[email]="email" [email]="email"
[loading]="submitting" [loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions" [masterPasswordPolicyOptions]="masterPasswordPolicyOptions"

View File

@@ -18,7 +18,10 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
// FIXME: remove `src` and fix import // FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { ToastService } from "../../../../components/src/toast"; import { ToastService } from "../../../../components/src/toast";
import { InputPasswordComponent } from "../input-password/input-password.component"; import {
InputPasswordComponent,
InputPasswordFlow,
} from "../input-password/input-password.component";
import { PasswordInputResult } from "../input-password/password-input-result"; import { PasswordInputResult } from "../input-password/password-input-result";
import { import {
@@ -33,6 +36,7 @@ import {
imports: [CommonModule, InputPasswordComponent, JslibModule], imports: [CommonModule, InputPasswordComponent, JslibModule],
}) })
export class SetPasswordJitComponent implements OnInit { export class SetPasswordJitComponent implements OnInit {
protected InputPasswordFlow = InputPasswordFlow;
protected email: string; protected email: string;
protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions; protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions;
protected orgId: string; protected orgId: string;

View File

@@ -6,7 +6,7 @@ import { PBKDF2KdfConfig } from "@bitwarden/key-management";
export interface SetPasswordCredentials { export interface SetPasswordCredentials {
masterKey: MasterKey; masterKey: MasterKey;
masterKeyHash: string; serverMasterKeyHash: string;
localMasterKeyHash: string; localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig; kdfConfig: PBKDF2KdfConfig;
hint: string; hint: string;