Bitwarden client applications (web, browser extension, desktop, and cli)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

722 lines
24 KiB

import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators, FormControl } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
PasswordStrengthScore,
PasswordStrengthV2Component,
} from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
// 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 {
AsyncActionsModule,
ButtonModule,
CheckboxModule,
DialogService,
FormFieldModule,
IconButtonModule,
InputModule,
LinkModule,
ToastService,
Translation,
} from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
DEFAULT_KDF_CONFIG,
KdfConfig,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "../../../../components/src/shared";
import { PasswordCalloutComponent } from "../password-callout/password-callout.component";
import { compareInputs, ValidationGoal } from "../validators/compare-inputs.validator";
import { PasswordInputResult } from "./password-input-result";
/**
* Determines which form elements will be displayed in the UI
* and which cryptographic keys will be created and emitted.
*/
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum InputPasswordFlow {
/**
* Form Fields: `[newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]`
*
* Note: this flow does not receive an active account `userId` as an `@Input`
*/
SetInitialPasswordAccountRegistration,
/**
* Form Fields: `[newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]`
*/
SetInitialPasswordAuthedUser,
/**
* Form Fields: `[currentPassword, newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]`
*/
ChangePassword,
/**
* Form Fields: `[currentPassword, newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches, rotateUserKey]`
*/
ChangePasswordWithOptionalUserKeyRotation,
/**
* This flow is used when a user changes the password for another user's account, such as:
* - Emergency Access Takeover
* - Account Recovery
*
* Since both of those processes use a dialog, the `InputPasswordComponent` will not display
* buttons for `ChangePasswordDelegation` because the dialog will have its own buttons.
*
* Form Fields: `[newPassword, newPasswordConfirm]`
*
* Note: this flow does not receive an active account `userId` or `email` as `@Input`s
*/
ChangePasswordDelegation,
}
interface InputPasswordForm {
currentPassword?: FormControl<string>;
newPassword: FormControl<string>;
newPasswordConfirm: FormControl<string>;
newPasswordHint?: FormControl<string>;
checkForBreaches?: FormControl<boolean>;
rotateUserKey?: FormControl<boolean>;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "auth-input-password",
templateUrl: "./input-password.component.html",
imports: [
AsyncActionsModule,
ButtonModule,
CheckboxModule,
FormFieldModule,
IconButtonModule,
InputModule,
JslibModule,
PasswordCalloutComponent,
PasswordStrengthV2Component,
ReactiveFormsModule,
LinkModule,
SharedModule,
],
})
export class InputPasswordComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(PasswordStrengthV2Component) passwordStrengthComponent:
| PasswordStrengthV2Component
| undefined = undefined;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onPasswordFormSubmit = new EventEmitter<PasswordInputResult>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onSecondaryButtonClick = new EventEmitter<void>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() isSubmitting = new EventEmitter<boolean>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) flow!: InputPasswordFlow;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: (val: string) => val?.trim().toLowerCase() }) email?: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() userId?: UserId;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() loading = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() inlineButtons = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() primaryButtonText?: Translation;
protected primaryButtonTextStr: string = "";
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() secondaryButtonText?: Translation;
protected secondaryButtonTextStr: string = "";
protected InputPasswordFlow = InputPasswordFlow;
private kdfConfig: KdfConfig | null = null;
private minHintLength = 0;
protected maxHintLength = 50;
protected minPasswordLength = Utils.minimumPasswordLength;
protected minPasswordMsg = "";
protected passwordStrengthScore: PasswordStrengthScore = 0;
protected showErrorSummary = false;
protected showPassword = false;
protected formGroup = this.formBuilder.nonNullable.group<InputPasswordForm>(
{
newPassword: this.formBuilder.nonNullable.control("", [
Validators.required,
Validators.minLength(this.minPasswordLength),
]),
newPasswordConfirm: this.formBuilder.nonNullable.control("", Validators.required),
},
{
validators: [
compareInputs(
ValidationGoal.InputsShouldMatch,
"newPassword",
"newPasswordConfirm",
this.i18nService.t("masterPassDoesntMatch"),
),
],
},
);
protected get minPasswordLengthMsg() {
if (
this.masterPasswordPolicyOptions != undefined &&
this.masterPasswordPolicyOptions.minLength > 0
) {
return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength);
} else {
return this.i18nService.t("characterMinimum", this.minPasswordLength);
}
}
constructor(
private auditService: AuditService,
private cipherService: CipherService,
private configService: ConfigService,
private dialogService: DialogService,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private kdfConfigService: KdfConfigService,
private keyService: KeyService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
private toastService: ToastService,
private validationService: ValidationService,
) {}
ngOnInit(): void {
this.addFormFieldsIfNecessary();
this.setButtonText();
}
private addFormFieldsIfNecessary() {
if (this.flow !== InputPasswordFlow.ChangePasswordDelegation) {
this.formGroup.addControl(
"newPasswordHint",
this.formBuilder.nonNullable.control("", [
Validators.minLength(this.minHintLength),
Validators.maxLength(this.maxHintLength),
]),
);
this.formGroup.addValidators([
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"newPassword",
"newPasswordHint",
this.i18nService.t("hintEqualsPassword"),
),
]);
this.formGroup.addControl("checkForBreaches", this.formBuilder.nonNullable.control(true));
}
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
this.formGroup.addControl(
"currentPassword",
this.formBuilder.nonNullable.control("", Validators.required),
);
this.formGroup.addValidators([
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"currentPassword",
"newPassword",
this.i18nService.t("yourNewPasswordCannotBeTheSameAsYourCurrentPassword"),
),
]);
}
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
this.formGroup.addControl("rotateUserKey", this.formBuilder.nonNullable.control(false));
}
}
private setButtonText() {
if (this.primaryButtonText) {
this.primaryButtonTextStr = this.i18nService.t(
this.primaryButtonText.key,
...(this.primaryButtonText?.placeholders ?? []),
);
}
if (this.secondaryButtonText) {
this.secondaryButtonTextStr = this.i18nService.t(
this.secondaryButtonText.key,
...(this.secondaryButtonText?.placeholders ?? []),
);
}
}
submit = async (): Promise<PasswordInputResult | undefined> => {
try {
this.isSubmitting.emit(true);
this.verifyFlow();
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
this.showErrorSummary = true;
return;
}
const currentPassword = this.formGroup.controls.currentPassword?.value ?? "";
const newPassword = this.formGroup.controls.newPassword.value;
const newPasswordHint = this.formGroup.controls.newPasswordHint?.value ?? "";
const checkForBreaches = this.formGroup.controls.checkForBreaches?.value ?? true;
if (this.flow === InputPasswordFlow.ChangePasswordDelegation) {
return await this.handleChangePasswordDelegationFlow(newPassword);
}
if (!this.email) {
throw new Error("Email not found.");
}
// 1. Determine kdfConfig
if (this.flow === InputPasswordFlow.SetInitialPasswordAccountRegistration) {
this.kdfConfig = DEFAULT_KDF_CONFIG;
} else {
if (!this.userId) {
throw new Error("userId not found.");
}
this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
}
if (this.kdfConfig == null) {
throw new Error("KdfConfig not found.");
}
const salt =
this.userId != null
? await firstValueFrom(this.masterPasswordService.saltForUser$(this.userId))
: this.masterPasswordService.emailToSalt(this.email);
if (salt == null) {
throw new Error("Salt not found.");
}
// 2. Verify current password is correct (if necessary)
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
const currentPasswordVerified = await this.verifyCurrentPassword(
currentPassword,
this.kdfConfig,
);
if (!currentPasswordVerified) {
return;
}
}
// 3. Verify new password
const newPasswordVerified = await this.verifyNewPassword(
newPassword,
this.passwordStrengthScore,
checkForBreaches,
);
if (!newPasswordVerified) {
return;
}
const newApisFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
);
if (newApisFlagEnabled) {
// 4. Build a PasswordInputResult object
const passwordInputResult: PasswordInputResult = {
currentPassword,
newPassword,
kdfConfig: this.kdfConfig,
salt,
newPasswordHint,
rotateUserKey: this.formGroup.controls.rotateUserKey?.value ?? false,
};
// 5. Emit and return PasswordInputResult object
this.onPasswordFormSubmit.emit(passwordInputResult);
return passwordInputResult;
}
// 4. Create cryptographic keys and build a PasswordInputResult object
const newMasterKey = await this.keyService.makeMasterKey(
newPassword,
this.email,
this.kdfConfig,
);
const newServerMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const newLocalMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
newMasterKey,
HashPurpose.LocalAuthorization,
);
const passwordInputResult: PasswordInputResult = {
newPassword,
salt,
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig: this.kdfConfig,
};
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
const currentMasterKey = await this.keyService.makeMasterKey(
currentPassword,
this.email,
this.kdfConfig,
);
const currentServerMasterKeyHash = await this.keyService.hashMasterKey(
currentPassword,
currentMasterKey,
HashPurpose.ServerAuthorization,
);
const currentLocalMasterKeyHash = await this.keyService.hashMasterKey(
currentPassword,
currentMasterKey,
HashPurpose.LocalAuthorization,
);
passwordInputResult.currentPassword = currentPassword;
passwordInputResult.currentMasterKey = currentMasterKey;
passwordInputResult.currentServerMasterKeyHash = currentServerMasterKeyHash;
passwordInputResult.currentLocalMasterKeyHash = currentLocalMasterKeyHash;
}
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value;
}
// 5. Emit cryptographic keys and other password related properties
this.onPasswordFormSubmit.emit(passwordInputResult);
return passwordInputResult;
} catch (e) {
this.validationService.showError(e);
} finally {
this.isSubmitting.emit(false);
}
};
/**
* We cannot mark the `userId` or `email` `@Input`s as required because some flows
* require them, and some do not. This method enforces that:
* - Certain flows MUST have a `userId` and/or `email` passed down
* - Certain flows must NOT have a `userId` and/or `email` passed down
*/
private verifyFlow() {
/** UserId checks */
// These flows require that an active account userId must NOT be passed down
if (
this.flow === InputPasswordFlow.SetInitialPasswordAccountRegistration ||
this.flow === InputPasswordFlow.ChangePasswordDelegation
) {
if (this.userId) {
throw new Error("There should be no active account userId passed down in a this flow.");
}
}
// All other flows require that an active account userId MUST be passed down
if (
this.flow !== InputPasswordFlow.SetInitialPasswordAccountRegistration &&
this.flow !== InputPasswordFlow.ChangePasswordDelegation
) {
if (!this.userId) {
throw new Error("This flow requires that an active account userId be passed down.");
}
}
/** Email checks */
// This flow requires that an email must NOT be passed down
if (this.flow === InputPasswordFlow.ChangePasswordDelegation) {
if (this.email) {
throw new Error("There should be no email passed down in this flow.");
}
}
// All other flows require that an email MUST be passed down
if (this.flow !== InputPasswordFlow.ChangePasswordDelegation) {
if (!this.email) {
throw new Error("This flow requires that an email be passed down.");
}
}
}
private async handleChangePasswordDelegationFlow(
newPassword: string,
): Promise<PasswordInputResult | undefined> {
const newPasswordVerified = await this.verifyNewPassword(
newPassword,
this.passwordStrengthScore,
false,
);
if (!newPasswordVerified) {
return;
}
const passwordInputResult: PasswordInputResult = {
newPassword,
};
this.onPasswordFormSubmit.emit(passwordInputResult);
return passwordInputResult;
}
/**
* Returns `true` if the current password is correct (it can be used to successfully decrypt
* the masterKeyEncryptedUserKey), `false` otherwise
*/
private async verifyCurrentPassword(
currentPassword: string,
kdfConfig: KdfConfig,
): Promise<boolean> {
if (!this.email) {
throw new Error("Email is required to verify current password.");
}
if (!this.userId) {
throw new Error("userId is required to verify current password.");
}
const currentMasterKey = await this.keyService.makeMasterKey(
currentPassword,
this.email,
kdfConfig,
);
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
currentMasterKey,
this.userId,
);
if (decryptedUserKey == null) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("invalidMasterPassword"),
});
return false;
}
return true;
}
/**
* Returns `true` if the new password is not weak or breached and it passes
* any enforced org policy options, `false` otherwise
*/
private async verifyNewPassword(
newPassword: string,
passwordStrengthScore: PasswordStrengthScore,
checkForBreaches: boolean,
): Promise<boolean> {
// Check if the password is breached, weak, or both
const passwordIsBreached =
checkForBreaches && (await this.auditService.passwordLeaked(newPassword)) > 0;
const passwordIsWeak = passwordStrengthScore != null && passwordStrengthScore < 3;
if (passwordIsBreached && passwordIsWeak) {
const userAcceptedDialog = await this.dialogService.openSimpleDialog({
title: { key: "weakAndExposedMasterPassword" },
content: { key: "weakAndBreachedMasterPasswordDesc" },
type: "warning",
});
if (!userAcceptedDialog) {
return false;
}
} else if (passwordIsWeak) {
const userAcceptedDialog = await this.dialogService.openSimpleDialog({
title: { key: "weakMasterPassword" },
content: { key: "weakMasterPasswordDesc" },
type: "warning",
});
if (!userAcceptedDialog) {
return false;
}
} else if (passwordIsBreached) {
const userAcceptedDialog = await this.dialogService.openSimpleDialog({
title: { key: "exposedMasterPassword" },
content: { key: "exposedMasterPasswordDesc" },
type: "warning",
});
if (!userAcceptedDialog) {
return false;
}
}
// Check if password meets org policy requirements
if (
this.masterPasswordPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
this.passwordStrengthScore,
newPassword,
this.masterPasswordPolicyOptions,
)
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"),
});
return false;
}
return true;
}
protected async rotateUserKeyClicked() {
const rotateUserKeyCtrl = this.formGroup.controls.rotateUserKey;
const rotateUserKey = rotateUserKeyCtrl?.value;
if (rotateUserKey) {
if (!this.userId) {
throw new Error("userId not passed down");
}
const ciphers = await this.cipherService.getAllDecrypted(this.userId);
let hasOldAttachments = false;
if (ciphers != null) {
for (let i = 0; i < ciphers.length; i++) {
if (ciphers[i].organizationId == null && ciphers[i].hasOldAttachments) {
hasOldAttachments = true;
break;
}
}
}
if (hasOldAttachments) {
const learnMore = await this.dialogService.openSimpleDialog({
title: { key: "warning" },
content: { key: "oldAttachmentsNeedFixDesc" },
acceptButtonText: { key: "learnMore" },
cancelButtonText: { key: "close" },
type: "warning",
});
if (learnMore) {
this.platformUtilsService.launchUri(
"https://bitwarden.com/help/attachments/#add-storage-space",
);
}
rotateUserKeyCtrl.setValue(false);
return;
}
const result = await this.dialogService.openSimpleDialog({
title: { key: "rotateEncKeyTitle" },
content:
this.i18nService.t("updateEncryptionKeyWarning") +
" " +
this.i18nService.t("updateEncryptionKeyAccountExportWarning") +
" " +
this.i18nService.t("rotateEncKeyConfirmation"),
type: "warning",
});
if (!result) {
rotateUserKeyCtrl.setValue(false);
}
}
}
protected getPasswordStrengthScore(score: PasswordStrengthScore) {
this.passwordStrengthScore = score;
}
protected async generatePassword() {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
this.formGroup.patchValue({
newPassword: await this.passwordGenerationService.generatePassword(options),
});
if (!this.passwordStrengthComponent) {
throw new Error("PasswordStrengthComponent is not initialized");
}
this.passwordStrengthComponent.updatePasswordStrength(
this.formGroup.controls.newPassword.value,
);
}
protected copy() {
const value = this.formGroup.value.newPassword;
if (value == null) {
return;
}
this.platformUtilsService.copyToClipboard(value, { window: window });
this.toastService.showToast({
variant: "info",
title: "",
message: this.i18nService.t("valueCopied", this.i18nService.t("password")),
});
}
}