mirror of
https://github.com/bitwarden/browser
synced 2026-02-19 10:54:00 +00:00
* PM-13632: Enable sign in with passkeys in the browser extension * Refactor component + Icon fix This commit refactors the login-via-webauthn commit as per @JaredSnider-Bitwarden suggestions. It also fixes an existing issue where Icons are not displayed properly on the web vault. Remove old one. Rename the file Working refactor Removed the icon from the component Fixed icons not showing. Changed layout to be 'embedded' * Add tracking links * Update app.module.ts * Remove default Icons on load * Remove login.module.ts * Add env changer to the passkey component * Remove leftover dependencies * PRF Unlock Cleanup and testes * Workaround prf type missing * Fix any type * Undo accidental cleanup to keep PR focused * Undo accidental cleanup to keep PR focused * Cleaned up public interface * Use UserId type * Typed UserId and improved isPrfUnlockAvailable * Rename key and use zero challenge array * logservice * Cleanup rpId handling * Refactor to separate component + icon * Moved the prf unlock service impl. * Fix broken test * fix tests * Use isChromium * Update services.module.ts * missing , in locales * Update desktop-lock-component.service.ts * Fix more desktoptests * Expect a single UnlockOption from IdTokenResponse, but multiple from sync * Missing s * remove catches * Use new control flow in unlock-via-prf.component.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Changed throw behaviour of unlockVaultWithPrf * remove timeout comment * refactired webauthm-prf-unlock.service internally * WebAuthnPrfUnlockServiceAbstraction -> WebAuthnPrfUnlockService * Fixed any and bad import * Fix errors after merge * Added missing PinServiceAbstraction * Fixed format * Removed @Inject() * Fix broken tests after Inject removal * Return userkey instead of setting it * Used input/output signals * removed duplicate MessageSender registration * nit: Made import relative * Disable onPush requirement because it would need refactoring the component * Added feature flag (#17494) * Fixed ById from main * Import feature flag from file * Add missing test providers for MasterPasswordLockComponent Add WebAuthnPrfUnlockService and DialogService mocks to fix test failures caused by UnlockViaPrfComponent dependencies. --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
808 lines
27 KiB
TypeScript
808 lines
27 KiB
TypeScript
import { CommonModule } from "@angular/common";
|
|
import { Component, NgZone, OnDestroy, OnInit } from "@angular/core";
|
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
|
import { Router, ActivatedRoute } from "@angular/router";
|
|
import {
|
|
BehaviorSubject,
|
|
filter,
|
|
firstValueFrom,
|
|
interval,
|
|
mergeMap,
|
|
Subject,
|
|
switchMap,
|
|
takeUntil,
|
|
tap,
|
|
} from "rxjs";
|
|
|
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
|
import { LogoutService } from "@bitwarden/auth/common";
|
|
import { InternalPolicyService } 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 { getUserId } from "@bitwarden/common/auth/services/account.service";
|
|
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
|
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
|
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
|
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
|
import { UserKey } from "@bitwarden/common/types/key";
|
|
import {
|
|
TooltipDirective,
|
|
AsyncActionsModule,
|
|
AnonLayoutWrapperDataService,
|
|
ButtonModule,
|
|
DialogService,
|
|
FormFieldModule,
|
|
IconButtonModule,
|
|
ToastService,
|
|
} from "@bitwarden/components";
|
|
import {
|
|
KeyService,
|
|
BiometricStateService,
|
|
BiometricsService,
|
|
BiometricsStatus,
|
|
UserAsymmetricKeysRegenerationService,
|
|
} from "@bitwarden/key-management";
|
|
|
|
import {
|
|
UnlockOption,
|
|
LockComponentService,
|
|
UnlockOptions,
|
|
UnlockOptionValue,
|
|
} from "../services/lock-component.service";
|
|
|
|
import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component";
|
|
import { UnlockViaPrfComponent } from "./unlock-via-prf.component";
|
|
|
|
const BroadcasterSubscriptionId = "LockComponent";
|
|
|
|
const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
|
|
[ClientType.Web]: "vault",
|
|
[ClientType.Desktop]: "vault",
|
|
[ClientType.Browser]: "/tabs/current",
|
|
};
|
|
|
|
type AfterUnlockActions = {
|
|
passwordEvaluation?: {
|
|
masterPassword: string;
|
|
};
|
|
};
|
|
|
|
/// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible
|
|
/// Fixes safari autoprompt behavior
|
|
const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
|
|
|
|
const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [
|
|
BiometricsStatus.HardwareUnavailable,
|
|
BiometricsStatus.DesktopDisconnected,
|
|
BiometricsStatus.NotEnabledInConnectedDesktopApp,
|
|
];
|
|
|
|
// 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: "bit-lock",
|
|
templateUrl: "lock.component.html",
|
|
imports: [
|
|
CommonModule,
|
|
JslibModule,
|
|
ReactiveFormsModule,
|
|
ButtonModule,
|
|
FormFieldModule,
|
|
AsyncActionsModule,
|
|
IconButtonModule,
|
|
UnlockViaPrfComponent,
|
|
MasterPasswordLockComponent,
|
|
TooltipDirective,
|
|
],
|
|
})
|
|
export class LockComponent implements OnInit, OnDestroy {
|
|
private destroy$ = new Subject<void>();
|
|
protected loading = true;
|
|
|
|
activeAccount: Account | null = null;
|
|
|
|
clientType?: ClientType;
|
|
|
|
unlockOptions: UnlockOptions | null = null;
|
|
|
|
UnlockOption = UnlockOption;
|
|
|
|
private _activeUnlockOptionBSubject: BehaviorSubject<UnlockOptionValue | null> =
|
|
new BehaviorSubject<UnlockOptionValue | null>(null);
|
|
|
|
activeUnlockOption$ = this._activeUnlockOptionBSubject.asObservable();
|
|
|
|
set activeUnlockOption(value: UnlockOptionValue | null) {
|
|
this._activeUnlockOptionBSubject.next(value);
|
|
}
|
|
|
|
get activeUnlockOption(): UnlockOptionValue | null {
|
|
return this._activeUnlockOptionBSubject.value;
|
|
}
|
|
|
|
private invalidPinAttempts = 0;
|
|
|
|
biometricUnlockBtnText?: string;
|
|
|
|
showPassword = false;
|
|
private enforcedMasterPasswordOptions?: MasterPasswordPolicyOptions = undefined;
|
|
|
|
formGroup: FormGroup | null = null;
|
|
|
|
// Browser extension properties:
|
|
shouldClosePopout = false;
|
|
|
|
// Desktop properties:
|
|
private deferFocus: boolean | null = null;
|
|
private biometricAsked = false;
|
|
|
|
defaultUnlockOptionSetForUser = false;
|
|
|
|
unlockingViaBiometrics = false;
|
|
|
|
constructor(
|
|
private accountService: AccountService,
|
|
private pinService: PinServiceAbstraction,
|
|
private keyService: KeyService,
|
|
private platformUtilsService: PlatformUtilsService,
|
|
private router: Router,
|
|
private activatedRoute: ActivatedRoute,
|
|
private dialogService: DialogService,
|
|
private messagingService: MessagingService,
|
|
private biometricStateService: BiometricStateService,
|
|
private ngZone: NgZone,
|
|
private i18nService: I18nService,
|
|
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
|
private logService: LogService,
|
|
private deviceTrustService: DeviceTrustServiceAbstraction,
|
|
private syncService: SyncService,
|
|
private policyService: InternalPolicyService,
|
|
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
|
private formBuilder: FormBuilder,
|
|
private toastService: ToastService,
|
|
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
|
|
private biometricService: BiometricsService,
|
|
private logoutService: LogoutService,
|
|
private lockComponentService: LockComponentService,
|
|
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
|
private encryptedMigrator: EncryptedMigrator,
|
|
|
|
// desktop deps
|
|
private broadcasterService: BroadcasterService,
|
|
) {}
|
|
|
|
async ngOnInit() {
|
|
this.listenForActiveUnlockOptionChanges();
|
|
|
|
// Listen for active account changes
|
|
this.listenForActiveAccountChanges();
|
|
|
|
this.listenForUnlockOptionsChanges();
|
|
|
|
// Identify client
|
|
this.clientType = this.platformUtilsService.getClientType();
|
|
|
|
if (this.clientType === ClientType.Desktop) {
|
|
await this.desktopOnInit();
|
|
} else if (this.clientType === ClientType.Browser) {
|
|
this.biometricUnlockBtnText = this.lockComponentService.getBiometricsUnlockBtnText();
|
|
}
|
|
}
|
|
|
|
private listenForUnlockOptionsChanges() {
|
|
interval(1000)
|
|
.pipe(
|
|
mergeMap(async () => {
|
|
if (this.activeAccount?.id != null) {
|
|
const prevBiometricsEnabled = this.unlockOptions?.biometrics.enabled;
|
|
|
|
this.unlockOptions = await firstValueFrom(
|
|
this.lockComponentService.getAvailableUnlockOptions$(this.activeAccount.id),
|
|
);
|
|
|
|
if (this.activeUnlockOption == null) {
|
|
this.loading = false;
|
|
await this.setDefaultActiveUnlockOption(this.unlockOptions);
|
|
} else if (!prevBiometricsEnabled && this.unlockOptions?.biometrics.enabled) {
|
|
await this.setDefaultActiveUnlockOption(this.unlockOptions);
|
|
if (this.activeUnlockOption === UnlockOption.Biometrics) {
|
|
await this.handleBiometricsUnlockEnabled();
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
takeUntil(this.destroy$),
|
|
)
|
|
.subscribe();
|
|
}
|
|
|
|
// Base component methods
|
|
private listenForActiveUnlockOptionChanges() {
|
|
this.activeUnlockOption$
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe((activeUnlockOption: UnlockOptionValue | null) => {
|
|
if (activeUnlockOption === UnlockOption.Pin) {
|
|
this.buildPinForm();
|
|
}
|
|
});
|
|
}
|
|
|
|
private buildPinForm() {
|
|
this.formGroup = this.formBuilder.group(
|
|
{
|
|
pin: ["", [Validators.required]],
|
|
},
|
|
{ updateOn: "submit" },
|
|
);
|
|
}
|
|
|
|
private listenForActiveAccountChanges() {
|
|
this.accountService.activeAccount$
|
|
.pipe(
|
|
tap((account) => {
|
|
this.loading = true;
|
|
this.activeAccount = account;
|
|
this.resetDataOnActiveAccountChange();
|
|
}),
|
|
filter((account): account is Account => account != null),
|
|
switchMap(async (account) => {
|
|
await this.handleActiveAccountChange(account);
|
|
this.loading = false;
|
|
}),
|
|
takeUntil(this.destroy$),
|
|
)
|
|
.subscribe();
|
|
}
|
|
|
|
private async handleActiveAccountChange(activeAccount: Account) {
|
|
// this account may be unlocked, prevent any prompts so we can redirect to vault
|
|
if (await this.keyService.hasUserKey(activeAccount.id)) {
|
|
return;
|
|
}
|
|
|
|
this.setEmailAsPageSubtitle(activeAccount.email);
|
|
|
|
this.unlockOptions = await firstValueFrom(
|
|
this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id),
|
|
);
|
|
|
|
const canUseBiometrics = [
|
|
BiometricsStatus.Available,
|
|
...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES,
|
|
].includes(await this.biometricService.getBiometricsStatusForUser(activeAccount.id));
|
|
if (
|
|
!this.unlockOptions?.masterPassword.enabled &&
|
|
!this.unlockOptions?.pin.enabled &&
|
|
!canUseBiometrics
|
|
) {
|
|
// User has no available unlock options, force logout. This happens for TDE users without a masterpassword, that don't have a persistent unlock method set.
|
|
this.logService.warning("[LockComponent] User cannot unlock again. Logging out!");
|
|
await this.logoutService.logout(activeAccount.id);
|
|
return;
|
|
}
|
|
|
|
await this.setDefaultActiveUnlockOption(this.unlockOptions);
|
|
|
|
if (this.unlockOptions?.biometrics.enabled) {
|
|
await this.handleBiometricsUnlockEnabled();
|
|
}
|
|
}
|
|
|
|
private resetDataOnActiveAccountChange() {
|
|
this.defaultUnlockOptionSetForUser = false;
|
|
this.unlockOptions = null;
|
|
this.activeUnlockOption = null;
|
|
this.formGroup = null; // new form group will be created based on new active unlock option
|
|
|
|
// Desktop properties:
|
|
this.biometricAsked = false;
|
|
}
|
|
|
|
private setEmailAsPageSubtitle(email: string) {
|
|
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
|
pageSubtitle: email,
|
|
});
|
|
}
|
|
|
|
private async setDefaultActiveUnlockOption(unlockOptions: UnlockOptions | null) {
|
|
// Priorities should be Biometrics > Pin > Master Password for speed
|
|
if (unlockOptions?.biometrics.enabled) {
|
|
this.activeUnlockOption = UnlockOption.Biometrics;
|
|
} else if (unlockOptions?.pin.enabled) {
|
|
this.activeUnlockOption = UnlockOption.Pin;
|
|
} else if (unlockOptions?.masterPassword.enabled) {
|
|
this.activeUnlockOption = UnlockOption.MasterPassword;
|
|
} else if (
|
|
unlockOptions != null &&
|
|
BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES.includes(
|
|
unlockOptions.biometrics.biometricsStatus,
|
|
)
|
|
) {
|
|
// If biometrics is temporarily unavailable for masterpassword-less users, but they have biometrics configured,
|
|
// then show the biometrics screen so the user knows why they can't unlock, and to give them the option to log out.
|
|
this.activeUnlockOption = UnlockOption.Biometrics;
|
|
}
|
|
}
|
|
|
|
private async handleBiometricsUnlockEnabled() {
|
|
this.biometricUnlockBtnText = this.lockComponentService.getBiometricsUnlockBtnText();
|
|
|
|
const autoPromptBiometrics = await firstValueFrom(
|
|
this.biometricStateService.promptAutomatically$,
|
|
);
|
|
|
|
// TODO: PM-12546 - we need to make our biometric autoprompt experience consistent between the
|
|
// desktop and extension.
|
|
if (this.clientType === "desktop") {
|
|
if (autoPromptBiometrics) {
|
|
this.loading = false;
|
|
await this.desktopAutoPromptBiometrics();
|
|
}
|
|
}
|
|
|
|
if (this.clientType === "browser") {
|
|
if (
|
|
this.unlockOptions?.biometrics.enabled &&
|
|
autoPromptBiometrics &&
|
|
(await this.biometricService.getShouldAutopromptNow())
|
|
) {
|
|
await this.biometricService.setShouldAutopromptNow(false);
|
|
|
|
const lastProcessReload = await this.biometricStateService.getLastProcessReload();
|
|
if (
|
|
lastProcessReload == null ||
|
|
isNaN(lastProcessReload.getTime()) ||
|
|
Date.now() - lastProcessReload.getTime() > AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY
|
|
) {
|
|
// Firefox extension closes the popup when unfocused during biometric unlock, pop out the window to prevent infinite loop.
|
|
if (this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension) {
|
|
await this.lockComponentService.popOutBrowserExtension();
|
|
this.shouldClosePopout = true;
|
|
}
|
|
this.loading = false;
|
|
await this.unlockViaBiometrics();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Note: this submit method is only used for unlock methods that require a form and user input.
|
|
// For biometrics unlock, the method is called directly.
|
|
submit = async (): Promise<void> => {
|
|
if (this.activeUnlockOption === UnlockOption.Pin) {
|
|
return await this.unlockViaPin();
|
|
}
|
|
};
|
|
|
|
async logOut() {
|
|
const confirmed = await this.dialogService.openSimpleDialog({
|
|
title: { key: "logOut" },
|
|
content: { key: "logOutConfirmation" },
|
|
acceptButtonText: { key: "logOut" },
|
|
type: "warning",
|
|
});
|
|
|
|
if (confirmed && this.activeAccount != null) {
|
|
await this.logoutService.logout(this.activeAccount.id);
|
|
// navigate to root so redirect guard can properly route next active user or null user to correct page
|
|
await this.router.navigate(["/"]);
|
|
}
|
|
}
|
|
|
|
async unlockViaBiometrics(): Promise<void> {
|
|
this.unlockingViaBiometrics = true;
|
|
|
|
if (
|
|
this.unlockOptions == null ||
|
|
!this.unlockOptions.biometrics.enabled ||
|
|
this.activeAccount == null
|
|
) {
|
|
this.unlockingViaBiometrics = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.biometricStateService.setUserPromptCancelled();
|
|
const userKey = await this.biometricService.unlockWithBiometricsForUser(
|
|
this.activeAccount.id,
|
|
);
|
|
|
|
// If user cancels biometric prompt, userKey is undefined.
|
|
if (userKey) {
|
|
await this.setUserKeyAndContinue(userKey);
|
|
}
|
|
|
|
this.unlockingViaBiometrics = false;
|
|
} catch (e) {
|
|
// Cancelling is a valid action.
|
|
if (e instanceof Error && e.message === "canceled") {
|
|
this.unlockingViaBiometrics = false;
|
|
return;
|
|
}
|
|
|
|
this.logService.error("[LockComponent] Failed to unlock via biometrics.", e);
|
|
|
|
let biometricTranslatedErrorDesc;
|
|
|
|
if (this.clientType === "browser") {
|
|
const biometricErrorDescTranslationKey = this.lockComponentService.getBiometricsError(e);
|
|
|
|
if (biometricErrorDescTranslationKey) {
|
|
biometricTranslatedErrorDesc = this.i18nService.t(biometricErrorDescTranslationKey);
|
|
}
|
|
}
|
|
|
|
// if no translation key found, show generic error message
|
|
if (!biometricTranslatedErrorDesc) {
|
|
biometricTranslatedErrorDesc = this.i18nService.t("unexpectedError");
|
|
}
|
|
|
|
const confirmed = await this.dialogService.openSimpleDialog({
|
|
title: { key: "error" },
|
|
content: biometricTranslatedErrorDesc,
|
|
acceptButtonText: { key: "tryAgain" },
|
|
type: "danger",
|
|
});
|
|
|
|
if (confirmed) {
|
|
// try again
|
|
await this.unlockViaBiometrics();
|
|
}
|
|
|
|
this.unlockingViaBiometrics = false;
|
|
}
|
|
}
|
|
|
|
async onPrfUnlockSuccess(userKey: UserKey): Promise<void> {
|
|
await this.setUserKeyAndContinue(userKey);
|
|
}
|
|
|
|
togglePassword() {
|
|
this.showPassword = !this.showPassword;
|
|
}
|
|
|
|
private validatePin(): boolean {
|
|
if (this.formGroup?.invalid) {
|
|
this.toastService.showToast({
|
|
variant: "error",
|
|
title: this.i18nService.t("errorOccurred"),
|
|
message: this.i18nService.t("pinRequired"),
|
|
});
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private async unlockViaPin() {
|
|
if (!this.validatePin() || this.formGroup == null || this.activeAccount == null) {
|
|
return;
|
|
}
|
|
|
|
const pin = this.formGroup.controls.pin.value;
|
|
|
|
const MAX_INVALID_PIN_ENTRY_ATTEMPTS = 5;
|
|
|
|
try {
|
|
const userKey = await this.pinService.decryptUserKeyWithPin(pin, this.activeAccount.id);
|
|
|
|
if (userKey) {
|
|
await this.setUserKeyAndContinue(userKey);
|
|
return; // successfully unlocked
|
|
}
|
|
|
|
// Failure state: invalid PIN or failed decryption
|
|
this.invalidPinAttempts++;
|
|
|
|
// Log user out if they have entered an invalid PIN too many times
|
|
if (this.invalidPinAttempts >= MAX_INVALID_PIN_ENTRY_ATTEMPTS) {
|
|
this.toastService.showToast({
|
|
variant: "error",
|
|
message: this.i18nService.t("tooManyInvalidPinEntryAttemptsLoggingOut"),
|
|
});
|
|
this.messagingService.send("logout");
|
|
return;
|
|
}
|
|
|
|
this.toastService.showToast({
|
|
variant: "error",
|
|
title: this.i18nService.t("errorOccurred"),
|
|
message: this.i18nService.t("invalidPin"),
|
|
});
|
|
} catch {
|
|
this.toastService.showToast({
|
|
variant: "error",
|
|
title: this.i18nService.t("errorOccurred"),
|
|
message: this.i18nService.t("unexpectedError"),
|
|
});
|
|
}
|
|
}
|
|
|
|
async successfulMasterPasswordUnlock(event: {
|
|
userKey: UserKey;
|
|
masterPassword: string;
|
|
}): Promise<void> {
|
|
if (event.userKey == null || !event.masterPassword) {
|
|
this.logService.error(
|
|
"[LockComponent] successfulMasterPasswordUnlock called with invalid data.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
await this.setUserKeyAndContinue(event.userKey, {
|
|
passwordEvaluation: {
|
|
masterPassword: event.masterPassword,
|
|
},
|
|
});
|
|
}
|
|
|
|
protected async setUserKeyAndContinue(
|
|
key: UserKey,
|
|
afterUnlockActions: AfterUnlockActions = {},
|
|
): Promise<void> {
|
|
if (this.activeAccount == null) {
|
|
throw new Error("No active user.");
|
|
}
|
|
|
|
// Add a mark to indicate that the user has unlocked their vault. A good starting point for measuring unlock performance.
|
|
this.logService.mark("Vault unlocked");
|
|
|
|
await this.keyService.setUserKey(key, this.activeAccount.id);
|
|
await this.pinService.userUnlocked(this.activeAccount.id);
|
|
|
|
// Now that we have a decrypted user key in memory, we can check if we
|
|
// need to establish trust on the current device
|
|
await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id);
|
|
|
|
await this.doContinue(afterUnlockActions);
|
|
}
|
|
|
|
private async doContinue(afterUnlockActions: AfterUnlockActions) {
|
|
if (this.activeAccount == null) {
|
|
throw new Error("No active user.");
|
|
}
|
|
|
|
await this.biometricStateService.resetUserPromptCancelled();
|
|
|
|
try {
|
|
await this.encryptedMigrator.runMigrations(
|
|
this.activeAccount.id,
|
|
afterUnlockActions.passwordEvaluation?.masterPassword ?? null,
|
|
);
|
|
} catch {
|
|
// Don't block login success on migration failure
|
|
}
|
|
|
|
this.messagingService.send("unlocked");
|
|
|
|
if (afterUnlockActions.passwordEvaluation) {
|
|
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
|
if (userId == null) {
|
|
throw new Error("No active user.");
|
|
}
|
|
|
|
try {
|
|
// If we do not have any saved policies, attempt to load them from the service
|
|
if (this.enforcedMasterPasswordOptions == undefined) {
|
|
this.enforcedMasterPasswordOptions = await firstValueFrom(
|
|
this.accountService.activeAccount$.pipe(
|
|
getUserId,
|
|
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (this.requirePasswordChange(afterUnlockActions.passwordEvaluation.masterPassword)) {
|
|
await this.masterPasswordService.setForceSetPasswordReason(
|
|
ForceSetPasswordReason.WeakMasterPassword,
|
|
userId,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
// Do not prevent unlock if there is an error evaluating policies
|
|
this.logService.error(e);
|
|
}
|
|
}
|
|
|
|
// Vault can be de-synced since server notifications get ignored while locked. Need to check whether sync is required using the sync service.
|
|
const startSync = new Date().getTime();
|
|
// TODO: This should probably not be blocking
|
|
await this.syncService.fullSync(false);
|
|
this.logService.info(`[LockComponent] Sync took ${new Date().getTime() - startSync}ms`);
|
|
|
|
const startRegeneration = new Date().getTime();
|
|
// TODO: This should probably not be blocking
|
|
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(this.activeAccount.id);
|
|
this.logService.info(
|
|
`[LockComponent] Private key regeneration took ${new Date().getTime() - startRegeneration}ms`,
|
|
);
|
|
|
|
if (this.clientType === "browser") {
|
|
const previousUrl = this.lockComponentService.getPreviousUrl();
|
|
/**
|
|
* In a passkey flow, the `previousUrl` will still be `/fido2?<queryParams>` at this point
|
|
* because the `/lock` route doesn't save the URL in the `BrowserRouterService`. This is
|
|
* handled by the `doNotSaveUrl` property on the `/lock` route in `app-routing.module.ts`.
|
|
*/
|
|
if (previousUrl) {
|
|
await this.router.navigateByUrl(previousUrl);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// determine success route based on client type
|
|
// The disable-redirect parameter allows callers to prevent automatic navigation after unlock,
|
|
// useful when the lock component is used in contexts where custom post-unlock behavior is needed
|
|
// such as passkey modals.
|
|
if (
|
|
this.clientType != null &&
|
|
this.activatedRoute.snapshot.paramMap.get("disable-redirect") === null
|
|
) {
|
|
const successRoute = clientTypeToSuccessRouteRecord[this.clientType];
|
|
await this.router.navigate([successRoute]);
|
|
}
|
|
|
|
if (
|
|
this.shouldClosePopout &&
|
|
this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension
|
|
) {
|
|
this.lockComponentService.closeBrowserExtensionPopout();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the master password meets the enforced policy requirements
|
|
* If not, returns false
|
|
*/
|
|
private requirePasswordChange(masterPassword: string): boolean {
|
|
if (
|
|
this.enforcedMasterPasswordOptions == undefined ||
|
|
!this.enforcedMasterPasswordOptions.enforceOnLogin ||
|
|
this.activeAccount == null
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
|
|
masterPassword,
|
|
this.activeAccount.email,
|
|
)?.score;
|
|
|
|
return !this.policyService.evaluateMasterPassword(
|
|
passwordStrength,
|
|
masterPassword,
|
|
this.enforcedMasterPasswordOptions,
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------------
|
|
// Desktop methods:
|
|
// -----------------------------------------------------------------------------------------------
|
|
|
|
async desktopOnInit() {
|
|
this.biometricUnlockBtnText = this.lockComponentService.getBiometricsUnlockBtnText();
|
|
|
|
// TODO: move this into a WindowService and subscribe to messages via MessageListener service.
|
|
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
|
|
this.ngZone.run(() => {
|
|
switch (message.command) {
|
|
case "windowHidden":
|
|
this.onWindowHidden();
|
|
break;
|
|
case "windowIsFocused":
|
|
if (this.deferFocus === null) {
|
|
this.deferFocus = !message.windowIsFocused;
|
|
if (!this.deferFocus) {
|
|
this.focusInput();
|
|
}
|
|
} else if (this.deferFocus && message.windowIsFocused) {
|
|
this.focusInput();
|
|
this.deferFocus = false;
|
|
}
|
|
break;
|
|
default:
|
|
}
|
|
});
|
|
});
|
|
this.messagingService.send("getWindowIsFocused");
|
|
}
|
|
|
|
private async desktopAutoPromptBiometrics() {
|
|
if (!this.unlockOptions?.biometrics?.enabled || this.biometricAsked) {
|
|
return;
|
|
}
|
|
|
|
if (!(await this.biometricService.getShouldAutopromptNow())) {
|
|
return;
|
|
}
|
|
|
|
// prevent the biometric prompt from showing if the user has already cancelled it
|
|
if (await firstValueFrom(this.biometricStateService.promptCancelled$)) {
|
|
return;
|
|
}
|
|
|
|
const windowVisible = await this.lockComponentService.isWindowVisible();
|
|
|
|
if (windowVisible) {
|
|
this.biometricAsked = true;
|
|
await this.unlockViaBiometrics();
|
|
}
|
|
}
|
|
|
|
onWindowHidden() {
|
|
this.showPassword = false;
|
|
}
|
|
|
|
private focusInput() {
|
|
if (this.unlockOptions) {
|
|
document.getElementById(this.unlockOptions.pin.enabled ? "pin" : "masterPassword")?.focus();
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------------------------
|
|
|
|
ngOnDestroy() {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
|
|
if (this.clientType === "desktop") {
|
|
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
|
}
|
|
}
|
|
|
|
get biometricsAvailable(): boolean {
|
|
return this.unlockOptions?.biometrics.enabled ?? false;
|
|
}
|
|
|
|
get showBiometrics(): boolean {
|
|
if (this.unlockOptions == null) {
|
|
return false;
|
|
}
|
|
return (
|
|
this.unlockOptions.biometrics.biometricsStatus !== BiometricsStatus.PlatformUnsupported &&
|
|
this.unlockOptions.biometrics.biometricsStatus !== BiometricsStatus.NotEnabledLocally
|
|
);
|
|
}
|
|
|
|
get biometricUnavailabilityReason(): string {
|
|
switch (this.unlockOptions?.biometrics.biometricsStatus) {
|
|
case BiometricsStatus.Available:
|
|
return "";
|
|
case BiometricsStatus.UnlockNeeded:
|
|
return this.i18nService.t("biometricsStatusHelptextUnlockNeeded");
|
|
case BiometricsStatus.HardwareUnavailable:
|
|
return this.i18nService.t("biometricsStatusHelptextHardwareUnavailable");
|
|
case BiometricsStatus.AutoSetupNeeded:
|
|
return this.i18nService.t("biometricsStatusHelptextAutoSetupNeeded");
|
|
case BiometricsStatus.ManualSetupNeeded:
|
|
return this.i18nService.t("biometricsStatusHelptextManualSetupNeeded");
|
|
case BiometricsStatus.NotEnabledInConnectedDesktopApp:
|
|
return this.i18nService.t(
|
|
"biometricsStatusHelptextNotEnabledInDesktop",
|
|
this.activeAccount?.email,
|
|
);
|
|
case BiometricsStatus.NotEnabledLocally:
|
|
return this.i18nService.t(
|
|
"biometricsStatusHelptextNotEnabledInDesktop",
|
|
this.activeAccount?.email,
|
|
);
|
|
case BiometricsStatus.DesktopDisconnected:
|
|
return this.i18nService.t("biometricsStatusHelptextDesktopDisconnected");
|
|
default:
|
|
return (
|
|
this.i18nService.t("biometricsStatusHelptextUnavailableReasonUnknown") +
|
|
this.unlockOptions?.biometrics.biometricsStatus
|
|
);
|
|
}
|
|
}
|
|
}
|