1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

refactor(set-change-password): [Auth/PM-18458] Create new ChangePasswordComponent (#14226)

This PR creates a new ChangePasswordComponent. The first use-case of the ChangePasswordComponent is to place it inside a new PasswordSettingsComponent, which is accessed by going to Account Settings > Security.

The ChangePasswordComponent will be updated in future PRs to handle more change password scenarios.

Feature Flags: PM16117_ChangeExistingPasswordRefactor
This commit is contained in:
rr-bw
2025-05-16 10:41:46 -07:00
committed by GitHub
parent d16a5cb73e
commit afbddeaf86
37 changed files with 1349 additions and 310 deletions

View File

@@ -0,0 +1 @@
export * from "./web-change-password.service";

View File

@@ -0,0 +1,63 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ChangePasswordService } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { UserKeyRotationService } from "@bitwarden/web-vault/app/key-management/key-rotation/user-key-rotation.service";
import { WebChangePasswordService } from "./web-change-password.service";
describe("WebChangePasswordService", () => {
let keyService: MockProxy<KeyService>;
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let userKeyRotationService: MockProxy<UserKeyRotationService>;
let sut: ChangePasswordService;
const userId = "userId" as UserId;
const user: Account = {
id: userId,
email: "email",
emailVerified: false,
name: "name",
};
const currentPassword = "currentPassword";
const newPassword = "newPassword";
const newPasswordHint = "newPasswordHint";
beforeEach(() => {
keyService = mock<KeyService>();
masterPasswordApiService = mock<MasterPasswordApiService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
userKeyRotationService = mock<UserKeyRotationService>();
sut = new WebChangePasswordService(
keyService,
masterPasswordApiService,
masterPasswordService,
userKeyRotationService,
);
});
describe("rotateUserKeyMasterPasswordAndEncryptedData()", () => {
it("should call the method with the same name on the UserKeyRotationService with the correct arguments", async () => {
// Arrange & Act
await sut.rotateUserKeyMasterPasswordAndEncryptedData(
currentPassword,
newPassword,
user,
newPasswordHint,
);
// Assert
expect(
userKeyRotationService.rotateUserKeyMasterPasswordAndEncryptedData,
).toHaveBeenCalledWith(currentPassword, newPassword, user, newPasswordHint);
});
});
});

View File

@@ -0,0 +1,34 @@
import { ChangePasswordService, DefaultChangePasswordService } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { UserKeyRotationService } from "@bitwarden/web-vault/app/key-management/key-rotation/user-key-rotation.service";
export class WebChangePasswordService
extends DefaultChangePasswordService
implements ChangePasswordService
{
constructor(
protected keyService: KeyService,
protected masterPasswordApiService: MasterPasswordApiService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
private userKeyRotationService: UserKeyRotationService,
) {
super(keyService, masterPasswordApiService, masterPasswordService);
}
override async rotateUserKeyMasterPasswordAndEncryptedData(
currentPassword: string,
newPassword: string,
user: Account,
newPasswordHint: string,
): Promise<void> {
await this.userKeyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
currentPassword,
newPassword,
user,
newPasswordHint,
);
}
}

View File

@@ -1,3 +1,4 @@
export * from "./change-password";
export * from "./login";
export * from "./login-decryption-options";
export * from "./webauthn-login";

View File

@@ -185,11 +185,11 @@ describe("WebRegistrationFinishService", () => {
emailVerificationToken = "emailVerificationToken";
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
passwordInputResult = {
masterKey: masterKey,
serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
newMasterKey: masterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint",
newPasswordHint: "newPasswordHint",
newPassword: "newPassword",
};
@@ -231,8 +231,8 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: emailVerificationToken,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
masterPasswordHash: passwordInputResult.newServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
publicKey: userKeyPair[0],
@@ -267,8 +267,8 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
masterPasswordHash: passwordInputResult.newServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
publicKey: userKeyPair[0],
@@ -308,8 +308,8 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
masterPasswordHash: passwordInputResult.newServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
publicKey: userKeyPair[0],
@@ -351,8 +351,8 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
masterPasswordHash: passwordInputResult.newServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
publicKey: userKeyPair[0],
@@ -396,8 +396,8 @@ describe("WebRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: undefined,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
masterPasswordHash: passwordInputResult.newServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
publicKey: userKeyPair[0],

View File

@@ -29,6 +29,9 @@ import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { UserKeyRotationService } from "../../key-management/key-rotation/user-key-rotation.service";
/**
* @deprecated use the auth `PasswordSettingsComponent` instead
*/
@Component({
selector: "app-change-password",
templateUrl: "change-password.component.html",
@@ -132,7 +135,7 @@ export class ChangePasswordComponent
content:
this.i18nService.t("updateEncryptionKeyWarning") +
" " +
this.i18nService.t("updateEncryptionKeyExportWarning") +
this.i18nService.t("updateEncryptionKeyAccountExportWarning") +
" " +
this.i18nService.t("rotateEncKeyConfirmation"),
type: "warning",

View File

@@ -0,0 +1,10 @@
<div class="tabbed-header">
<h1>{{ "changeMasterPassword" | i18n }}</h1>
</div>
<div class="tw-max-w-lg tw-mb-12">
<bit-callout type="warning">{{ "loggedOutWarning" | i18n }}</bit-callout>
<auth-change-password [inputPasswordFlow]="inputPasswordFlow"></auth-change-password>
</div>
<app-webauthn-login-settings></app-webauthn-login-settings>

View File

@@ -0,0 +1,35 @@
import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { ChangePasswordComponent, InputPasswordFlow } from "@bitwarden/auth/angular";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { CalloutModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { WebauthnLoginSettingsModule } from "../../webauthn-login-settings";
@Component({
standalone: true,
selector: "app-password-settings",
templateUrl: "password-settings.component.html",
imports: [CalloutModule, ChangePasswordComponent, I18nPipe, WebauthnLoginSettingsModule],
})
export class PasswordSettingsComponent implements OnInit {
inputPasswordFlow = InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation;
constructor(
private router: Router,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
) {}
async ngOnInit() {
const userHasMasterPassword = await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPassword$,
);
if (!userHasMasterPassword) {
await this.router.navigate(["/settings/security/two-factor"]);
return;
}
}
}

View File

@@ -1,10 +1,14 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ChangePasswordComponent } from "../change-password.component";
import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component";
import { DeviceManagementComponent } from "./device-management.component";
import { PasswordSettingsComponent } from "./password-settings/password-settings.component";
import { SecurityKeysComponent } from "./security-keys.component";
import { SecurityComponent } from "./security.component";
@@ -14,10 +18,31 @@ const routes: Routes = [
component: SecurityComponent,
data: { titleId: "security" },
children: [
{ path: "", pathMatch: "full", redirectTo: "change-password" },
{ path: "", pathMatch: "full", redirectTo: "password" },
{
path: "change-password",
component: ChangePasswordComponent,
canActivate: [
canAccessFeature(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
false,
"/settings/security/password",
false,
),
],
data: { titleId: "masterPassword" },
},
{
path: "password",
component: PasswordSettingsComponent,
canActivate: [
canAccessFeature(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
true,
"/settings/security/change-password",
false,
),
],
data: { titleId: "masterPassword" },
},
{

View File

@@ -1,7 +1,7 @@
<app-header>
<bit-tab-nav-bar slot="tabs">
<ng-container *ngIf="showChangePassword">
<bit-tab-link route="change-password">{{ "masterPassword" | i18n }}</bit-tab-link>
<bit-tab-link [route]="changePasswordRoute">{{ "masterPassword" | i18n }}</bit-tab-link>
</ng-container>
<bit-tab-link route="two-factor">{{ "twoStepLogin" | i18n }}</bit-tab-link>
<bit-tab-link route="device-management">{{ "devices" | i18n }}</bit-tab-link>

View File

@@ -1,6 +1,7 @@
import { Component, OnInit } from "@angular/core";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@Component({
@@ -10,6 +11,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
})
export class SecurityComponent implements OnInit {
showChangePassword = true;
changePasswordRoute = "change-password";
constructor(
private userVerificationService: UserVerificationService,
@@ -18,5 +20,12 @@ export class SecurityComponent implements OnInit {
async ngOnInit() {
this.showChangePassword = await this.userVerificationService.hasMasterPassword();
const changePasswordRefreshFlag = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
if (changePasswordRefreshFlag) {
this.changePasswordRoute = "password";
}
}
}

View File

@@ -1,89 +1,100 @@
<div *ngIf="!useTrialStepper">
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[email]="email"
[masterPasswordPolicyOptions]="enforcedPolicyOptions"
(onPasswordFormSubmit)="handlePasswordSubmit($event)"
[primaryButtonText]="{ key: 'createAccount' }"
></auth-input-password>
</div>
<div *ngIf="useTrialStepper">
<app-vertical-stepper #stepper linear (selectionChange)="verticalStepChange($event)">
<app-vertical-step label="Create Account" [editable]="false" [subLabel]="email">
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[email]="email"
[masterPasswordPolicyOptions]="enforcedPolicyOptions"
(onPasswordFormSubmit)="handlePasswordSubmit($event)"
[primaryButtonText]="{ key: 'createAccount' }"
></auth-input-password>
</app-vertical-step>
<app-vertical-step label="Organization Information" [subLabel]="orgInfoSubLabel">
<app-org-info [nameOnly]="true" [formGroup]="orgInfoFormGroup"></app-org-info>
<button
type="button"
bitButton
buttonType="primary"
[disabled]="orgInfoFormGroup.controls.name.invalid"
[loading]="loading && (trialPaymentOptional$ | async)"
(click)="orgNameEntrySubmit()"
>
{{ (trialPaymentOptional$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
</button>
</app-vertical-step>
<app-vertical-step
label="Billing"
[subLabel]="billingSubLabel"
*ngIf="!(trialPaymentOptional$ | async) && !isSecretsManagerFree"
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: orgInfoFormGroup.value.name,
email: orgInfoFormGroup.value.billingEmail,
type: trialOrganizationType,
}"
[subscriptionProduct]="
product === ProductType.SecretsManager
? SubscriptionProduct.SecretsManager
: SubscriptionProduct.PasswordManager
"
[trialLength]="trialLength"
(steppedBack)="previousStep()"
(organizationCreated)="createdOrganization($event)"
>
</app-trial-billing-step>
</app-vertical-step>
<app-vertical-step label="Confirmation Details" [applyBorder]="false">
<app-trial-confirmation-details
[email]="email"
[orgLabel]="orgLabel"
[product]="this.product"
[trialLength]="trialLength"
></app-trial-confirmation-details>
<div class="tw-mb-3 tw-flex">
<a
@if (initializing) {
<div class="tw-flex tw-items-center tw-justify-center">
<i
class="bwi bwi-spinner bwi-spin bwi-3x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
} @else {
<div *ngIf="!useTrialStepper">
<auth-input-password
[flow]="inputPasswordFlow"
[email]="email"
[masterPasswordPolicyOptions]="enforcedPolicyOptions"
(onPasswordFormSubmit)="handlePasswordSubmit($event)"
[primaryButtonText]="{ key: 'createAccount' }"
></auth-input-password>
</div>
<div *ngIf="useTrialStepper">
<app-vertical-stepper #stepper linear (selectionChange)="verticalStepChange($event)">
<app-vertical-step label="Create Account" [editable]="false" [subLabel]="email">
<auth-input-password
[flow]="inputPasswordFlow"
[email]="email"
[masterPasswordPolicyOptions]="enforcedPolicyOptions"
(onPasswordFormSubmit)="handlePasswordSubmit($event)"
[primaryButtonText]="{ key: 'createAccount' }"
></auth-input-password>
</app-vertical-step>
<app-vertical-step label="Organization Information" [subLabel]="orgInfoSubLabel">
<app-org-info [nameOnly]="true" [formGroup]="orgInfoFormGroup"></app-org-info>
<button
type="button"
bitButton
buttonType="primary"
[routerLink]="
[disabled]="orgInfoFormGroup.controls.name.invalid"
[loading]="loading && (trialPaymentOptional$ | async)"
(click)="orgNameEntrySubmit()"
>
{{ (trialPaymentOptional$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
</button>
</app-vertical-step>
<app-vertical-step
label="Billing"
[subLabel]="billingSubLabel"
*ngIf="!(trialPaymentOptional$ | async) && !isSecretsManagerFree"
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: orgInfoFormGroup.value.name,
email: orgInfoFormGroup.value.billingEmail,
type: trialOrganizationType,
}"
[subscriptionProduct]="
product === ProductType.SecretsManager
? ['/sm', orgId]
: ['/organizations', orgId, 'vault']
? SubscriptionProduct.SecretsManager
: SubscriptionProduct.PasswordManager
"
[trialLength]="trialLength"
(steppedBack)="previousStep()"
(organizationCreated)="createdOrganization($event)"
>
{{ "getStarted" | i18n | titlecase }}
</a>
<a
type="button"
bitButton
buttonType="secondary"
[routerLink]="['/organizations', orgId, 'members']"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3"
>
{{ "inviteUsers" | i18n }}
</a>
</div>
</app-vertical-step>
</app-vertical-stepper>
</div>
</app-trial-billing-step>
</app-vertical-step>
<app-vertical-step label="Confirmation Details" [applyBorder]="false">
<app-trial-confirmation-details
[email]="email"
[orgLabel]="orgLabel"
[product]="this.product"
[trialLength]="trialLength"
></app-trial-confirmation-details>
<div class="tw-mb-3 tw-flex">
<a
type="button"
bitButton
buttonType="primary"
[routerLink]="
product === ProductType.SecretsManager
? ['/sm', orgId]
: ['/organizations', orgId, 'vault']
"
>
{{ "getStarted" | i18n | titlecase }}
</a>
<a
type="button"
bitButton
buttonType="secondary"
[routerLink]="['/organizations', orgId, 'members']"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3"
>
{{ "inviteUsers" | i18n }}
</a>
</div>
</app-vertical-step>
</app-vertical-stepper>
</div>
}

View File

@@ -52,7 +52,8 @@ export type InitiationPath =
export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
InputPasswordFlow = InputPasswordFlow;
inputPasswordFlow = InputPasswordFlow.AccountRegistration;
initializing = true;
/** Password Manager or Secrets Manager */
product: ProductType;
@@ -203,6 +204,8 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
.subscribe(() => {
this.orgInfoFormGroup.controls.name.markAsTouched();
});
this.initializing = false;
}
ngOnDestroy(): void {

View File

@@ -34,6 +34,7 @@ import {
LoginDecryptionOptionsService,
TwoFactorAuthComponentService,
TwoFactorAuthDuoComponentService,
ChangePasswordService,
} from "@bitwarden/auth/angular";
import {
InternalUserDecryptionOptionsServiceAbstraction,
@@ -110,6 +111,7 @@ import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarde
import { flagEnabled } from "../../utils/flags";
import { PolicyListService } from "../admin-console/core/policy-list.service";
import {
WebChangePasswordService,
WebSetPasswordJitService,
WebRegistrationFinishService,
WebLoginComponentService,
@@ -123,6 +125,7 @@ import { AcceptOrganizationInviteService } from "../auth/organization-invite/acc
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
import { WebFileDownloadService } from "../core/web-file-download.service";
import { UserKeyRotationService } from "../key-management/key-rotation/user-key-rotation.service";
import { WebLockComponentService } from "../key-management/lock/services/web-lock-component.service";
import { WebProcessReloadService } from "../key-management/services/web-process-reload.service";
import { WebBiometricsService } from "../key-management/web-biometric.service";
@@ -373,6 +376,16 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultSshImportPromptService,
deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction],
}),
safeProvider({
provide: ChangePasswordService,
useClass: WebChangePasswordService,
deps: [
KeyServiceAbstraction,
MasterPasswordApiService,
InternalMasterPasswordServiceAbstraction,
UserKeyRotationService,
],
}),
];
@NgModule({

View File

@@ -4549,8 +4549,8 @@
"updateEncryptionKeyWarning": {
"message": "After updating your encryption key, you are required to log out and back in to all Bitwarden applications that you are currently using (such as the mobile app or browser extensions). Failure to log out and back in (which downloads your new encryption key) may result in data corruption. We will attempt to log you out automatically, however, it may be delayed."
},
"updateEncryptionKeyExportWarning": {
"message": "Any encrypted exports that you have saved will also become invalid."
"updateEncryptionKeyAccountExportWarning": {
"message": "Any account restricted exports you have saved will become invalid."
},
"subscription": {
"message": "Subscription"

View File

@@ -27,6 +27,8 @@ import {
TwoFactorAuthComponentService,
TwoFactorAuthEmailComponentService,
TwoFactorAuthWebAuthnComponentService,
ChangePasswordService,
DefaultChangePasswordService,
} from "@bitwarden/auth/angular";
import {
AuthRequestApiService,
@@ -1538,6 +1540,15 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultCipherEncryptionService,
deps: [SdkService, LogService],
}),
safeProvider({
provide: ChangePasswordService,
useClass: DefaultChangePasswordService,
deps: [
KeyService,
MasterPasswordApiServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
],
}),
];
@NgModule({

View File

@@ -0,0 +1,20 @@
@if (initializing) {
<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>
} @else {
<auth-input-password
[flow]="inputPasswordFlow"
[email]="email"
[userId]="userId"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[inlineButtons]="true"
[primaryButtonText]="{ key: 'changeMasterPassword' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
>
</auth-input-password>
}

View File

@@ -0,0 +1,110 @@
import { Component, Input, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
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 { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
InputPasswordComponent,
InputPasswordFlow,
} from "../input-password/input-password.component";
import { PasswordInputResult } from "../input-password/password-input-result";
import { ChangePasswordService } from "./change-password.service.abstraction";
@Component({
standalone: true,
selector: "auth-change-password",
templateUrl: "change-password.component.html",
imports: [InputPasswordComponent, I18nPipe],
})
export class ChangePasswordComponent implements OnInit {
@Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword;
activeAccount: Account | null = null;
email?: string;
userId?: UserId;
masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
initializing = true;
submitting = false;
constructor(
private accountService: AccountService,
private changePasswordService: ChangePasswordService,
private i18nService: I18nService,
private messagingService: MessagingService,
private policyService: PolicyService,
private toastService: ToastService,
private syncService: SyncService,
) {}
async ngOnInit() {
this.activeAccount = await firstValueFrom(this.accountService.activeAccount$);
this.userId = this.activeAccount?.id;
this.email = this.activeAccount?.email;
if (!this.userId) {
throw new Error("userId not found");
}
this.masterPasswordPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(this.userId),
);
this.initializing = false;
}
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
try {
if (passwordInputResult.rotateUserKey) {
if (this.activeAccount == null) {
throw new Error("activeAccount not found");
}
if (passwordInputResult.currentPassword == null) {
throw new Error("currentPassword not found");
}
await this.syncService.fullSync(true);
await this.changePasswordService.rotateUserKeyMasterPasswordAndEncryptedData(
passwordInputResult.currentPassword,
passwordInputResult.newPassword,
this.activeAccount,
passwordInputResult.newPasswordHint,
);
} else {
if (!this.userId) {
throw new Error("userId not found");
}
await this.changePasswordService.changePassword(passwordInputResult, this.userId);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("masterPasswordChanged"),
message: this.i18nService.t("masterPasswordChangedDesc"),
});
this.messagingService.send("logout");
}
} catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("errorOccurred"),
});
} finally {
this.submitting = false;
}
}
}

View File

@@ -0,0 +1,36 @@
import { PasswordInputResult } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
export abstract class ChangePasswordService {
/**
* Creates a new user key and re-encrypts all required data with it.
* - does so by calling the underlying method on the `UserKeyRotationService`
* - implemented in Web only
*
* @param currentPassword the current password
* @param newPassword the new password
* @param user the user account
* @param newPasswordHint the new password hint
* @throws if called from a non-Web client
*/
abstract rotateUserKeyMasterPasswordAndEncryptedData(
currentPassword: string,
newPassword: string,
user: Account,
newPasswordHint: string,
): Promise<void>;
/**
* Changes the user's password and re-encrypts the user key with the `newMasterKey`.
* - Specifically, this method uses credentials from the `passwordInputResult` to:
* 1. Decrypt the user key with the `currentMasterKey`
* 2. Re-encrypt that user key with the `newMasterKey`, resulting in a `newMasterKeyEncryptedUserKey`
* 3. Build a `PasswordRequest` object that gets POSTed to `"/accounts/password"`
*
* @param passwordInputResult credentials object received from the `InputPasswordComponent`
* @param userId the `userId`
* @throws if the `userId`, `currentMasterKey`, or `currentServerMasterKeyHash` is not found
*/
abstract changePassword(passwordInputResult: PasswordInputResult, userId: UserId): Promise<void>;
}

View File

@@ -0,0 +1,177 @@
import { mock, MockProxy } from "jest-mock-extended";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { PasswordInputResult } from "../input-password/password-input-result";
import { ChangePasswordService } from "./change-password.service.abstraction";
import { DefaultChangePasswordService } from "./default-change-password.service";
describe("DefaultChangePasswordService", () => {
let keyService: MockProxy<KeyService>;
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let sut: ChangePasswordService;
const userId = "userId" as UserId;
const user: Account = {
id: userId,
email: "email",
emailVerified: false,
name: "name",
};
const passwordInputResult: PasswordInputResult = {
currentMasterKey: new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
currentServerMasterKeyHash: "currentServerMasterKeyHash",
newPassword: "newPassword",
newPasswordHint: "newPasswordHint",
newMasterKey: new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
kdfConfig: new PBKDF2KdfConfig(),
};
const decryptedUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const newMasterKeyEncryptedUserKey: [UserKey, EncString] = [
decryptedUserKey,
{ encryptedString: "newMasterKeyEncryptedUserKey" } as EncString,
];
beforeEach(() => {
keyService = mock<KeyService>();
masterPasswordApiService = mock<MasterPasswordApiService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
sut = new DefaultChangePasswordService(
keyService,
masterPasswordApiService,
masterPasswordService,
);
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(decryptedUserKey);
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(newMasterKeyEncryptedUserKey);
});
describe("changePassword()", () => {
it("should call the postPassword() API method with a the correct PasswordRequest credentials", async () => {
// Act
await sut.changePassword(passwordInputResult, userId);
// Assert
expect(masterPasswordApiService.postPassword).toHaveBeenCalledWith(
expect.objectContaining({
masterPasswordHash: passwordInputResult.currentServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
newMasterPasswordHash: passwordInputResult.newServerMasterKeyHash,
key: newMasterKeyEncryptedUserKey[1].encryptedString,
}),
);
});
it("should call decryptUserKeyWithMasterKey and encryptUserKeyWithMasterKey", async () => {
// Act
await sut.changePassword(passwordInputResult, userId);
// Assert
expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
passwordInputResult.currentMasterKey,
userId,
);
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
passwordInputResult.newMasterKey,
decryptedUserKey,
);
});
it("should throw if a userId was not found", async () => {
// Arrange
const userId: null = null;
// Act
const testFn = sut.changePassword(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("userId not found");
});
it("should throw if a currentMasterKey was not found", async () => {
// Arrange
const incorrectPasswordInputResult = { ...passwordInputResult };
incorrectPasswordInputResult.currentMasterKey = null;
// Act
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow(
"currentMasterKey or currentServerMasterKeyHash not found",
);
});
it("should throw if a currentServerMasterKeyHash was not found", async () => {
// Arrange
const incorrectPasswordInputResult = { ...passwordInputResult };
incorrectPasswordInputResult.currentServerMasterKeyHash = null;
// Act
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow(
"currentMasterKey or currentServerMasterKeyHash not found",
);
});
it("should throw an error if user key decryption fails", async () => {
// Arrange
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null);
// Act
const testFn = sut.changePassword(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("Could not decrypt user key");
});
it("should throw an error if postPassword() fails", async () => {
// Arrange
masterPasswordApiService.postPassword.mockRejectedValueOnce(new Error("error"));
// Act
const testFn = sut.changePassword(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("Could not change password");
expect(masterPasswordApiService.postPassword).toHaveBeenCalled();
});
});
describe("rotateUserKeyMasterPasswordAndEncryptedData()", () => {
it("should throw an error (the method is only implemented in Web)", async () => {
// Act
const testFn = sut.rotateUserKeyMasterPasswordAndEncryptedData(
"currentPassword",
"newPassword",
user,
"newPasswordHint",
);
// Assert
await expect(testFn).rejects.toThrow(
"rotateUserKeyMasterPasswordAndEncryptedData() is only implemented in Web",
);
});
});
});

View File

@@ -0,0 +1,59 @@
import { PasswordInputResult, ChangePasswordService } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
export class DefaultChangePasswordService implements ChangePasswordService {
constructor(
protected keyService: KeyService,
protected masterPasswordApiService: MasterPasswordApiService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
) {}
async rotateUserKeyMasterPasswordAndEncryptedData(
currentPassword: string,
newPassword: string,
user: Account,
hint: string,
): Promise<void> {
throw new Error("rotateUserKeyMasterPasswordAndEncryptedData() is only implemented in Web");
}
async changePassword(passwordInputResult: PasswordInputResult, userId: UserId) {
if (!userId) {
throw new Error("userId not found");
}
if (!passwordInputResult.currentMasterKey || !passwordInputResult.currentServerMasterKeyHash) {
throw new Error("currentMasterKey or currentServerMasterKeyHash not found");
}
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
passwordInputResult.currentMasterKey,
userId,
);
if (decryptedUserKey == null) {
throw new Error("Could not decrypt user key");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
passwordInputResult.newMasterKey,
decryptedUserKey,
);
const request = new PasswordRequest();
request.masterPasswordHash = passwordInputResult.currentServerMasterKeyHash;
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
request.masterPasswordHint = passwordInputResult.newPasswordHint;
request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string;
try {
await this.masterPasswordApiService.postPassword(request);
} catch {
throw new Error("Could not change password");
}
}
}

View File

@@ -8,6 +8,11 @@ export * from "./anon-layout/anon-layout-wrapper.component";
export * from "./anon-layout/anon-layout-wrapper-data.service";
export * from "./anon-layout/default-anon-layout-wrapper-data.service";
// change password
export * from "./change-password/change-password.component";
export * from "./change-password/change-password.service.abstraction";
export * from "./change-password/default-change-password.service";
// fingerprint dialog
export * from "./fingerprint-dialog/fingerprint-dialog.component";

View File

@@ -6,8 +6,8 @@
<bit-form-field
*ngIf="
inputPasswordFlow === InputPasswordFlow.ChangePassword ||
inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
flow === InputPasswordFlow.ChangePassword ||
flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
"
>
<bit-label>{{ "currentMasterPass" | i18n }}</bit-label>
@@ -58,12 +58,12 @@
</div>
<bit-form-field>
<bit-label>{{ "confirmMasterPassword" | i18n }}</bit-label>
<bit-label>{{ "confirmNewMasterPass" | i18n }}</bit-label>
<input
id="input-password-form_confirm-new-password"
id="input-password-form_new-password-confirm"
bitInput
type="password"
formControlName="confirmNewPassword"
formControlName="newPasswordConfirm"
/>
<button
type="button"
@@ -76,21 +76,33 @@
<bit-form-field>
<bit-label>{{ "masterPassHintLabel" | i18n }}</bit-label>
<input bitInput formControlName="hint" />
<input id="input-password-form_new-password-hint" bitInput formControlName="newPasswordHint" />
<bit-hint>
{{ "masterPassHintText" | i18n: formGroup.value.hint.length : maxHintLength.toString() }}
{{
"masterPassHintText"
| i18n: formGroup.value.newPasswordHint.length : maxHintLength.toString()
}}
</bit-hint>
</bit-form-field>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="checkForBreaches" />
<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
*ngIf="inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation"
>
<input type="checkbox" bitCheckbox formControlName="rotateUserKey" />
<bit-form-control *ngIf="flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation">
<input
id="input-password-form_rotate-user-key"
type="checkbox"
bitCheckbox
formControlName="rotateUserKey"
(change)="rotateUserKeyClicked()"
/>
<bit-label>
{{ "rotateAccountEncKey" | i18n }}
<a

View File

@@ -1,5 +1,6 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators, FormGroup } from "@angular/forms";
import { ReactiveFormsModule, FormBuilder, Validators, FormControl } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -9,9 +10,13 @@ import {
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
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 { 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 { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
AsyncActionsModule,
ButtonModule,
@@ -23,7 +28,12 @@ import {
ToastService,
Translation,
} from "@bitwarden/components";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
import {
DEFAULT_KDF_CONFIG,
KdfConfig,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
@@ -34,30 +44,41 @@ import { compareInputs, ValidationGoal } from "../validators/compare-inputs.vali
import { PasswordInputResult } from "./password-input-result";
/**
* Determines which form input elements will be displayed in the UI.
* Determines which form elements will be displayed in the UI
* and which cryptographic keys will be created and emitted.
*/
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum InputPasswordFlow {
/**
* - Input: New password
* - Input: Confirm new password
* - Input: Hint
* - Checkbox: Check for breaches
* Form elements displayed:
* - [Input] New password
* - [Input] New password confirm
* - [Input] New password hint
* - [Checkbox] Check for breaches
*/
SetInitialPassword,
/**
* Everything above, plus:
* - Input: Current password (as the first element in the UI)
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)
*/
ChangePassword,
/**
* Everything above, plus:
* - Checkbox: Rotate account encryption key (as the last element in the UI)
* All form elements above, plus: [Checkbox] Rotate account encryption key (as the last element in the UI)
*/
ChangePasswordWithOptionalUserKeyRotation,
}
interface InputPasswordForm {
newPassword: FormControl<string>;
newPasswordConfirm: FormControl<string>;
newPasswordHint: FormControl<string>;
checkForBreaches: FormControl<boolean>;
currentPassword?: FormControl<string>;
rotateUserKey?: FormControl<boolean>;
}
@Component({
standalone: true,
selector: "auth-input-password",
@@ -80,9 +101,10 @@ export class InputPasswordComponent implements OnInit {
@Output() onPasswordFormSubmit = new EventEmitter<PasswordInputResult>();
@Output() onSecondaryButtonClick = new EventEmitter<void>();
@Input({ required: true }) inputPasswordFlow!: InputPasswordFlow;
@Input({ required: true }) email!: string;
@Input({ required: true }) flow!: InputPasswordFlow;
@Input({ required: true, transform: (val: string) => val.trim().toLowerCase() }) email!: string;
@Input() userId?: UserId;
@Input() loading = false;
@Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
@@ -93,6 +115,7 @@ export class InputPasswordComponent implements OnInit {
protected secondaryButtonTextStr: string = "";
protected InputPasswordFlow = InputPasswordFlow;
private kdfConfig: KdfConfig | null = null;
private minHintLength = 0;
protected maxHintLength = 50;
protected minPasswordLength = Utils.minimumPasswordLength;
@@ -101,64 +124,93 @@ export class InputPasswordComponent implements OnInit {
protected showErrorSummary = false;
protected showPassword = false;
protected formGroup = this.formBuilder.nonNullable.group(
protected formGroup = this.formBuilder.nonNullable.group<InputPasswordForm>(
{
newPassword: ["", [Validators.required, Validators.minLength(this.minPasswordLength)]],
confirmNewPassword: ["", Validators.required],
hint: [
"", // must be string (not null) because we check length in validation
[Validators.minLength(this.minHintLength), Validators.maxLength(this.maxHintLength)],
],
checkForBreaches: [true],
newPassword: this.formBuilder.nonNullable.control("", [
Validators.required,
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: [
compareInputs(
ValidationGoal.InputsShouldMatch,
"newPassword",
"confirmNewPassword",
"newPasswordConfirm",
this.i18nService.t("masterPassDoesntMatch"),
),
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"newPassword",
"hint",
"newPasswordHint",
this.i18nService.t("hintEqualsPassword"),
),
],
},
);
protected get minPasswordLengthMsg() {
if (
this.masterPasswordPolicyOptions != null &&
this.masterPasswordPolicyOptions.minLength > 0
) {
return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength);
} else {
return this.i18nService.t("characterMinimum", this.minPasswordLength);
}
}
constructor(
private auditService: AuditService,
private keyService: KeyService,
private cipherService: CipherService,
private dialogService: DialogService,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private kdfConfigService: KdfConfigService,
private keyService: KeyService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
private toastService: ToastService,
) {}
ngOnInit(): void {
this.addFormFieldsIfNecessary();
this.setButtonText();
}
private addFormFieldsIfNecessary() {
if (
this.inputPasswordFlow === InputPasswordFlow.ChangePassword ||
this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
// https://github.com/angular/angular/issues/48794
(this.formGroup as FormGroup<any>).addControl(
this.formGroup.addControl(
"currentPassword",
this.formBuilder.control("", Validators.required),
this.formBuilder.nonNullable.control("", Validators.required),
);
this.formGroup.addValidators([
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"currentPassword",
"newPassword",
this.i18nService.t("yourNewPasswordCannotBeTheSameAsYourCurrentPassword"),
),
]);
}
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.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
this.formGroup.addControl("rotateUserKey", this.formBuilder.nonNullable.control(false));
}
}
private setButtonText() {
if (this.primaryButtonText) {
this.primaryButtonTextStr = this.i18nService.t(
this.primaryButtonText.key,
@@ -174,22 +226,9 @@ export class InputPasswordComponent implements OnInit {
}
}
get minPasswordLengthMsg() {
if (
this.masterPasswordPolicyOptions != null &&
this.masterPasswordPolicyOptions.minLength > 0
) {
return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength);
} else {
return this.i18nService.t("characterMinimum", this.minPasswordLength);
}
}
getPasswordStrengthScore(score: PasswordStrengthScore) {
this.passwordStrengthScore = score;
}
protected submit = async () => {
this.verifyFlowAndUserId();
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
@@ -197,79 +236,204 @@ export class InputPasswordComponent implements OnInit {
return;
}
const newPassword = this.formGroup.controls.newPassword.value;
const passwordEvaluatedSuccessfully = await this.evaluateNewPassword(
newPassword,
this.passwordStrengthScore,
this.formGroup.controls.checkForBreaches.value,
);
if (!passwordEvaluatedSuccessfully) {
return;
}
// Create and hash new master key
const kdfConfig = DEFAULT_KDF_CONFIG;
if (this.email == null) {
if (!this.email) {
throw new Error("Email is required to create master key.");
}
const masterKey = await this.keyService.makeMasterKey(
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) {
return;
}
}
// 3. Verify new password
const newPasswordVerified = await this.verifyNewPassword(
newPassword,
this.email.trim().toLowerCase(),
kdfConfig,
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 serverMasterKeyHash = await this.keyService.hashMasterKey(
const newServerMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
masterKey,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const localMasterKeyHash = await this.keyService.hashMasterKey(
const newLocalMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
masterKey,
newMasterKey,
HashPurpose.LocalAuthorization,
);
const passwordInputResult: PasswordInputResult = {
newPassword,
hint: this.formGroup.controls.hint.value,
kdfConfig,
masterKey,
serverMasterKeyHash,
localMasterKeyHash,
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig: this.kdfConfig,
};
if (
this.inputPasswordFlow === InputPasswordFlow.ChangePassword ||
this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
passwordInputResult.currentPassword = this.formGroup.get("currentPassword")?.value;
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.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.get("rotateUserKey")?.value;
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);
};
// Returns true if the password passes all checks, false otherwise
private async evaluateNewPassword(
/**
* 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");
}
}
}
/**
* Returns `true` if the current password is correct (it can be used to successfully decrypt
* the masterKeyEncrypedUserKey), `false` otherwise
*/
private async verifyCurrentPassword(
currentPassword: string,
kdfConfig: KdfConfig,
): Promise<boolean> {
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,
);
if (decryptedUserKey == null) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("invalidMasterPassword"),
});
return false;
}
return true;
}
/**
* Returns `true` if the new password is not weak or breached and it passes
* any enforced org policy options, `false` otherwise
*/
private async verifyNewPassword(
newPassword: string,
passwordStrengthScore: PasswordStrengthScore,
checkForBreaches: boolean,
) {
): Promise<boolean> {
// Check if the password is breached, weak, or both
const passwordIsBreached =
checkForBreaches && (await this.auditService.passwordLeaked(newPassword));
checkForBreaches && (await this.auditService.passwordLeaked(newPassword)) > 0;
const passwordWeak = passwordStrengthScore != null && passwordStrengthScore < 3;
const passwordIsWeak = passwordStrengthScore != null && passwordStrengthScore < 3;
if (passwordIsBreached && passwordWeak) {
if (passwordIsBreached && passwordIsWeak) {
const userAcceptedDialog = await this.dialogService.openSimpleDialog({
title: { key: "weakAndExposedMasterPassword" },
content: { key: "weakAndBreachedMasterPasswordDesc" },
@@ -279,7 +443,7 @@ export class InputPasswordComponent implements OnInit {
if (!userAcceptedDialog) {
return false;
}
} else if (passwordWeak) {
} else if (passwordIsWeak) {
const userAcceptedDialog = await this.dialogService.openSimpleDialog({
title: { key: "weakMasterPasswordDesc" },
content: { key: "weakMasterPasswordDesc" },
@@ -321,4 +485,67 @@ export class InputPasswordComponent implements OnInit {
return true;
}
protected async rotateUserKeyClicked() {
const rotateUserKeyCtrl = this.formGroup.controls.rotateUserKey;
const rotateUserKey = rotateUserKeyCtrl?.value;
if (rotateUserKey) {
if (!this.userId) {
throw new Error("userId not passed down");
}
const ciphers = await this.cipherService.getAllDecrypted(this.userId);
let hasOldAttachments = false;
if (ciphers != null) {
for (let i = 0; i < ciphers.length; i++) {
if (ciphers[i].organizationId == null && ciphers[i].hasOldAttachments) {
hasOldAttachments = true;
break;
}
}
}
if (hasOldAttachments) {
const learnMore = await this.dialogService.openSimpleDialog({
title: { key: "warning" },
content: { key: "oldAttachmentsNeedFixDesc" },
acceptButtonText: { key: "learnMore" },
cancelButtonText: { key: "close" },
type: "warning",
});
if (learnMore) {
this.platformUtilsService.launchUri(
"https://bitwarden.com/help/attachments/#add-storage-space",
);
}
rotateUserKeyCtrl.setValue(false);
return;
}
const result = await this.dialogService.openSimpleDialog({
title: { key: "rotateEncKeyTitle" },
content:
this.i18nService.t("updateEncryptionKeyWarning") +
" " +
this.i18nService.t("updateEncryptionKeyAccountExportWarning") +
" " +
this.i18nService.t("rotateEncKeyConfirmation"),
type: "warning",
});
if (!result) {
rotateUserKeyCtrl.setValue(false);
}
}
}
protected getPasswordStrengthScore(score: PasswordStrengthScore) {
this.passwordStrengthScore = score;
}
}

View File

@@ -6,9 +6,10 @@ import * as stories from "./input-password.stories.ts";
# InputPassword Component
The `InputPasswordComponent` allows a user to enter master password related credentials. On
submission it creates a master key, master key hash, and emits those values to the parent (along
with the other values found in `PasswordInputResult`).
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 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
@@ -16,12 +17,27 @@ the parent component to act on those values as needed.
<br />
## Table of Contents
- [@Inputs](#inputs)
- [@Outputs](#outputs)
- [The InputPasswordFlow](#the-inputpasswordflow)
- [HTML - Form Fields](#html---form-fields)
- [TypeScript - Credential Generation](#typescript---credential-generation)
- [Difference between AccountRegistration and SetInitialPasswordAuthedUser](#difference-between-accountregistration-and-setinitialpasswordautheduser)
- [Validation](#validation)
- [Submit Logic](#submit-logic)
- [Example](#example)
<br />
## `@Input()`'s
**Required**
- `inputPasswordFlow` - the parent component must provide the correct flow, which is used to
determine which form input elements will be displayed in the UI.
- `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.
@@ -29,13 +45,15 @@ the parent component to act on those values as needed.
- `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.
The primary button will also show a spinner if `loading` is 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
<br />
## `@Output()`'s
- `onPasswordFormSubmit` - on form submit, emits a `PasswordInputResult` object
@@ -45,25 +63,31 @@ the parent component to act on those values as needed.
<br />
## Form Input Fields
## The `InputPasswordFlow`
The `InputPasswordComponent` can handle up to 6 different form input fields, depending on the
`InputPasswordFlow` provided by the parent component.
The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the
credential generation logic of the component.
**InputPasswordFlow.SetInitialPassword**
<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**
**`InputPasswordFlow.ChangePassword`**
Includes everything above, plus:
- Input: Current password (as the first element in the UI)
**InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation**
**`InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation`**
Includes everything above, plus:
@@ -71,49 +95,122 @@ Includes everything above, plus:
<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`).
<br />
### Difference between `AccountRegistration` 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 />
- `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:
- If there is no `userId` passed down from the parent, we default to `DEFAULT_KDF_CONFIG`
- If there is a `userId` passed down from the parent, we get the `kdfConfig` from state using the
`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:
- If the passed down flow is `AccountRegistration`, 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`
If either of these checks is not met, the component throws to alert the dev of a mistake.
<br />
## Validation
Validation ensures that:
Form validators ensure 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
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 />
## On Submit
## Submit Logic
When the form is submitted, the `InputPasswordComponent` does the following in order:
1. If the user selected the checkbox to check for password breaches, they will recieve a popup
dialog if their entered password is found in a breach. The popup will give them the option to
continue with the password or to back out and choose a different password.
2. If there is a master password policy being enforced by an org, it will check to make sure the
entered master password meets the policy requirements.
3. The component will use the password, email, and default kdfConfig to create a master key and
master key hash.
4. The component will emit the following values (defined in the `PasswordInputResult` interface) to
be used by the parent component as needed:
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
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
`PasswordInputResult`) to be used by the parent as needed.
```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;
hint: string;
kdfConfig: PBKDF2KdfConfig;
masterKey: MasterKey;
serverMasterKeyHash: string;
localMasterKeyHash: string;
currentPassword?: string; // included if the flow is ChangePassword or ChangePasswordWithOptionalUserKeyRotation
newPasswordHint: string;
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
kdfConfig: KdfConfig;
rotateUserKey?: boolean; // included if the flow is ChangePasswordWithOptionalUserKeyRotation
}
```
# Example - InputPasswordFlow.SetInitialPassword
# Example
<Story of={stories.SetInitialPassword} />
**`InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation`**
<br />
# Example - With Policy Requrements
<Story of={stories.WithPolicies} />
<Story of={stories.ChangePasswordWithOptionalUserKeyRotation} />

View File

@@ -2,14 +2,20 @@ import { importProvidersFrom } from "@angular/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { action } from "@storybook/addon-actions";
import { Meta, StoryObj, applicationConfig } from "@storybook/angular";
import { of } from "rxjs";
import { ZXCVBNResult } from "zxcvbn";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { 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 { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
// FIXME: remove `/apps` import from `/libs`
// FIXME: remove `src` and fix import
@@ -26,12 +32,47 @@ export default {
providers: [
importProvidersFrom(PreloadedEnglishI18nModule),
importProvidersFrom(BrowserAnimationsModule),
{
provide: AccountService,
useValue: {
activeAccount$: of({
id: "1" as UserId,
name: "User",
email: "user@email.com",
emailVerified: true,
}),
},
},
{
provide: AuditService,
useValue: {
passwordLeaked: () => Promise.resolve(1),
} as Partial<AuditService>,
},
{
provide: CipherService,
useValue: {
getAllDecrypted: () => Promise.resolve([]),
},
},
{
provide: KdfConfigService,
useValue: {
getKdfConfig$: () => of(DEFAULT_KDF_CONFIG),
},
},
{
provide: MasterPasswordServiceAbstraction,
useValue: {
decryptUserKeyWithMasterKey: () => Promise.resolve("example-decrypted-user-key"),
},
},
{
provide: PlatformUtilsService,
useValue: {
launchUri: () => Promise.resolve(true),
},
},
{
provide: KeyService,
useValue: {
@@ -87,11 +128,14 @@ export default {
],
args: {
InputPasswordFlow: {
SetInitialPassword: InputPasswordFlow.SetInitialPassword,
AccountRegistration: InputPasswordFlow.AccountRegistration,
SetInitialPasswordAuthedUser: InputPasswordFlow.SetInitialPasswordAuthedUser,
ChangePassword: InputPasswordFlow.ChangePassword,
ChangePasswordWithOptionalUserKeyRotation:
InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation,
},
userId: "1" as UserId,
email: "user@email.com",
masterPasswordPolicyOptions: {
minComplexity: 4,
minLength: 14,
@@ -108,11 +152,27 @@ export default {
type Story = StoryObj<InputPasswordComponent>;
export const SetInitialPassword: Story = {
export const AccountRegistration: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password [inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"></auth-input-password>
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[email]="email"
></auth-input-password>
`,
}),
};
export const SetInitialPasswordAuthedUser: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.SetInitialPasswordAuthedUser"
[email]="email"
[userId]="userId"
></auth-input-password>
`,
}),
};
@@ -121,7 +181,11 @@ export const ChangePassword: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password [inputPasswordFlow]="InputPasswordFlow.ChangePassword"></auth-input-password>
<auth-input-password
[flow]="InputPasswordFlow.ChangePassword"
[email]="email"
[userId]="userId"
></auth-input-password>
`,
}),
};
@@ -131,7 +195,9 @@ export const ChangePasswordWithOptionalUserKeyRotation: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation"
[flow]="InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation"
[email]="email"
[userId]="userId"
></auth-input-password>
`,
}),
@@ -142,7 +208,9 @@ export const WithPolicies: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="InputPasswordFlow.SetInitialPasswordAuthedUser"
[email]="email"
[userId]="userId"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
></auth-input-password>
`,
@@ -154,7 +222,8 @@ export const SecondaryButton: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="InputPasswordFlow.AccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'cancel' }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
></auth-input-password>
@@ -167,7 +236,8 @@ export const SecondaryButtonWithPlaceHolderText: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="InputPasswordFlow.AccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'backTo', placeholders: ['homepage'] }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
></auth-input-password>
@@ -180,7 +250,8 @@ export const InlineButton: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="InputPasswordFlow.AccountRegistration"
[email]="email"
[inlineButtons]="true"
></auth-input-password>
`,
@@ -192,7 +263,8 @@ export const InlineButtons: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="InputPasswordFlow.AccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'cancel' }"
[inlineButtons]="true"
(onSecondaryButtonClick)="onSecondaryButtonClick()"

View File

@@ -1,13 +1,18 @@
import { MasterKey } from "@bitwarden/common/types/key";
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
import { KdfConfig } from "@bitwarden/key-management";
export interface PasswordInputResult {
newPassword: string;
hint: string;
kdfConfig: PBKDF2KdfConfig;
masterKey: MasterKey;
serverMasterKeyHash: string;
localMasterKeyHash: string;
currentPassword?: string;
currentMasterKey?: MasterKey;
currentServerMasterKeyHash?: string;
currentLocalMasterKeyHash?: string;
newPassword: string;
newPasswordHint: string;
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
kdfConfig: KdfConfig;
rotateUserKey?: boolean;
}

View File

@@ -58,12 +58,12 @@ describe("DefaultRegistrationFinishService", () => {
emailVerificationToken = "emailVerificationToken";
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
passwordInputResult = {
masterKey: masterKey,
serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
newMasterKey: masterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint",
newPassword: "password",
newPasswordHint: "newPasswordHint",
newPassword: "newPassword",
};
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
@@ -93,8 +93,8 @@ describe("DefaultRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: emailVerificationToken,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
masterPasswordHash: passwordInputResult.newServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
publicKey: userKeyPair[0],

View File

@@ -36,7 +36,7 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi
providerUserId?: string,
): Promise<void> {
const [newUserKey, newEncUserKey] = await this.keyService.makeUserKey(
passwordInputResult.masterKey,
passwordInputResult.newMasterKey,
);
if (!newUserKey || !newEncUserKey) {
@@ -79,8 +79,8 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi
const registerFinishRequest = new RegisterFinishRequest(
email,
passwordInputResult.serverMasterKeyHash,
passwordInputResult.hint,
passwordInputResult.newServerMasterKeyHash,
passwordInputResult.newPasswordHint,
encryptedUserKey,
userAsymmetricKeysRequest,
passwordInputResult.kdfConfig.kdfType,

View File

@@ -5,7 +5,7 @@
<auth-input-password
*ngIf="!loading"
[email]="email"
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="inputPasswordFlow"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[loading]="submitting"
[primaryButtonText]="{ key: 'createAccount' }"

View File

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

View File

@@ -111,12 +111,12 @@ describe("DefaultSetPasswordJitService", () => {
userId = "userId" as UserId;
passwordInputResult = {
masterKey: masterKey,
serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
hint: "hint",
newMasterKey: masterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
newPasswordHint: "newPasswordHint",
kdfConfig: DEFAULT_KDF_CONFIG,
newPassword: "password",
newPassword: "newPassword",
};
credentials = {
@@ -131,9 +131,9 @@ describe("DefaultSetPasswordJitService", () => {
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
setPasswordRequest = new SetPasswordRequest(
passwordInputResult.serverMasterKeyHash,
passwordInputResult.newServerMasterKeyHash,
protectedUserKey[1].encryptedString,
passwordInputResult.hint,
passwordInputResult.newPasswordHint,
orgSsoIdentifier,
keysRequest,
passwordInputResult.kdfConfig.kdfType,

View File

@@ -20,7 +20,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { PBKDF2KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management";
import {
SetPasswordCredentials,
@@ -43,10 +43,10 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
async setPassword(credentials: SetPasswordCredentials): Promise<void> {
const {
masterKey,
serverMasterKeyHash,
localMasterKeyHash,
hint,
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig,
orgSsoIdentifier,
orgId,
@@ -60,7 +60,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
}
}
const protectedUserKey = await this.makeProtectedUserKey(masterKey, userId);
const protectedUserKey = await this.makeProtectedUserKey(newMasterKey, userId);
if (protectedUserKey == null) {
throw new Error("protectedUserKey not found. Could not set password.");
}
@@ -70,12 +70,12 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey);
const request = new SetPasswordRequest(
serverMasterKeyHash,
newServerMasterKeyHash,
protectedUserKey[1].encryptedString,
hint,
newPasswordHint,
orgSsoIdentifier,
keysRequest,
kdfConfig.kdfType, // kdfConfig is always DEFAULT_KDF_CONFIG (see InputPasswordComponent)
kdfConfig.kdfType,
kdfConfig.iterations,
);
@@ -85,14 +85,14 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
// User now has a password so update account decryption options in state
await this.updateAccountDecryptionProperties(masterKey, kdfConfig, protectedUserKey, userId);
await this.updateAccountDecryptionProperties(newMasterKey, kdfConfig, protectedUserKey, userId);
await this.keyService.setPrivateKey(keyPair[1].encryptedString, userId);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId);
if (resetPasswordAutoEnroll) {
await this.handleResetPasswordAutoEnroll(serverMasterKeyHash, orgId, userId);
await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId);
}
}
@@ -127,7 +127,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
private async updateAccountDecryptionProperties(
masterKey: MasterKey,
kdfConfig: PBKDF2KdfConfig,
kdfConfig: KdfConfig,
protectedUserKey: [UserKey, EncString],
userId: UserId,
) {

View File

@@ -13,11 +13,12 @@
</app-callout>
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[primaryButtonText]="{ key: 'createAccount' }"
[flow]="inputPasswordFlow"
[email]="email"
[userId]="userId"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[primaryButtonText]="{ key: 'createAccount' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
></auth-input-password>
</ng-container>

View File

@@ -3,7 +3,7 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
@@ -36,7 +36,7 @@ import {
imports: [CommonModule, InputPasswordComponent, JslibModule],
})
export class SetPasswordJitComponent implements OnInit {
protected InputPasswordFlow = InputPasswordFlow;
protected inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAuthedUser;
protected email: string;
protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions;
protected orgId: string;
@@ -60,9 +60,9 @@ export class SetPasswordJitComponent implements OnInit {
) {}
async ngOnInit() {
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
this.userId = activeAccount?.id;
this.email = activeAccount?.email;
await this.syncService.fullSync(true);
this.syncLoading = false;
@@ -97,14 +97,12 @@ export class SetPasswordJitComponent implements OnInit {
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const credentials: SetPasswordCredentials = {
...passwordInputResult,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
userId,
userId: this.userId,
};
try {

View File

@@ -2,14 +2,14 @@
// @ts-strict-ignore
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
import { KdfConfig } from "@bitwarden/key-management";
export interface SetPasswordCredentials {
masterKey: MasterKey;
serverMasterKeyHash: string;
localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
hint: string;
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
newPasswordHint: string;
kdfConfig: KdfConfig;
orgSsoIdentifier: string;
orgId: string;
resetPasswordAutoEnroll: boolean;

View File

@@ -18,6 +18,7 @@ export enum FeatureFlag {
SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions",
/* Auth */
PM16117_ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor",
PM9115_TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence",
/* Autofill */
@@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
/* Auth */
[FeatureFlag.PM16117_ChangeExistingPasswordRefactor]: FALSE,
[FeatureFlag.PM9115_TwoFactorExtensionDataPersistence]: FALSE,
/* Billing */