1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

refactor(emergency-access-takeover): [PM-18721][PM-21271] Integrate InputPasswordComponent in EmergencyAccessTakeoverDialogComponent (#14636)

Integrates the `InputPasswordComponent` within the `EmergencyAccessTakeoverDialogComponent`

Feature Flag: `PM16117_ChangeExistingPasswordRefactor`
This commit is contained in:
rr-bw
2025-06-24 09:41:20 -07:00
committed by GitHub
parent 67e55379d7
commit 4a06562f60
17 changed files with 871 additions and 302 deletions

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -87,13 +85,23 @@ export class EmergencyAccessService
}
/**
* Returns policies that apply to the grantor.
* Returns policies that apply to the grantor if the grantor is the owner of an org, otherwise returns null.
* Intended for grantee.
* @param id emergency access id
*
* @remarks
* The ONLY time the API call will return an array of policies is when the Grantor is the OWNER
* of an organization. In all other scenarios the server returns null. Even if the Grantor
* is the member of an org that has enforced MP policies, the server will still return null
* because in the Emergency Access Takeover process, the Grantor gets removed from the org upon
* takeover, and therefore the MP policies are irrelevant.
*
* The only scenario where a Grantor does NOT get removed from the org is when that Grantor is the
* OWNER of the org. In that case the server returns Grantor policies and we enforce them on the client.
*/
async getGrantorPolicies(id: string): Promise<Policy[]> {
const response = await this.emergencyAccessApiService.getEmergencyGrantorPolicies(id);
let policies: Policy[];
let policies: Policy[] = [];
if (response.data != null && response.data.length > 0) {
policies = response.data.map((policyResponse) => new Policy(new PolicyData(policyResponse)));
}
@@ -299,6 +307,10 @@ export class EmergencyAccessService
const encKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey, grantorUserKey);
if (encKey == null || !encKey[1].encryptedString) {
throw new Error("masterKeyEncryptedUserKey not found");
}
const request = new EmergencyAccessPasswordRequest();
request.newMasterPasswordHash = masterKeyHash;
request.key = encKey[1].encryptedString;
@@ -405,6 +417,15 @@ export class EmergencyAccessService
}
private async encryptKey(userKey: UserKey, publicKey: Uint8Array): Promise<EncryptedString> {
return (await this.encryptService.encapsulateKeyUnsigned(userKey, publicKey)).encryptedString;
const publicKeyEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
userKey,
publicKey,
);
if (publicKeyEncryptedUserKey == null || !publicKeyEncryptedUserKey.encryptedString) {
throw new Error("publicKeyEncryptedUserKey not found");
}
return publicKeyEncryptedUserKey.encryptedString;
}
}

View File

@@ -10,6 +10,8 @@ import { OrganizationManagementPreferencesService } from "@bitwarden/common/admi
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -34,6 +36,10 @@ import {
EmergencyAccessAddEditComponent,
EmergencyAccessAddEditDialogResult,
} from "./emergency-access-add-edit.component";
import {
EmergencyAccessTakeoverDialogComponent,
EmergencyAccessTakeoverDialogResultType,
} from "./takeover/emergency-access-takeover-dialog.component";
import {
EmergencyAccessTakeoverComponent,
EmergencyAccessTakeoverResultType,
@@ -69,6 +75,7 @@ export class EmergencyAccessComponent implements OnInit {
private toastService: ToastService,
private apiService: ApiService,
private accountService: AccountService,
private configService: ConfigService,
) {
this.canAccessPremium$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
@@ -285,6 +292,45 @@ export class EmergencyAccessComponent implements OnInit {
}
takeover = async (details: GrantorEmergencyAccess) => {
const changePasswordRefactorFlag = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
if (changePasswordRefactorFlag) {
if (!details || !details.email || !details.id) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("grantorDetailsNotFound"),
});
this.logService.error(
"Grantor details not found when attempting emergency access takeover",
);
return;
}
const grantorName = this.userNamePipe.transform(details);
const dialogRef = EmergencyAccessTakeoverDialogComponent.open(this.dialogService, {
data: {
grantorName,
grantorEmail: details.email,
emergencyAccessId: details.id,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === EmergencyAccessTakeoverDialogResultType.Done) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("passwordResetFor", grantorName),
});
}
return;
}
const dialogRef = EmergencyAccessTakeoverComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(details),

View File

@@ -0,0 +1,46 @@
<bit-dialog>
<span bitDialogTitle>
{{ "takeover" | i18n }}
<small class="tw-text-muted" *ngIf="dialogData.grantorName">{{ dialogData.grantorName }}</small>
</span>
<div bitDialogContent>
@if (initializing) {
<div class="tw-flex tw-items-center tw-justify-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
} @else {
<!-- TODO: PM-22237 -->
<!-- <bit-callout type="warning">{{
"emergencyAccessLoggedOutWarning" | i18n: dialogData.grantorName
}}</bit-callout> -->
<auth-input-password
[flow]="inputPasswordFlow"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
(isSubmitting)="handleIsSubmittingChange($event)"
></auth-input-password>
}
</div>
<ng-container bitDialogFooter>
<button
type="button"
bitButton
buttonType="primary"
[disabled]="submitting$ | async"
(click)="handlePrimaryButtonClick()"
>
{{ "save" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,160 @@
import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, ViewChild } from "@angular/core";
import { BehaviorSubject, combineLatest, firstValueFrom, map } from "rxjs";
import {
InputPasswordComponent,
InputPasswordFlow,
PasswordInputResult,
} from "@bitwarden/auth/angular";
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
ButtonModule,
CalloutModule,
DIALOG_DATA,
DialogConfig,
DialogModule,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { EmergencyAccessService } from "../../../emergency-access";
type EmergencyAccessTakeoverDialogData = {
grantorName: string;
grantorEmail: string;
/** Traces a unique emergency request */
emergencyAccessId: string;
};
export const EmergencyAccessTakeoverDialogResultType = {
Done: "done",
} as const;
export type EmergencyAccessTakeoverDialogResultType =
(typeof EmergencyAccessTakeoverDialogResultType)[keyof typeof EmergencyAccessTakeoverDialogResultType];
/**
* This component is used by a Grantee to take over emergency access of a Grantor's account
* by changing the Grantor's master password. It is displayed as a dialog when the Grantee
* clicks the "Takeover" button while on the `/settings/emergency-access` page (see `EmergencyAccessComponent`).
*
* @link https://bitwarden.com/help/emergency-access/
*/
@Component({
standalone: true,
selector: "auth-emergency-access-takeover-dialog",
templateUrl: "./emergency-access-takeover-dialog.component.html",
imports: [
ButtonModule,
CalloutModule,
CommonModule,
DialogModule,
I18nPipe,
InputPasswordComponent,
],
})
export class EmergencyAccessTakeoverDialogComponent implements OnInit {
@ViewChild(InputPasswordComponent)
inputPasswordComponent: InputPasswordComponent | undefined = undefined;
private parentSubmittingBehaviorSubject = new BehaviorSubject(false);
parentSubmitting$ = this.parentSubmittingBehaviorSubject.asObservable();
private childSubmittingBehaviorSubject = new BehaviorSubject(false);
childSubmitting$ = this.childSubmittingBehaviorSubject.asObservable();
submitting$ = combineLatest([this.parentSubmitting$, this.childSubmitting$]).pipe(
map(([parentIsSubmitting, childIsSubmitting]) => parentIsSubmitting || childIsSubmitting),
);
initializing = true;
inputPasswordFlow = InputPasswordFlow.ChangePasswordDelegation;
masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
constructor(
@Inject(DIALOG_DATA) protected dialogData: EmergencyAccessTakeoverDialogData,
private accountService: AccountService,
private dialogRef: DialogRef<EmergencyAccessTakeoverDialogResultType>,
private emergencyAccessService: EmergencyAccessService,
private i18nService: I18nService,
private logService: LogService,
private policyService: PolicyService,
private toastService: ToastService,
) {}
async ngOnInit() {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const grantorPolicies = await this.emergencyAccessService.getGrantorPolicies(
this.dialogData.emergencyAccessId,
);
this.masterPasswordPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(activeUserId, grantorPolicies),
);
this.initializing = false;
}
protected handlePrimaryButtonClick = async () => {
if (!this.inputPasswordComponent) {
throw new Error("InputPasswordComponent is not initialized");
}
await this.inputPasswordComponent.submit();
};
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.parentSubmittingBehaviorSubject.next(true);
try {
await this.emergencyAccessService.takeover(
this.dialogData.emergencyAccessId,
passwordInputResult.newPassword,
this.dialogData.grantorEmail,
);
} catch (e) {
this.logService.error(e);
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("unexpectedError"),
});
} finally {
this.parentSubmittingBehaviorSubject.next(false);
}
this.dialogRef.close(EmergencyAccessTakeoverDialogResultType.Done);
}
protected handleIsSubmittingChange(isSubmitting: boolean) {
this.childSubmittingBehaviorSubject.next(isSubmitting);
}
/**
* Strongly typed helper to open an EmergencyAccessTakeoverDialogComponent
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param dialogConfig Configuration for the dialog
*/
static open = (
dialogService: DialogService,
dialogConfig: DialogConfig<
EmergencyAccessTakeoverDialogData,
DialogRef<EmergencyAccessTakeoverDialogResultType, unknown>
>,
) => {
return dialogService.open<
EmergencyAccessTakeoverDialogResultType,
EmergencyAccessTakeoverDialogData
>(EmergencyAccessTakeoverDialogComponent, dialogConfig);
};
}

View File

@@ -52,7 +52,7 @@ export type InitiationPath =
export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
inputPasswordFlow = InputPasswordFlow.AccountRegistration;
inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAccountRegistration;
initializing = true;
/** Password Manager or Secrets Manager */

View File

@@ -5370,6 +5370,9 @@
"emergencyRejected": {
"message": "Emergency access rejected"
},
"grantorDetailsNotFound": {
"message": "Grantor details not found"
},
"passwordResetFor": {
"message": "Password reset for $USER$. You can now login using the new password.",
"placeholders": {
@@ -5773,12 +5776,24 @@
}
}
},
"emergencyAccessLoggedOutWarning": {
"message": "Proceeding will log $NAME$ out of their current session, requiring them to log back in. Active sessions on other devices may continue to remain active for up to one hour.",
"placeholders": {
"name": {
"content": "$1",
"example": "John Smith"
}
}
},
"thisUser": {
"message": "this user"
},
"resetPasswordMasterPasswordPolicyInEffect": {
"message": "One or more organization policies require the master password to meet the following requirements:"
},
"changePasswordDelegationMasterPasswordPolicyInEffect": {
"message": "One or more organization policies require the master password to meet the following requirements:"
},
"resetPasswordSuccess": {
"message": "Password reset success!"
},

View File

@@ -71,8 +71,11 @@ export class ChangePasswordComponent implements OnInit {
throw new Error("activeAccount not found");
}
if (passwordInputResult.currentPassword == null) {
throw new Error("currentPassword not found");
if (
passwordInputResult.currentPassword == null ||
passwordInputResult.newPasswordHint == null
) {
throw new Error("currentPassword or newPasswordHint not found");
}
await this.syncService.fullSync(true);

View File

@@ -116,7 +116,7 @@ describe("DefaultChangePasswordService", () => {
// Assert
await expect(testFn).rejects.toThrow(
"currentMasterKey or currentServerMasterKeyHash not found",
"invalid PasswordInputResult credentials, could not change password",
);
});
@@ -130,7 +130,7 @@ describe("DefaultChangePasswordService", () => {
// Assert
await expect(testFn).rejects.toThrow(
"currentMasterKey or currentServerMasterKeyHash not found",
"invalid PasswordInputResult credentials, could not change password",
);
});

View File

@@ -26,8 +26,14 @@ export class DefaultChangePasswordService implements ChangePasswordService {
if (!userId) {
throw new Error("userId not found");
}
if (!passwordInputResult.currentMasterKey || !passwordInputResult.currentServerMasterKeyHash) {
throw new Error("currentMasterKey or currentServerMasterKeyHash not found");
if (
!passwordInputResult.currentMasterKey ||
!passwordInputResult.currentServerMasterKeyHash ||
!passwordInputResult.newMasterKey ||
!passwordInputResult.newServerMasterKeyHash ||
passwordInputResult.newPasswordHint == null
) {
throw new Error("invalid PasswordInputResult credentials, could not change password");
}
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(

View File

@@ -1,6 +1,11 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<auth-password-callout
*ngIf="masterPasswordPolicyOptions"
[message]="
flow === InputPasswordFlow.ChangePasswordDelegation
? 'changePasswordDelegationMasterPasswordPolicyInEffect'
: 'masterPasswordPolicyInEffect'
"
[policy]="masterPasswordPolicyOptions"
></auth-password-callout>
@@ -35,6 +40,22 @@
type="password"
formControlName="newPassword"
/>
<button
*ngIf="flow === InputPasswordFlow.ChangePasswordDelegation"
type="button"
bitIconButton="bwi-generate"
bitSuffix
[appA11yTitle]="'generatePassword' | i18n"
(click)="generatePassword()"
></button>
<button
*ngIf="flow === InputPasswordFlow.ChangePasswordDelegation"
type="button"
bitSuffix
bitIconButton="bwi-clone"
appA11yTitle="{{ 'copyPassword' | i18n }}"
(click)="copy()"
></button>
<button
type="button"
bitIconButton
@@ -42,7 +63,7 @@
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<bit-hint>
<bit-hint *ngIf="flow !== InputPasswordFlow.ChangePasswordDelegation">
<span class="tw-font-bold">{{ "important" | i18n }} </span>
{{ "masterPassImportant" | i18n }}
{{ minPasswordLengthMsg }}.
@@ -74,26 +95,32 @@
></button>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "masterPassHintLabel" | i18n }}</bit-label>
<input id="input-password-form_new-password-hint" bitInput formControlName="newPasswordHint" />
<bit-hint>
{{
"masterPassHintText"
| i18n: formGroup.value.newPasswordHint.length : maxHintLength.toString()
}}
</bit-hint>
</bit-form-field>
<ng-container *ngIf="flow !== InputPasswordFlow.ChangePasswordDelegation">
<bit-form-field>
<bit-label>{{ "masterPassHintLabel" | i18n }}</bit-label>
<input
id="input-password-form_new-password-hint"
bitInput
formControlName="newPasswordHint"
/>
<bit-hint>
{{
"masterPassHintText"
| i18n: formGroup.value.newPasswordHint.length.toString() : maxHintLength.toString()
}}
</bit-hint>
</bit-form-field>
<bit-form-control>
<input
id="input-password-form_check-for-breaches"
type="checkbox"
bitCheckbox
formControlName="checkForBreaches"
/>
<bit-label>{{ "checkForBreaches" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
id="input-password-form_check-for-breaches"
type="checkbox"
bitCheckbox
formControlName="checkForBreaches"
/>
<bit-label>{{ "checkForBreaches" | i18n }}</bit-label>
</bit-form-control>
</ng-container>
<bit-form-control *ngIf="flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation">
<input
@@ -116,7 +143,11 @@
</bit-label>
</bit-form-control>
<div class="tw-flex tw-gap-2" [ngClass]="inlineButtons ? 'tw-flex-row' : 'tw-flex-col'">
<div
*ngIf="flow !== InputPasswordFlow.ChangePasswordDelegation"
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>

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators, FormControl } from "@angular/forms";
import { firstValueFrom } from "rxjs";
@@ -13,6 +13,7 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
@@ -30,6 +31,7 @@ import {
ToastService,
Translation,
} from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
DEFAULT_KDF_CONFIG,
KdfConfig,
@@ -53,31 +55,46 @@ import { PasswordInputResult } from "./password-input-result";
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum InputPasswordFlow {
/**
* Form elements displayed:
* - [Input] New password
* - [Input] New password confirm
* - [Input] New password hint
* - [Checkbox] Check for breaches
* Form Fields: `[newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]`
*
* Note: this flow does not receive an active account `userId` as an `@Input`
*/
SetInitialPasswordAccountRegistration,
/**
* Form Fields: `[newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]`
*/
AccountRegistration, // important: this flow does not involve an activeAccount/userId
SetInitialPasswordAuthedUser,
/*
* All form elements above, plus: [Input] Current password (as the first element in the UI)
/**
* Form Fields: `[currentPassword, newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]`
*/
ChangePassword,
/**
* All form elements above, plus: [Checkbox] Rotate account encryption key (as the last element in the UI)
* Form Fields: `[currentPassword, newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches, rotateUserKey]`
*/
ChangePasswordWithOptionalUserKeyRotation,
/**
* This flow is used when a user changes the password for another user's account, such as:
* - Emergency Access Takeover
* - Account Recovery
*
* Since both of those processes use a dialog, the `InputPasswordComponent` will not display
* buttons for `ChangePasswordDelegation` because the dialog will have its own buttons.
*
* Form Fields: `[newPassword, newPasswordConfirm]`
*
* Note: this flow does not receive an active account `userId` or `email` as `@Input`s
*/
ChangePasswordDelegation,
}
interface InputPasswordForm {
currentPassword?: FormControl<string>;
newPassword: FormControl<string>;
newPasswordConfirm: FormControl<string>;
newPasswordHint: FormControl<string>;
checkForBreaches: FormControl<boolean>;
newPasswordHint?: FormControl<string>;
currentPassword?: FormControl<string>;
checkForBreaches?: FormControl<boolean>;
rotateUserKey?: FormControl<boolean>;
}
@@ -91,20 +108,25 @@ interface InputPasswordForm {
FormFieldModule,
IconButtonModule,
InputModule,
ReactiveFormsModule,
SharedModule,
JslibModule,
PasswordCalloutComponent,
PasswordStrengthV2Component,
JslibModule,
ReactiveFormsModule,
SharedModule,
],
})
export class InputPasswordComponent implements OnInit {
@ViewChild(PasswordStrengthV2Component) passwordStrengthComponent:
| PasswordStrengthV2Component
| undefined = undefined;
@Output() onPasswordFormSubmit = new EventEmitter<PasswordInputResult>();
@Output() onSecondaryButtonClick = new EventEmitter<void>();
@Output() isSubmitting = new EventEmitter<boolean>();
@Input({ required: true }) flow!: InputPasswordFlow;
@Input({ required: true, transform: (val: string) => val.trim().toLowerCase() }) email!: string;
@Input({ transform: (val: string) => val?.trim().toLowerCase() }) email?: string;
@Input() userId?: UserId;
@Input() loading = false;
@Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
@@ -132,11 +154,6 @@ export class InputPasswordComponent implements OnInit {
Validators.minLength(this.minPasswordLength),
]),
newPasswordConfirm: this.formBuilder.nonNullable.control("", Validators.required),
newPasswordHint: this.formBuilder.nonNullable.control("", [
Validators.minLength(this.minHintLength),
Validators.maxLength(this.maxHintLength),
]),
checkForBreaches: this.formBuilder.nonNullable.control(true),
},
{
validators: [
@@ -146,12 +163,6 @@ export class InputPasswordComponent implements OnInit {
"newPasswordConfirm",
this.i18nService.t("masterPassDoesntMatch"),
),
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"newPassword",
"newPasswordHint",
this.i18nService.t("hintEqualsPassword"),
),
],
},
);
@@ -176,9 +187,11 @@ export class InputPasswordComponent implements OnInit {
private kdfConfigService: KdfConfigService,
private keyService: KeyService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
private toastService: ToastService,
private validationService: ValidationService,
) {}
ngOnInit(): void {
@@ -187,6 +200,27 @@ export class InputPasswordComponent implements OnInit {
}
private addFormFieldsIfNecessary() {
if (this.flow !== InputPasswordFlow.ChangePasswordDelegation) {
this.formGroup.addControl(
"newPasswordHint",
this.formBuilder.nonNullable.control("", [
Validators.minLength(this.minHintLength),
Validators.maxLength(this.maxHintLength),
]),
);
this.formGroup.addValidators([
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"newPassword",
"newPasswordHint",
this.i18nService.t("hintEqualsPassword"),
),
]);
this.formGroup.addControl("checkForBreaches", this.formBuilder.nonNullable.control(true));
}
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
@@ -227,160 +261,201 @@ export class InputPasswordComponent implements OnInit {
}
}
protected submit = async () => {
this.verifyFlowAndUserId();
submit = async () => {
try {
this.isSubmitting.emit(true);
this.formGroup.markAllAsTouched();
this.verifyFlow();
if (this.formGroup.invalid) {
this.showErrorSummary = true;
return;
}
this.formGroup.markAllAsTouched();
if (!this.email) {
throw new Error("Email is required to create master key.");
}
const currentPassword = this.formGroup.controls.currentPassword?.value ?? "";
const newPassword = this.formGroup.controls.newPassword.value;
const newPasswordHint = this.formGroup.controls.newPasswordHint.value;
const checkForBreaches = this.formGroup.controls.checkForBreaches.value;
// 1. Determine kdfConfig
if (this.flow === InputPasswordFlow.AccountRegistration) {
this.kdfConfig = DEFAULT_KDF_CONFIG;
} else {
if (!this.userId) {
throw new Error("userId not passed down");
}
this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
}
if (this.kdfConfig == null) {
throw new Error("KdfConfig is required to create master key.");
}
// 2. Verify current password is correct (if necessary)
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
const currentPasswordVerified = await this.verifyCurrentPassword(
currentPassword,
this.kdfConfig,
);
if (!currentPasswordVerified) {
if (this.formGroup.invalid) {
this.showErrorSummary = true;
return;
}
const currentPassword = this.formGroup.controls.currentPassword?.value ?? "";
const newPassword = this.formGroup.controls.newPassword.value;
const newPasswordHint = this.formGroup.controls.newPasswordHint?.value ?? "";
const checkForBreaches = this.formGroup.controls.checkForBreaches?.value ?? true;
if (this.flow === InputPasswordFlow.ChangePasswordDelegation) {
await this.handleChangePasswordDelegationFlow(newPassword);
return;
}
if (!this.email) {
throw new Error("Email is required to create master key.");
}
// 1. Determine kdfConfig
if (this.flow === InputPasswordFlow.SetInitialPasswordAccountRegistration) {
this.kdfConfig = DEFAULT_KDF_CONFIG;
} else {
if (!this.userId) {
throw new Error("userId not passed down");
}
this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
}
if (this.kdfConfig == null) {
throw new Error("KdfConfig is required to create master key.");
}
// 2. Verify current password is correct (if necessary)
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
const currentPasswordVerified = await this.verifyCurrentPassword(
currentPassword,
this.kdfConfig,
);
if (!currentPasswordVerified) {
return;
}
}
// 3. Verify new password
const newPasswordVerified = await this.verifyNewPassword(
newPassword,
this.passwordStrengthScore,
checkForBreaches,
);
if (!newPasswordVerified) {
return;
}
// 4. Create cryptographic keys and build a PasswordInputResult object
const newMasterKey = await this.keyService.makeMasterKey(
newPassword,
this.email,
this.kdfConfig,
);
const newServerMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const newLocalMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
newMasterKey,
HashPurpose.LocalAuthorization,
);
const passwordInputResult: PasswordInputResult = {
newPassword,
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig: this.kdfConfig,
};
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
const currentMasterKey = await this.keyService.makeMasterKey(
currentPassword,
this.email,
this.kdfConfig,
);
const currentServerMasterKeyHash = await this.keyService.hashMasterKey(
currentPassword,
currentMasterKey,
HashPurpose.ServerAuthorization,
);
const currentLocalMasterKeyHash = await this.keyService.hashMasterKey(
currentPassword,
currentMasterKey,
HashPurpose.LocalAuthorization,
);
passwordInputResult.currentPassword = currentPassword;
passwordInputResult.currentMasterKey = currentMasterKey;
passwordInputResult.currentServerMasterKeyHash = currentServerMasterKeyHash;
passwordInputResult.currentLocalMasterKeyHash = currentLocalMasterKeyHash;
}
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value;
}
// 5. Emit cryptographic keys and other password related properties
this.onPasswordFormSubmit.emit(passwordInputResult);
} catch (e) {
this.validationService.showError(e);
} finally {
this.isSubmitting.emit(false);
}
};
/**
* We cannot mark the `userId` or `email` `@Input`s as required because some flows
* require them, and some do not. This method enforces that:
* - Certain flows MUST have a `userId` and/or `email` passed down
* - Certain flows must NOT have a `userId` and/or `email` passed down
*/
private verifyFlow() {
/** UserId checks */
// These flows require that an active account userId must NOT be passed down
if (
this.flow === InputPasswordFlow.SetInitialPasswordAccountRegistration ||
this.flow === InputPasswordFlow.ChangePasswordDelegation
) {
if (this.userId) {
throw new Error("There should be no active account userId passed down in a this flow.");
}
}
// 3. Verify new password
// All other flows require that an active account userId MUST be passed down
if (
this.flow !== InputPasswordFlow.SetInitialPasswordAccountRegistration &&
this.flow !== InputPasswordFlow.ChangePasswordDelegation
) {
if (!this.userId) {
throw new Error("This flow requires that an active account userId be passed down.");
}
}
/** Email checks */
// This flow requires that an email must NOT be passed down
if (this.flow === InputPasswordFlow.ChangePasswordDelegation) {
if (this.email) {
throw new Error("There should be no email passed down in this flow.");
}
}
// All other flows require that an email MUST be passed down
if (this.flow !== InputPasswordFlow.ChangePasswordDelegation) {
if (!this.email) {
throw new Error("This flow requires that an email be passed down.");
}
}
}
private async handleChangePasswordDelegationFlow(newPassword: string) {
const newPasswordVerified = await this.verifyNewPassword(
newPassword,
this.passwordStrengthScore,
checkForBreaches,
false,
);
if (!newPasswordVerified) {
return;
}
// 4. Create cryptographic keys and build a PasswordInputResult object
const newMasterKey = await this.keyService.makeMasterKey(
newPassword,
this.email,
this.kdfConfig,
);
const newServerMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const newLocalMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
newMasterKey,
HashPurpose.LocalAuthorization,
);
const passwordInputResult: PasswordInputResult = {
newPassword,
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig: this.kdfConfig,
};
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
const currentMasterKey = await this.keyService.makeMasterKey(
currentPassword,
this.email,
this.kdfConfig,
);
const currentServerMasterKeyHash = await this.keyService.hashMasterKey(
currentPassword,
currentMasterKey,
HashPurpose.ServerAuthorization,
);
const currentLocalMasterKeyHash = await this.keyService.hashMasterKey(
currentPassword,
currentMasterKey,
HashPurpose.LocalAuthorization,
);
passwordInputResult.currentPassword = currentPassword;
passwordInputResult.currentMasterKey = currentMasterKey;
passwordInputResult.currentServerMasterKeyHash = currentServerMasterKeyHash;
passwordInputResult.currentLocalMasterKeyHash = currentLocalMasterKeyHash;
}
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value;
}
// 5. Emit cryptographic keys and other password related properties
this.onPasswordFormSubmit.emit(passwordInputResult);
};
/**
* This method prevents a dev from passing down the wrong `InputPasswordFlow`
* from the parent component or from failing to pass down a `userId` for flows
* that require it.
*
* We cannot mark the `userId` `@Input` as required because in an account registration
* flow we will not have an active account `userId` to pass down.
*/
private verifyFlowAndUserId() {
/**
* There can be no active account (and thus no userId) in an account registration
* flow. If there is a userId, it means the dev passed down the wrong InputPasswordFlow
* from the parent component.
*/
if (this.flow === InputPasswordFlow.AccountRegistration) {
if (this.userId) {
throw new Error(
"There can be no userId in an account registration flow. Please pass down the appropriate InputPasswordFlow from the parent component.",
);
}
}
/**
* There MUST be an active account (and thus a userId) in all other flows.
* If no userId is passed down, it means the dev either:
* (a) passed down the wrong InputPasswordFlow, or
* (b) passed down the correct InputPasswordFlow but failed to pass down a userId
*/
if (this.flow !== InputPasswordFlow.AccountRegistration) {
if (!this.userId) {
throw new Error("The selected InputPasswordFlow requires that a userId be passed down");
}
}
}
/**
@@ -391,16 +466,19 @@ export class InputPasswordComponent implements OnInit {
currentPassword: string,
kdfConfig: KdfConfig,
): Promise<boolean> {
if (!this.email) {
throw new Error("Email is required to verify current password.");
}
if (!this.userId) {
throw new Error("userId is required to verify current password.");
}
const currentMasterKey = await this.keyService.makeMasterKey(
currentPassword,
this.email,
kdfConfig,
);
if (!this.userId) {
throw new Error("userId not passed down");
}
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
currentMasterKey,
this.userId,
@@ -549,4 +627,33 @@ export class InputPasswordComponent implements OnInit {
protected getPasswordStrengthScore(score: PasswordStrengthScore) {
this.passwordStrengthScore = score;
}
protected async generatePassword() {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
this.formGroup.patchValue({
newPassword: await this.passwordGenerationService.generatePassword(options),
});
if (!this.passwordStrengthComponent) {
throw new Error("PasswordStrengthComponent is not initialized");
}
this.passwordStrengthComponent.updatePasswordStrength(
this.formGroup.controls.newPassword.value,
);
}
protected copy() {
const value = this.formGroup.value.newPassword;
if (value == null) {
return;
}
this.platformUtilsService.copyToClipboard(value, { window: window });
this.toastService.showToast({
variant: "info",
title: "",
message: this.i18nService.t("valueCopied", this.i18nService.t("password")),
});
}
}

View File

@@ -6,14 +6,20 @@ import * as stories from "./input-password.stories.ts";
# InputPassword Component
The `InputPasswordComponent` allows a user to enter master password related credentials. On form
submission, the component creates cryptographic properties (`newMasterKey`,
`newServerMasterKeyHash`, etc.) and emits those properties to the parent (along with the other
values defined in `PasswordInputResult`).
The `InputPasswordComponent` allows a user to enter master password related credentials.
Specifically, it does the following:
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
the parent component to act on those values as needed.
1. Displays form fields in the UI
2. Validates form fields
3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`,
`newServerMasterKeyHash`, etc.)
4. Emits the generated properties to the parent component
The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our
form UI and validation logic consistent. As such, it is intended for re-use in different set/change
password scenarios throughout the Bitwarden application. It is mostly presentational and simply
emits values rather than acting on them itself. It is the job of the parent component to act on
those values as needed.
<br />
@@ -22,11 +28,13 @@ the parent component to act on those values as needed.
- [@Inputs](#inputs)
- [@Outputs](#outputs)
- [The InputPasswordFlow](#the-inputpasswordflow)
- [Use Cases](#use-cases)
- [HTML - Form Fields](#html---form-fields)
- [TypeScript - Credential Generation](#typescript---credential-generation)
- [Difference between AccountRegistration and SetInitialPasswordAuthedUser](#difference-between-accountregistration-and-setinitialpasswordautheduser)
- [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser)
- [Validation](#validation)
- [Submit Logic](#submit-logic)
- [Submitting From a Parent Dialog Component](#submitting-from-a-parent-dialog-component)
- [Example](#example)
<br />
@@ -37,12 +45,24 @@ the parent component to act on those values as needed.
- `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine
which form input elements will be displayed in the UI and which cryptographic keys will be created
and emitted.
- `email` - the parent component must provide an email so that the `InputPasswordComponent` can
create a master key.
and emitted. [Click here](#the-inputpasswordflow) to learn more about the different
`InputPasswordFlow` options.
**Optional (sometimes)**
These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs`
are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that
the `email` and/or `userId` is present in certain flows, while not present in other flows.
- `email` - allows the `InputPasswordComponent` to generate a master key
- `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`,
verify that a current password is correct, and perform validation prior to user key rotation on
the parent
**Optional**
These `@Inputs` are truly 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` is true.
@@ -57,6 +77,7 @@ the parent component to act on those values as needed.
## `@Output()`'s
- `onPasswordFormSubmit` - on form submit, emits a `PasswordInputResult` object
([see more below](#submit-logic)).
- `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.)
@@ -66,79 +87,100 @@ the parent component to act on those values as needed.
## The `InputPasswordFlow`
The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the
credential generation logic of the component.
credential generation logic of the component. It is important for the dev to understand when to use
each flow.
<br />
### Use Cases
**`SetInitialPasswordAccountRegistration`**
Used in scenarios where we have no existing user, and thus NO active account `userId`:
- Standard Account Registration
- Email Invite Account Registration
- Trial Initiation Account registration<br /><br />
**`SetInitialPasswordAuthedUser`**
Used in scenarios where we do have an existing and authed user, and thus an active account `userId`:
- A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set
their initial password
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a
starting role that requires them to have/set their initial password
- A note on JIT provisioned user flows:
- Even though a JIT provisioned user is a brand-new user who was “just” created, we consider
them to be an “existing authed user” _from the perspective of the set-password flow_. This is
because at the time they set their initial password, their account already exists in the
database (before setting their password) and they have already authenticated via SSO.
- The same is not true in the account registration flows above&mdash;that is, during account
registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their
initial password, their account does not yet exist in the database, and will only be created
once they set an initial password.
- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now
requires them to have/set their initial password
- An existing user logs in after their org admin offboarded the org from TDE, and the user must now
have/set their initial password<br /><br />
**`ChangePassword`**
Used in scenarios where we simply want to offer the user the ability to change their password:
- User clicks an org email invite link an logs in with their password which does not meet the org's
policy requirements
- User logs in with password that does not meet the org's policy requirements
- User logs in after their password was reset via Account Recovery (and now they must change their
password)<br /><br />
**`ChangePasswordWithOptionalUserKeyRotation`**
Used in scenarios where we want to offer users the additional option of rotating their user key:
- Account Settings (Web) - change password screen
Note that the user key rotation itself does not happen on the `InputPasswordComponent`, but rather
on the parent component. The `InputPasswordComponent` simply emits a boolean value that indicates
whether or not the user key should be rotated.<br /><br />
**`ChangePasswordDelegation`**
Used in scenarios where one user changes the password for another user's account:
- Emergency Access Takeover
- Account Recovery<br /><br />
### HTML - Form Fields
The `InputPasswordFlow` determines which form fields get displayed in the UI.
**`InputPasswordFlow.AccountRegistration`** and **`InputPasswordFlow.SetInitialPasswordAuthedUser`**
- Input: New password
- 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)
Click through the individual Stories in Storybook to see how the `InputPassswordFlow` determines
which form field UI elements get displayed.
<br />
### TypeScript - Credential Generation
- The `AccountRegistration` and `SetInitialPasswordAuthedUser` flows involve a user setting their
password for the first time. Therefore on submit the component will only generate new credentials
(`newMasterKey`) and not current credentials (`currentMasterKey`).
- The `ChangePassword` and `ChangePasswordWithOptionalUserKeyRotation` flows both require the user
to enter a current password along with a new password. Therefore on submit the component will
generate current credentials (`currentMasterKey`) along with new credentials (`newMasterKey`).
- **`SetInitialPasswordAccountRegistration`** and **`SetInitialPasswordAuthedUser`**
- These flows involve a user setting their password for the first time. Therefore on submit the
component will only generate new credentials (`newMasterKey`) and not current credentials
(`currentMasterKey`).<br /><br />
- **`ChangePassword`** and **`ChangePasswordWithOptionalUserKeyRotation`**
- These flows both require the user to enter a current password along with a new password.
Therefore on submit the component will generate current credentials (`currentMasterKey`) along
with new credentials (`newMasterKey`).<br /><br />
- **`ChangePasswordDelegation`**
- This flow does not generate any credentials, but simply validates the new password and emits it
up to the parent.
<br />
### Difference between `AccountRegistration` and `SetInitialPasswordAuthedUser`
### Difference between `SetInitialPasswordAccountRegistration` and `SetInitialPasswordAuthedUser`
These two flows are similar in that they display the same form fields and only generate new
credentials, but we need to keep them separate for the following reasons:
- `AccountRegistration` involves scenarios where we have no existing user, and **thus NO active
account `userId`**:
- Standard Account Registration
- Email Invite Account Registration
- Trial Initiation Account Registration
<br />
- `SetInitialPasswordAccountRegistration` involves scenarios where we have no existing user, and
**thus NO active account `userId`**:
- `SetInitialPasswordAuthedUser` involves scenarios where we do have an existing and authed user,
and **thus an active account `userId`**:
- A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set
their initial password
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a
starting role that requires them to have/set their initial password
- A note on JIT provisioned user flows:
- Even though a JIT provisioned user is a brand-new user who was “just” created, we consider
them to be an “existing authed user” _from the perspective of the set-password flow_. This
is because at the time they set their initial password, their account already exists in the
database (before setting their password) and they have already authenticated via SSO.
- The same is not true in the account registration flows above&mdash;that is, during account
registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set
their initial password, their account does not yet exist in the database, and will only be
created once they set an initial password.
- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now
requires them to have/set their initial password
- An existing user logs in after their org admin offboarded the org from TDE, and the user must
now have/set their initial password
The presence or absence of an active account `userId` is important because it determines how we get
the correct `kdfConfig` prior to key generation:
@@ -148,12 +190,12 @@ the correct `kdfConfig` prior to key generation:
`userId`
That said, we cannot mark the `userId` as a required via `@Input({ required: true })` because
`AccountRegistration` flows will not have a `userId`. But we still want to require a `userId` in a
`SetInitialPasswordAuthedUser` flow. Therefore the `InputPasswordComponent` has init logic that
ensures the following:
`SetInitialPasswordAccountRegistration` flows will not have a `userId`. But we still want to require
a `userId` in a `SetInitialPasswordAuthedUser` flow. Therefore the `InputPasswordComponent` has init
logic that ensures the following:
- If the passed down flow is `AccountRegistration`, require that the parent **MUST NOT** have passed
down a `userId`
- If the passed down flow is `SetInitialPasswordAccountRegistration`, require that the parent **MUST
NOT** have passed down a `userId`
- If the passed down flow is `SetInitialPasswordAuthedUser` require that the parent must also have
passed down a `userId`
@@ -169,11 +211,6 @@ Form validators ensure that:
- The new password and confirmed new password are the same
- The new password and password hint are NOT the same
Additional submit logic validation ensures that:
- The new password adheres to any enforced master password policy options (that were passed down
from the parent)
<br />
## Submit Logic
@@ -182,9 +219,10 @@ When the form is submitted, the `InputPasswordComponent` does the following in o
1. Verifies inputs:
- Checks that the current password is correct (if it was required in the flow)
- Checks if the new password is found in a breach and warns the user if so (if the user selected
the checkbox)
- Checks that the new password meets any master password policy requirements enforced by an org
- Checks that the new password is not weak or found in any breaches (if the user selected the
checkbox)
- Checks that the new password adheres to any enforced master password policies that were
optionally passed down by the parent
2. Uses the form inputs to create cryptographic properties (`newMasterKey`,
`newServerMasterKeyHash`, etc.)
3. Emits those cryptographic properties up to the parent (along with other values defined in
@@ -192,23 +230,83 @@ When the form is submitted, the `InputPasswordComponent` does the following in o
```typescript
export interface PasswordInputResult {
// Properties starting with "current..." are included if the flow is ChangePassword or ChangePasswordWithOptionalUserKeyRotation
currentPassword?: string;
currentMasterKey?: MasterKey;
currentServerMasterKeyHash?: string;
currentLocalMasterKeyHash?: string;
newPassword: string;
newPasswordHint: string;
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
newPasswordHint?: string;
newMasterKey?: MasterKey;
newServerMasterKeyHash?: string;
newLocalMasterKeyHash?: string;
kdfConfig: KdfConfig;
rotateUserKey?: boolean; // included if the flow is ChangePasswordWithOptionalUserKeyRotation
kdfConfig?: KdfConfig;
rotateUserKey?: boolean;
}
```
## Submitting From a Parent Dialog Component
Some of our set/change password flows use dialogs, such as Emergency Access Takeover and Account
Recovery. These are covered by the `ChangePasswordDelegation` flow. Because dialogs have their own
buttons, we don't want to display an additional Submit button in the `InputPasswordComponent` when
embedded in a dialog.
Therefore we do the following:
- The `InputPasswordComponent` hides the button in the UI and exposes its `submit()` method as a
public method.
- The parent dialog component can then access this method via `@ViewChild()`.
- When the user clicks the primary button on the parent dialog, we call the `submit()` method on the
`InputPasswordComponent`.
```html
<!-- emergency-access-takeover-dialog.component.html -->
<bit-dialog dialogSize="large">
<span bitDialogTitle><!-- ... --></span>
<div bitDialogContent>
<auth-input-password
[flow]="inputPasswordFlow"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
></auth-input-password>
</div>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="handlePrimaryButtonClick()">
{{ "save" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
```
```typescript
// emergency-access-takeover-dialog.component.ts
export class EmergencyAccessTakeoverDialogComponent implements OnInit {
@ViewChild(InputPasswordComponent)
inputPasswordComponent: InputPasswordComponent;
// ...
handlePrimaryButtonClick = async () => {
await this.inputPasswordComponent.submit();
};
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
// ... run logic that handles the `PasswordInputResult` object emission
}
}
```
<br />
# Example
**`InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation`**

View File

@@ -11,12 +11,14 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
// FIXME: remove `/apps` import from `/libs`
@@ -73,6 +75,7 @@ export default {
provide: PlatformUtilsService,
useValue: {
launchUri: () => Promise.resolve(true),
copyToClipboard: () => true,
},
},
{
@@ -125,16 +128,31 @@ export default {
showToast: action("ToastService.showToast"),
} as Partial<ToastService>,
},
{
provide: PasswordGenerationServiceAbstraction,
useValue: {
getOptions: () => ({}),
generatePassword: () => "generated-password",
},
},
{
provide: ValidationService,
useValue: {
showError: () => ["validation error"],
},
},
],
}),
],
args: {
InputPasswordFlow: {
AccountRegistration: InputPasswordFlow.AccountRegistration,
SetInitialPasswordAccountRegistration:
InputPasswordFlow.SetInitialPasswordAccountRegistration,
SetInitialPasswordAuthedUser: InputPasswordFlow.SetInitialPasswordAuthedUser,
ChangePassword: InputPasswordFlow.ChangePassword,
ChangePasswordWithOptionalUserKeyRotation:
InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation,
ChangePasswordDelegation: InputPasswordFlow.ChangePasswordDelegation,
},
userId: "1" as UserId,
email: "user@email.com",
@@ -154,12 +172,12 @@ export default {
type Story = StoryObj<InputPasswordComponent>;
export const AccountRegistration: Story = {
export const SetInitialPasswordAccountRegistration: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
></auth-input-password>
`,
@@ -205,14 +223,24 @@ export const ChangePasswordWithOptionalUserKeyRotation: Story = {
}),
};
export const ChangePasswordDelegation: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password [flow]="InputPasswordFlow.ChangePasswordDelegation"></auth-input-password>
<br />
<div>Note: no buttons here as this flow is expected to be used in a dialog, which will have its own buttons</div>
`,
}),
};
export const WithPolicies: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.SetInitialPasswordAuthedUser"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
[userId]="userId"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
></auth-input-password>
`,
@@ -224,7 +252,7 @@ export const SecondaryButton: Story = {
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'cancel' }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
@@ -238,7 +266,7 @@ export const SecondaryButtonWithPlaceHolderText: Story = {
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'backTo', placeholders: ['homepage'] }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
@@ -252,7 +280,7 @@ export const InlineButton: Story = {
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
[inlineButtons]="true"
></auth-input-password>
@@ -265,7 +293,7 @@ export const InlineButtons: Story = {
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[flow]="InputPasswordFlow.SetInitialPasswordAccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'cancel' }"
[inlineButtons]="true"

View File

@@ -8,11 +8,11 @@ export interface PasswordInputResult {
currentLocalMasterKeyHash?: string;
newPassword: string;
newPasswordHint: string;
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
newPasswordHint?: string;
newMasterKey?: MasterKey;
newServerMasterKeyHash?: string;
newLocalMasterKeyHash?: string;
kdfConfig: KdfConfig;
kdfConfig?: KdfConfig;
rotateUserKey?: boolean;
}

View File

@@ -39,7 +39,7 @@ import { RegistrationFinishService } from "./registration-finish.service";
export class RegistrationFinishComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
inputPasswordFlow = InputPasswordFlow.AccountRegistration;
inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAccountRegistration;
loading = true;
submitting = false;
email: string;

View File

@@ -122,7 +122,11 @@ describe("DefaultSetPasswordJitService", () => {
};
credentials = {
...passwordInputResult,
newMasterKey: passwordInputResult.newMasterKey,
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash,
newPasswordHint: passwordInputResult.newPasswordHint,
kdfConfig: passwordInputResult.kdfConfig,
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,

View File

@@ -97,7 +97,11 @@ export class SetPasswordJitComponent implements OnInit {
this.submitting = true;
const credentials: SetPasswordCredentials = {
...passwordInputResult,
newMasterKey: passwordInputResult.newMasterKey,
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash,
newPasswordHint: passwordInputResult.newPasswordHint,
kdfConfig: passwordInputResult.kdfConfig,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,