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

feat(change-password): [PM-18720] (#5319) Change Password Implementation for Non Dialog Cases (#15319)

* feat(change-password-component): Change Password Update [18720] - Very close to complete.

* fix(policy-enforcement): [PM-21085] Fix Bug with Policy Enforcement - Removed temp code to force the state I need to verify correctness.

* fix(policy-enforcement): [PM-21085] Fix Bug with Policy Enforcement - Recover account working with change password component.

* fix(policy-enforcement): [PM-21085] Fix Bug with Policy Enforcement - Made code more dry.

* fix(change-password-component): Change Password Update [18720] - Updates to routing and the extension. Extension is still a wip.

* fix(change-password-component): Change Password Update [18720] - Extension routing changes.

* feat(change-password-component): Change Password Update [18720] - More extension work

* feat(change-password-component): Change Password Update [18720] - Pausing work for now while we wait for product to hear back.

* feat(change-password-component): Change Password Update [18720] - Removed duplicated anon layouts.

* feat(change-password-component): Change Password Update [18720] - Tidied up code.

* feat(change-password-component): Change Password Update [18720] - Small fixes to the styling

* feat(change-password-component): Change Password Update [18720] - Adding more content for the routing.

* feat(change-password-component): Change Password Update [18720] - Removed circular loop for now.

* feat(change-password-component): Change Password Update [18720] - Made comments regarding the change password routing complexities with change-password and auth guard.

* feat(change-password-component): Change Password Update [18720] - Undid some changes because they will be conflicts later on.

* feat(change-password-component): Change Password Update [18720] - Small directive change.

* feat(change-password-component): Change Password Update [18720] - Small changes and added some clarification on where I'm blocked

* feat(change-password-component): Change Password Update [18720] - Org invite is seemingly working, found one bug to iron out.

* refactor(change-password-component): Change Password Update [18720] - Fixed up policy service to be made more clear.

* docs(change-password-component): Change Password Update [18720] - Updated documentation.

* refactor(change-password-component): Change Password Update [18720] - Routing changes and policy service changes.

* fix(change-password-component): Change Password Update [18720] - Wrapping up changes.

* feat(change-password-component): Change Password Update [18720] - Should be working fully

* feat(change-password-component): Change Password Update [18720] - Found a bug, working on password policy being present on login.

* feat(change-password-component): Change Password Update [18720] - Turned on auth guard on other clients for change-password route.

* feat(change-password-component): Change Password Update [18720] - Committing intermediate changes.

* feat(change-password-component): Change Password Update [18720] - The master password policy endpoint has been added! Should be working. Testing now.

* feat(change-password-component): Change Password Update [18720] - Minor fixes.

* feat(change-password-component): Change Password Update [18720] - Undid naming change.

* feat(change-password-component): Change Password Update [18720] - Removed comment.

* feat(change-password-component): Change Password Update [18720] - Removed unneeded code.

* fix(change-password-component): Change Password Update [18720] - Took org invite state out of service and made it accessible.

* fix(change-password-component): Change Password Update [18720] - Small changes.

* fix(change-password-component): Change Password Update [18720] - Split up org invite service into client specific implementations and have them injected into clients properly

* feat(change-password-component): Change Password Update [18720] - Stopping work and going to switch to a new branch to pare down some of the solutions that were made to get this over the finish line

* feat(change-password-component): Change Password Update [18720] - Started to remove functionality in the login.component and the password login strategy.

* feat(change-password-component): Change Password Update [18720] - Removed more unneded changes.

* feat(change-password-component): Change Password Update [18720] - Change password clearing state working properly.

* fix(change-password-component): Change Password Update [18720] - Added docs and moved web implementation.

* comments(change-password-component): Change Password Update [18720] - Added more notes.

* test(change-password-component): Change Password Update [18720] - Added in tests for policy service.

* comment(change-password-component): Change Password Update [18720] - Updated doc with correct ticket number.

* comment(change-password-component): Change Password Update [18720] - Fixed doc.

* test(change-password-component): Change Password Update [18720] - Fixed tests.

* test(change-password-component): Change Password Update [18720] - Fixed linting errors. Have more tests to fix.

* test(change-password-component): Change Password Update [18720] - Added back in ignore for typesafety.

* fix(change-password-component): Change Password Update [18720] - Fixed other type issues.

* test(change-password-component): Change Password Update [18720] - Fixed tests.

* test(change-password-component): Change Password Update [18720] - Fixed more tests.

* test(change-password-component): Change Password Update [18720] - Fixed tiny duplicate code.

* fix(change-password-component): Change Password Update [18720] - Fixed desktop component.

* fix(change-password-component): Change Password Update [18720] - Removed unused code

* fix(change-password-component): Change Password Update [18720] - Fixed locales.

* fix(change-password-component): Change Password Update [18720] - Removed tracing.

* fix(change-password-component): Change Password Update [18720] - Removed duplicative services module entry.

* fix(change-password-component): Change Password Update [18720] - Added comment.

* fix(change-password-component): Change Password Update [18720] - Fixed unneeded call in two factor to get user id.

* fix(change-password-component): Change Password Update [18720] - Fixed a couple of tiny things.

* fix(change-password-component): Change Password Update [18720] - Added comment for later fix.

* fix(change-password-component): Change Password Update [18720] - Fixed linting error.

* PM-18720 - AuthGuard - move call to get isChangePasswordFlagOn down after other conditions for efficiency.

* PM-18720 - PasswordLoginStrategy tests - test new feature flagged combine org invite policies logic for weak password evaluation.

* PM-18720 - CLI - fix dep issue

* PM-18720 - ChangePasswordComp - extract change password warning up out of input password component

* PM-18720 - InputPassword - remove unused dependency.

* PM-18720 - ChangePasswordComp - add callout dep

* PM-18720 - Revert all anon-layout changes

* PM-18720 - Anon Layout - finish reverting changes.

* PM-18720 - WIP move of change password out of libs/auth

* PM-18720 - Clean up remaining imports from moving change password out of libs/auth

* PM-18720 - Add change-password barrel file for better import grouping

* PM-18720 - Change Password comp - restore maxWidth

* PM-18720 - After merge, fix errors

* PM-18720 - Desktop - fix api service import

* PM-18720 - NDV - fix routing.

* PM-18720 - Change Password Comp - add logout service todo

* PM-18720 - PasswordSettings - per feedback, component is already feature flagged behind PM16117_ChangeExistingPasswordRefactor so we can just delete the replaced callout (new text is in change-password comp)

* PM-18720 - Routing Modules - properly flag new component behind feature flag.

* PM-18720 - SSO Login Strategy - fix config service import since it is now in shared deps from main merge.

* PM-18720 - Fix SSO login strategy tests

* PM-18720 - Default Policy Service - address AC PR feedback

---------

Co-authored-by: Jared Snider <jsnider@bitwarden.com>
Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
This commit is contained in:
Patrick-Pimentel-Bitwarden
2025-07-10 09:08:25 -04:00
committed by GitHub
parent ec015bd253
commit 1f60bcdcc0
70 changed files with 1301 additions and 495 deletions

View File

@@ -37,15 +37,15 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
protected destroy$ = new Subject<void>();
constructor(
protected accountService: AccountService,
protected dialogService: DialogService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected keyService: KeyService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected messagingService: MessagingService,
protected platformUtilsService: PlatformUtilsService,
protected policyService: PolicyService,
protected dialogService: DialogService,
protected kdfConfigService: KdfConfigService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected accountService: AccountService,
protected toastService: ToastService,
) {}

View File

@@ -14,7 +14,6 @@ import {
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -58,38 +57,37 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
ForceSetPasswordReason = ForceSetPasswordReason;
constructor(
accountService: AccountService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
i18nService: I18nService,
keyService: KeyService,
messagingService: MessagingService,
platformUtilsService: PlatformUtilsService,
private policyApiService: PolicyApiServiceAbstraction,
policyService: PolicyService,
protected accountService: AccountService,
protected dialogService: DialogService,
protected encryptService: EncryptService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected keyService: KeyService,
protected masterPasswordApiService: MasterPasswordApiService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected messagingService: MessagingService,
protected organizationApiService: OrganizationApiServiceAbstraction,
protected organizationUserApiService: OrganizationUserApiService,
protected platformUtilsService: PlatformUtilsService,
protected policyApiService: PolicyApiServiceAbstraction,
protected policyService: PolicyService,
protected route: ActivatedRoute,
protected router: Router,
private masterPasswordApiService: MasterPasswordApiService,
private apiService: ApiService,
private syncService: SyncService,
private route: ActivatedRoute,
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private ssoLoginService: SsoLoginServiceAbstraction,
dialogService: DialogService,
kdfConfigService: KdfConfigService,
private encryptService: EncryptService,
protected ssoLoginService: SsoLoginServiceAbstraction,
protected syncService: SyncService,
protected toastService: ToastService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
) {
super(
accountService,
dialogService,
i18nService,
kdfConfigService,
keyService,
masterPasswordService,
messagingService,
platformUtilsService,
policyService,
dialogService,
kdfConfigService,
masterPasswordService,
accountService,
toastService,
);
}

View File

@@ -52,15 +52,15 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
toastService: ToastService,
) {
super(
accountService,
dialogService,
i18nService,
kdfConfigService,
keyService,
masterPasswordService,
messagingService,
platformUtilsService,
policyService,
dialogService,
kdfConfigService,
masterPasswordService,
accountService,
toastService,
);
}

View File

@@ -64,15 +64,15 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
toastService: ToastService,
) {
super(
accountService,
dialogService,
i18nService,
kdfConfigService,
keyService,
masterPasswordService,
messagingService,
platformUtilsService,
policyService,
dialogService,
kdfConfigService,
masterPasswordService,
accountService,
toastService,
);
}

View File

@@ -47,9 +47,6 @@ export const authGuard: CanActivateFn = async (
const isSetInitialPasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
const isChangePasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
// User JIT provisioned into a master-password-encryption org
if (
@@ -114,6 +111,10 @@ export const authGuard: CanActivateFn = async (
return router.createUrlTree([route]);
}
const isChangePasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
// Post- Account Recovery or Weak Password on login
if (
(forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset ||

View File

@@ -0,0 +1,28 @@
@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 {
<bit-callout
*ngIf="this.forceSetPasswordReason !== ForceSetPasswordReason.AdminForcePasswordReset"
type="warning"
>{{ "changePasswordWarning" | i18n }}</bit-callout
>
<auth-input-password
[flow]="inputPasswordFlow"
[email]="email"
[userId]="userId"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[inlineButtons]="true"
[primaryButtonText]="{ key: 'changeMasterPassword' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
[secondaryButtonText]="secondaryButtonText()"
(onSecondaryButtonClick)="logOut()"
>
</auth-input-password>
}

View File

@@ -0,0 +1,202 @@
import { Component, Input, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
InputPasswordComponent,
InputPasswordFlow,
PasswordInputResult,
} from "@bitwarden/auth/angular";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import {
AnonLayoutWrapperDataService,
DialogService,
ToastService,
Icons,
CalloutComponent,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { ChangePasswordService } from "./change-password.service.abstraction";
/**
* Change Password Component
*
* NOTE: The change password component uses the input-password component which will show the
* current password input form in some flows, although it could be left off. This is intentional
* and by design to maintain a strong security posture as some flows could have the user
* end up at a change password without having one before.
*/
@Component({
selector: "auth-change-password",
templateUrl: "change-password.component.html",
imports: [InputPasswordComponent, I18nPipe, CalloutComponent],
})
export class ChangePasswordComponent implements OnInit {
@Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword;
activeAccount: Account | null = null;
email?: string;
userId?: UserId;
masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
initializing = true;
submitting = false;
formPromise?: Promise<any>;
forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
protected readonly ForceSetPasswordReason = ForceSetPasswordReason;
constructor(
private accountService: AccountService,
private changePasswordService: ChangePasswordService,
private i18nService: I18nService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private organizationInviteService: OrganizationInviteService,
private messagingService: MessagingService,
private policyService: PolicyService,
private toastService: ToastService,
private syncService: SyncService,
private dialogService: DialogService,
private logService: LogService,
) {}
async ngOnInit() {
this.activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (!this.activeAccount) {
throw new Error("No active active account found while trying to change passwords.");
}
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.forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(this.userId),
);
if (this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset) {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageIcon: Icons.LockIcon,
pageTitle: { key: "updateMasterPassword" },
pageSubtitle: { key: "accountRecoveryUpdateMasterPasswordSubtitle" },
});
} else if (this.forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword) {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageIcon: Icons.LockIcon,
pageTitle: { key: "updateMasterPassword" },
pageSubtitle: { key: "updateMasterPasswordSubtitle" },
maxWidth: "lg",
});
}
this.initializing = false;
}
async logOut() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
await this.organizationInviteService.clearOrganizationInvitation();
if (this.changePasswordService.clearDeeplinkState) {
await this.changePasswordService.clearDeeplinkState();
}
// TODO: PM-23515 eventually use the logout service instead of messaging service once it is available without circular dependencies
this.messagingService.send("logout");
}
}
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 ||
passwordInputResult.newPasswordHint == null
) {
throw new Error("currentPassword or newPasswordHint 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");
}
if (this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset) {
await this.changePasswordService.changePasswordForAccountRecovery(
passwordInputResult,
this.userId,
);
} else {
await this.changePasswordService.changePassword(passwordInputResult, this.userId);
}
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("masterPasswordChanged"),
});
// TODO: PM-23515 eventually use the logout service instead of messaging service once it is available without circular dependencies
this.messagingService.send("logout");
}
} catch (error) {
this.logService.error(error);
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("errorOccurred"),
});
} finally {
this.submitting = false;
}
}
/**
* Shows the logout button in the case of admin force reset password or weak password upon login.
*/
protected secondaryButtonText(): { key: string } | undefined {
return this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset ||
this.forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword
? { key: "logOut" }
: undefined;
}
}

View File

@@ -0,0 +1,62 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { 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 | null,
): 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 PUTed to `"/accounts/update-temp-password"` so that the
* ForcePasswordReset gets set to false.
* @param passwordInputResult
* @param userId
*/
abstract changePasswordForAccountRecovery(
passwordInputResult: PasswordInputResult,
userId: UserId,
): Promise<void>;
/**
* Optional method that will clear up any deep link state.
* - Currently only used on the web change password service.
*/
clearDeeplinkState?: () => Promise<void>;
}

View File

@@ -0,0 +1,217 @@
import { mock, MockProxy } from "jest-mock-extended";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordInputResult } 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 { 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 { 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 = undefined;
// Act
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow(
"invalid PasswordInputResult credentials, could not change password",
);
});
it("should throw if a currentServerMasterKeyHash was not found", async () => {
// Arrange
const incorrectPasswordInputResult = { ...passwordInputResult };
incorrectPasswordInputResult.currentServerMasterKeyHash = undefined;
// Act
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow(
"invalid PasswordInputResult credentials, could not change password",
);
});
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",
);
});
});
describe("changePasswordForAccountRecovery()", () => {
it("should call the putUpdateTempPassword() API method with the correct UpdateTempPasswordRequest credentials", async () => {
// Act
await sut.changePasswordForAccountRecovery(passwordInputResult, userId);
// Assert
expect(masterPasswordApiService.putUpdateTempPassword).toHaveBeenCalledWith(
expect.objectContaining({
newMasterPasswordHash: passwordInputResult.newServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
key: newMasterKeyEncryptedUserKey[1].encryptedString,
}),
);
});
it("should throw an error if user key decryption fails", async () => {
// Arrange
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null);
// Act
const testFn = sut.changePasswordForAccountRecovery(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("Could not decrypt user key");
});
it("should throw an error if putUpdateTempPassword() fails", async () => {
// Arrange
masterPasswordApiService.putUpdateTempPassword.mockRejectedValueOnce(new Error("error"));
// Act
const testFn = sut.changePasswordForAccountRecovery(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("Could not change password");
expect(masterPasswordApiService.putUpdateTempPassword).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,112 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordInputResult } 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 { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
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 { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { ChangePasswordService } from "./change-password.service.abstraction";
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");
}
private async preparePasswordChange(
passwordInputResult: PasswordInputResult,
userId: UserId | null,
request: PasswordRequest | UpdateTempPasswordRequest,
): Promise<[UserKey, EncString]> {
if (!userId) {
throw new Error("userId not found");
}
if (
!passwordInputResult.currentMasterKey ||
!passwordInputResult.currentServerMasterKeyHash ||
!passwordInputResult.newMasterKey ||
!passwordInputResult.newServerMasterKeyHash ||
passwordInputResult.newPasswordHint == null
) {
throw new Error("invalid PasswordInputResult credentials, could not change password");
}
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
passwordInputResult.currentMasterKey,
userId,
);
if (decryptedUserKey == null) {
throw new Error("Could not decrypt user key");
}
const newKeyValue = await this.keyService.encryptUserKeyWithMasterKey(
passwordInputResult.newMasterKey,
decryptedUserKey,
);
if (request instanceof PasswordRequest) {
request.masterPasswordHash = passwordInputResult.currentServerMasterKeyHash;
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
request.masterPasswordHint = passwordInputResult.newPasswordHint;
} else if (request instanceof UpdateTempPasswordRequest) {
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
request.masterPasswordHint = passwordInputResult.newPasswordHint;
}
return newKeyValue;
}
async changePassword(passwordInputResult: PasswordInputResult, userId: UserId | null) {
const request = new PasswordRequest();
const newMasterKeyEncryptedUserKey = await this.preparePasswordChange(
passwordInputResult,
userId,
request,
);
request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string;
try {
await this.masterPasswordApiService.postPassword(request);
} catch {
throw new Error("Could not change password");
}
}
async changePasswordForAccountRecovery(passwordInputResult: PasswordInputResult, userId: UserId) {
const request = new UpdateTempPasswordRequest();
const newMasterKeyEncryptedUserKey = await this.preparePasswordChange(
passwordInputResult,
userId,
request,
);
request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string;
try {
// TODO: PM-23047 will look to consolidate this into the change password endpoint.
await this.masterPasswordApiService.putUpdateTempPassword(request);
} catch {
throw new Error("Could not change password");
}
}
}

View File

@@ -0,0 +1,3 @@
export * from "./change-password.component";
export * from "./change-password.service.abstraction";
export * from "./default-change-password.service";

View File

@@ -11,6 +11,10 @@ import {
DefaultOrganizationUserApiService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import {
ChangePasswordService,
DefaultChangePasswordService,
} from "@bitwarden/angular/auth/password-management/change-password";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
@@ -29,8 +33,6 @@ import {
TwoFactorAuthComponentService,
TwoFactorAuthEmailComponentService,
TwoFactorAuthWebAuthnComponentService,
ChangePasswordService,
DefaultChangePasswordService,
} from "@bitwarden/auth/angular";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -115,6 +117,8 @@ import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation";
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation";
import { DefaultOrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/default-organization-invite.service";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation";
import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service";
import { TokenService } from "@bitwarden/common/auth/services/token.service";
@@ -1406,16 +1410,20 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultKdfConfigService,
deps: [StateProvider],
}),
safeProvider({
provide: OrganizationInviteService,
useClass: DefaultOrganizationInviteService,
deps: [],
}),
safeProvider({
provide: SetPasswordJitService,
useClass: DefaultSetPasswordJitService,
deps: [
ApiServiceAbstraction,
MasterPasswordApiServiceAbstraction,
KeyService,
EncryptService,
I18nServiceAbstraction,
KdfConfigService,
KeyService,
MasterPasswordApiServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
OrganizationApiServiceAbstraction,
OrganizationUserApiService,