From afbddeaf86d9e4092d262a0ac275cbf21803d00b Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Fri, 16 May 2025 10:41:46 -0700 Subject: [PATCH] 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 --- .../core/services/change-password/index.ts | 1 + .../web-change-password.service.spec.ts | 63 +++ .../web-change-password.service.ts | 34 ++ apps/web/src/app/auth/core/services/index.ts | 1 + .../web-registration-finish.service.spec.ts | 28 +- .../settings/change-password.component.ts | 5 +- .../password-settings.component.html | 10 + .../password-settings.component.ts | 35 ++ .../security/security-routing.module.ts | 27 +- .../settings/security/security.component.html | 2 +- .../settings/security/security.component.ts | 9 + .../complete-trial-initiation.component.html | 177 ++++---- .../complete-trial-initiation.component.ts | 5 +- apps/web/src/app/core/core.module.ts | 13 + apps/web/src/locales/en/messages.json | 4 +- .../src/services/jslib-services.module.ts | 11 + .../change-password.component.html | 20 + .../change-password.component.ts | 110 +++++ .../change-password.service.abstraction.ts | 36 ++ .../default-change-password.service.spec.ts | 177 ++++++++ .../default-change-password.service.ts | 59 +++ libs/auth/src/angular/index.ts | 5 + .../input-password.component.html | 36 +- .../input-password.component.ts | 411 ++++++++++++++---- .../angular/input-password/input-password.mdx | 169 +++++-- .../input-password/input-password.stories.ts | 94 +++- .../input-password/password-input-result.ts | 19 +- ...efault-registration-finish.service.spec.ts | 14 +- .../default-registration-finish.service.ts | 6 +- .../registration-finish.component.html | 2 +- .../registration-finish.component.ts | 3 +- .../default-set-password-jit.service.spec.ts | 14 +- .../default-set-password-jit.service.ts | 26 +- .../set-password-jit.component.html | 5 +- .../set-password-jit.component.ts | 14 +- .../set-password-jit.service.abstraction.ts | 12 +- libs/common/src/enums/feature-flag.enum.ts | 2 + 37 files changed, 1349 insertions(+), 310 deletions(-) create mode 100644 apps/web/src/app/auth/core/services/change-password/index.ts create mode 100644 apps/web/src/app/auth/core/services/change-password/web-change-password.service.spec.ts create mode 100644 apps/web/src/app/auth/core/services/change-password/web-change-password.service.ts create mode 100644 apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html create mode 100644 apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts create mode 100644 libs/auth/src/angular/change-password/change-password.component.html create mode 100644 libs/auth/src/angular/change-password/change-password.component.ts create mode 100644 libs/auth/src/angular/change-password/change-password.service.abstraction.ts create mode 100644 libs/auth/src/angular/change-password/default-change-password.service.spec.ts create mode 100644 libs/auth/src/angular/change-password/default-change-password.service.ts diff --git a/apps/web/src/app/auth/core/services/change-password/index.ts b/apps/web/src/app/auth/core/services/change-password/index.ts new file mode 100644 index 00000000000..9b2aa1c0143 --- /dev/null +++ b/apps/web/src/app/auth/core/services/change-password/index.ts @@ -0,0 +1 @@ +export * from "./web-change-password.service"; diff --git a/apps/web/src/app/auth/core/services/change-password/web-change-password.service.spec.ts b/apps/web/src/app/auth/core/services/change-password/web-change-password.service.spec.ts new file mode 100644 index 00000000000..45abfe4720a --- /dev/null +++ b/apps/web/src/app/auth/core/services/change-password/web-change-password.service.spec.ts @@ -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; + let masterPasswordApiService: MockProxy; + let masterPasswordService: MockProxy; + let userKeyRotationService: MockProxy; + + 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(); + masterPasswordApiService = mock(); + masterPasswordService = mock(); + userKeyRotationService = mock(); + + 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); + }); + }); +}); diff --git a/apps/web/src/app/auth/core/services/change-password/web-change-password.service.ts b/apps/web/src/app/auth/core/services/change-password/web-change-password.service.ts new file mode 100644 index 00000000000..b75aef0f1fc --- /dev/null +++ b/apps/web/src/app/auth/core/services/change-password/web-change-password.service.ts @@ -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 { + await this.userKeyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( + currentPassword, + newPassword, + user, + newPasswordHint, + ); + } +} diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index 11c8dd98872..5539e3b76ea 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -1,3 +1,4 @@ +export * from "./change-password"; export * from "./login"; export * from "./login-decryption-options"; export * from "./webauthn-login"; diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts index edce551342e..fe3b0837aa8 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts @@ -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], diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index f1ba9281f69..1d95a498694 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -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", diff --git a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html new file mode 100644 index 00000000000..94cf08b5871 --- /dev/null +++ b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html @@ -0,0 +1,10 @@ +
+

{{ "changeMasterPassword" | i18n }}

+
+ +
+ {{ "loggedOutWarning" | i18n }} + +
+ + diff --git a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts new file mode 100644 index 00000000000..ee30543fba2 --- /dev/null +++ b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts @@ -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; + } + } +} diff --git a/apps/web/src/app/auth/settings/security/security-routing.module.ts b/apps/web/src/app/auth/settings/security/security-routing.module.ts index 6ed21605184..14d4aab8a36 100644 --- a/apps/web/src/app/auth/settings/security/security-routing.module.ts +++ b/apps/web/src/app/auth/settings/security/security-routing.module.ts @@ -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" }, }, { diff --git a/apps/web/src/app/auth/settings/security/security.component.html b/apps/web/src/app/auth/settings/security/security.component.html index 6bd7c1daf36..355a33d4427 100644 --- a/apps/web/src/app/auth/settings/security/security.component.html +++ b/apps/web/src/app/auth/settings/security/security.component.html @@ -1,7 +1,7 @@ - {{ "masterPassword" | i18n }} + {{ "masterPassword" | i18n }} {{ "twoStepLogin" | i18n }} {{ "devices" | i18n }} diff --git a/apps/web/src/app/auth/settings/security/security.component.ts b/apps/web/src/app/auth/settings/security/security.component.ts index 4f70a19e378..41b1af17abb 100644 --- a/apps/web/src/app/auth/settings/security/security.component.ts +++ b/apps/web/src/app/auth/settings/security/security.component.ts @@ -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"; + } } } diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html index 078c926891a..7f093842b6a 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html @@ -1,89 +1,100 @@ -
- -
- + + + + + + + + +} diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts index 93f2bc021cd..215035a0d16 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts @@ -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 { diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 48e884f252c..e812edd8f32 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -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({ diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index cf2174cc1db..d526b2b76ff 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 920d35a1017..a8638efba18 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -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({ diff --git a/libs/auth/src/angular/change-password/change-password.component.html b/libs/auth/src/angular/change-password/change-password.component.html new file mode 100644 index 00000000000..fff873225be --- /dev/null +++ b/libs/auth/src/angular/change-password/change-password.component.html @@ -0,0 +1,20 @@ +@if (initializing) { + + {{ "loading" | i18n }} +} @else { + + +} diff --git a/libs/auth/src/angular/change-password/change-password.component.ts b/libs/auth/src/angular/change-password/change-password.component.ts new file mode 100644 index 00000000000..51c4d03d16f --- /dev/null +++ b/libs/auth/src/angular/change-password/change-password.component.ts @@ -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; + } + } +} diff --git a/libs/auth/src/angular/change-password/change-password.service.abstraction.ts b/libs/auth/src/angular/change-password/change-password.service.abstraction.ts new file mode 100644 index 00000000000..b036db439f8 --- /dev/null +++ b/libs/auth/src/angular/change-password/change-password.service.abstraction.ts @@ -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; + + /** + * 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; +} diff --git a/libs/auth/src/angular/change-password/default-change-password.service.spec.ts b/libs/auth/src/angular/change-password/default-change-password.service.spec.ts new file mode 100644 index 00000000000..ab993859d70 --- /dev/null +++ b/libs/auth/src/angular/change-password/default-change-password.service.spec.ts @@ -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; + let masterPasswordApiService: MockProxy; + let masterPasswordService: MockProxy; + + 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(); + masterPasswordApiService = mock(); + masterPasswordService = mock(); + + 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", + ); + }); + }); +}); diff --git a/libs/auth/src/angular/change-password/default-change-password.service.ts b/libs/auth/src/angular/change-password/default-change-password.service.ts new file mode 100644 index 00000000000..315f979aad9 --- /dev/null +++ b/libs/auth/src/angular/change-password/default-change-password.service.ts @@ -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 { + 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"); + } + } +} diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 91d34a34838..f4f6cc71a42 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -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"; diff --git a/libs/auth/src/angular/input-password/input-password.component.html b/libs/auth/src/angular/input-password/input-password.component.html index 39995f9f44f..8955a7b40b1 100644 --- a/libs/auth/src/angular/input-password/input-password.component.html +++ b/libs/auth/src/angular/input-password/input-password.component.html @@ -6,8 +6,8 @@ {{ "currentMasterPass" | i18n }} @@ -58,12 +58,12 @@ - {{ "confirmMasterPassword" | i18n }} + {{ "confirmNewMasterPass" | i18n }}