1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +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"