1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 10:13:31 +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({