mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
Auth/PM-9449 - UI Refresh + Client component consolidation into new LockV2 Component (#10451)
* PM-9449 - Init stub of new lock comp * PM-9449 - (1) Add new lock screen title to all clients (2) Add to temp web routing module config * PM-9449 - LockV2Comp - Building now with web HTML * PM-9449 - Libs/Auth LockComp - bring in all desktop ts code; WIP, need to stand up LockCompService to facilitate ipc communication. * PM-9449 - Create LockComponentService for facilitating client logic; potentially will decompose later. * PM-9449 - Add extension lock comp service. * PM-9449 - Libs/auth LockComp - bring in browser extension logic * PM-9449 - Libs/auth LockComp html start * PM-9449 - Libs/Auth LockComp - (1) Remove unused dep (2) Update setEmailAsPageSubtitle to work. * PM-9449 - Add getBiometricsError to lock comp service for extension. * PM-9449 - LockComp - (1) Save off client type as public comp var (2) Rename biometricLock as biometricLockSet * PM-9449 - Work on lock comp service getAvailableUnlockOptions * PM-9449 - WIP libs/auth LockComp * PM-9449 - (1) Remove default lock comp svc (2) Add web lock comp svc. * PM-9449 - UnlockOptions - replace incorrect type * PM-9449 - DesktopLockComponentService -get most of observable based getAvailableUnlockOptions$ logic in place. * PM-9449 - LockCompSvc - getAvailableUnlockOptions in place for all clients. * PM-9449 - Add getBiometricsUnlockBtnText to LockCompSvc and put TODO for wiring it up later * PM-9449 - Lock Comp - Replace all manual bools with unlock options. * PM-9449 - Desktop Lock Comp Svc - adjust spacing * PM-9449 - LockCompSvc - remove biometricsEnabled method * PM-9449 - LockComp - Clean up commented out code * PM-9449 - LockComp - webVaultHostname --> envHostName * PM-9449 - Fix lock comp svc deps * PM-9449 - LockComp - HTML progress * PM-9449 - LockComp cleanup * PM-9449 - Web Routing Module - wire up lock vs lockv2 using extension swap * PM-9449 - Wire up loading state * PM-9449 - LockComp - start wiring up listenForActiveUnlockOptionChanges logic with reactivity * PM-9449 - Update desktop & extension lock comp service to use new biometrics service vs platform utils for biometrics information. * PM-9449 - LockV2 - Swap platform util usage with toast svc * PM-9449 - LockV2Comp - Bring over user id logic from PM-8933 * PM-9449 - LockV2Comp - Adjust everything to use activeAccount.id. * PM-9449 - LockV2Comp - Progress on wiring up unlock option reactive stream. * PM-9449 - LockComp ts - some refactoring and minor progress. * PM-9449 - LockComp HTML - refactoring based on new idea to keep unlock options as separate as possible. * PM-9449 - Add PIN translation to web * PM-9449 - (1) Lock HTML refactor to make as independent verticals as possible (2) Refactor Lock ts (3) LockSvc - replace type with enum. * PM-9449 - LockV2Comp - remove hardcoded await. * PM-9449 - LockComp HTML - add todo * PM-9449 - Web - Routing module - cleanup commented out stuff * PM-9449 - LockV2Comp - Wire up biometrics + mild refactor. * PM-9449 - Desktop - Wire up lockV2 redirection * PM-9449 - LockV2 - Desktop - don't focus until unlock opts defined. * PM-9449 - Fix accidental check in * PM-9449 - LockV2 - loading state depends on unlock opts * PM-9449 - LockV2 comp - remove unnecessary hr * PM-9449 - Migrate "yourVaultIsLockedV2" translation to desktop & browser. * PM-9449 - LockV2 - Layout tweaks for biometrics * PM-9449 - LockV2 - Biometric btn text * PM-9449 - LockV2 - Wire up biometrics loading / disable state + remove unnecessary conditions around biometricsUnlockBtnText * PM-9449 - DesktopLockSvc - Per discussion with Bernd, remove interval polling and just check once for biometric support and availability. * PM-9449 - AuthGuard - Add todo to remove promptBiometric * PM-9449 - LockV2 - Refactor primary and desktop init logic + misc clean up * PM-9449 - LockV2 - Reorder init methods * PM-9449 - LockV2 - Per discussion with Product, deprecate windows biometric settings update warning * PM-9449 - Add TODO per discussion with Justin and remove TODO * PM-9449 - LockV2 - Restore hide password on desktop window hidden functionality. * PM-9449 - Clean up accomplished todo * PM-9449 - LockV2 - Refactor func name. * PM-9449 - LockV2 Comp - (1) TODO cleanup (2) Add browser logic to handleBiometricsUnlockEnabled * PM-9449 - LockCompSvc changes - (1) Observability for isFido2Session (2) Adjust errors and returns per discussion with Justin * PM-9449 - Per product, no longer need to support special fido2 case on extension. * PM-9449 - LockCompSvc - add getPreviousUrl support * PM-9449 - LockV2 - Continued ts cleanup * PM-9449 - LockV2Comp - clean up unused props * PM-9449 - LockV2Comp - Rename response to masterPasswordVerificationResponse * PM-9449 - LockV2 - Remove unused formPromise prop * PM-9449 - Add missing translations + update desktop to showReadonlyHostName * PM-9449 - LockV2 - cleanup TODO * PM-9449 - LockV2 - more cleanup * PM-9449 - Desktop Routing Module - only allow LockV2 access if extension refresh flag is enabled. * PM-9449 - Extension - AppRoutingModule - Add extension redirect + new lockV2 route. * PM-9449 - Extension - AppRoutingModule - Add lockV2 to the ExtensionAnonLayoutWrapperComponent intead of the regular one. * PM-9449 - Extension - CurrentAccountComp - add null checks as anon layout components don't have a state today. This prevents the account switcher from working on the new lockV2 comp. * PM-9449 - Extension AppRoutingModule - LockV2 should use ExtensionAnonLayoutWrapperData * PM-9449 - LockComp - BiometricUnlock - cancelling is a valid action. * PM-9449 - LockV2 - Biometric autoprompt cleanup * PM-9449 - LockV2 - (1) Add TODO for KM team (2) Fix submit logic. * PM-9449 - Tweak TODO to add task # * PM-9449 - Test WebLockComponentService * PM-9449 - ExtensionLockComponentService tested * PM-9449 - Tweak extension lock comp svc test * PM-9449 - DesktopLockComponentService tested * PM-9449 - Add task # to TODO * PM-9449 - Update apps/browser/src/services/extension-lock-component.service.ts per PR feedback Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * PM-9449 - Per PR feedback, replace from with defer for better reactive execution of promise based functions. * PM-9449 - Per PR feedback replace enum with type. * PM-9449 - Fix imports and tests due to key management file moves. * PM-9449 - Another test file import fix --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
@@ -43,5 +43,9 @@ export * from "./registration/registration-env-selector/registration-env-selecto
|
||||
export * from "./registration/registration-finish/registration-finish.service";
|
||||
export * from "./registration/registration-finish/default-registration-finish.service";
|
||||
|
||||
// lock
|
||||
export * from "./lock/lock.component";
|
||||
export * from "./lock/lock-component.service";
|
||||
|
||||
// vault timeout
|
||||
export * from "./vault-timeout-input/vault-timeout-input.component";
|
||||
|
||||
48
libs/auth/src/angular/lock/lock-component.service.ts
Normal file
48
libs/auth/src/angular/lock/lock-component.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export enum BiometricsDisableReason {
|
||||
NotSupportedOnOperatingSystem = "NotSupportedOnOperatingSystem",
|
||||
EncryptedKeysUnavailable = "BiometricsEncryptedKeysUnavailable",
|
||||
SystemBiometricsUnavailable = "SystemBiometricsUnavailable",
|
||||
}
|
||||
|
||||
// ex: type UnlockOptionValue = "masterPassword" | "pin" | "biometrics"
|
||||
export type UnlockOptionValue = (typeof UnlockOption)[keyof typeof UnlockOption];
|
||||
|
||||
export const UnlockOption = Object.freeze({
|
||||
MasterPassword: "masterPassword",
|
||||
Pin: "pin",
|
||||
Biometrics: "biometrics",
|
||||
}) satisfies { [Prop in keyof UnlockOptions as Capitalize<Prop>]: Prop };
|
||||
|
||||
export type UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: boolean;
|
||||
};
|
||||
pin: {
|
||||
enabled: boolean;
|
||||
};
|
||||
biometrics: {
|
||||
enabled: boolean;
|
||||
disableReason: BiometricsDisableReason | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The LockComponentService is a service which allows the single libs/auth LockComponent to delegate all
|
||||
* client specific functionality to client specific services implementations of LockComponentService.
|
||||
*/
|
||||
export abstract class LockComponentService {
|
||||
// Extension
|
||||
abstract getBiometricsError(error: any): string | null;
|
||||
abstract getPreviousUrl(): string | null;
|
||||
|
||||
// Desktop only
|
||||
abstract isWindowVisible(): Promise<boolean>;
|
||||
abstract getBiometricsUnlockBtnText(): string;
|
||||
|
||||
// Multi client
|
||||
abstract getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions>;
|
||||
}
|
||||
191
libs/auth/src/angular/lock/lock.component.html
Normal file
191
libs/auth/src/angular/lock/lock.component.html
Normal file
@@ -0,0 +1,191 @@
|
||||
<ng-template #loading>
|
||||
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-container *ngIf="unlockOptions; else loading">
|
||||
<!-- Biometrics Unlock -->
|
||||
<ng-container
|
||||
*ngIf="unlockOptions.biometrics.enabled && activeUnlockOption === UnlockOption.Biometrics"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
class="tw-mb-3"
|
||||
[disabled]="unlockingViaBiometrics"
|
||||
[loading]="unlockingViaBiometrics"
|
||||
block
|
||||
(click)="unlockViaBiometrics()"
|
||||
>
|
||||
<span> {{ biometricUnlockBtnText | i18n }}</span>
|
||||
</button>
|
||||
|
||||
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||
<p class="tw-text-center tw-mb-0">{{ "or" | i18n }}</p>
|
||||
|
||||
<ng-container *ngIf="unlockOptions.pin.enabled">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="activeUnlockOption = UnlockOption.Pin"
|
||||
>
|
||||
{{ "unlockWithPin" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="unlockOptions.masterPassword.enabled">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="activeUnlockOption = UnlockOption.MasterPassword"
|
||||
>
|
||||
{{ "unlockWithMasterPassword" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<button type="button" bitButton block (click)="logOut()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- PIN Unlock -->
|
||||
<ng-container *ngIf="unlockOptions.pin.enabled && activeUnlockOption === UnlockOption.Pin">
|
||||
<form [bitSubmit]="submit" [formGroup]="formGroup">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "pin" | i18n }}</bit-label>
|
||||
<input
|
||||
type="password"
|
||||
formControlName="pin"
|
||||
bitInput
|
||||
appAutofocus
|
||||
name="pin"
|
||||
class="tw-font-mono"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton
|
||||
bitSuffix
|
||||
bitPasswordInputToggle
|
||||
[(toggled)]="showPassword"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
|
||||
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" block>
|
||||
{{ "unlock" | i18n }}
|
||||
</button>
|
||||
|
||||
<p class="tw-text-center">{{ "or" | i18n }}</p>
|
||||
|
||||
<ng-container *ngIf="unlockOptions.biometrics.enabled">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="activeUnlockOption = UnlockOption.Biometrics"
|
||||
>
|
||||
<span> {{ biometricUnlockBtnText | i18n }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="unlockOptions.masterPassword.enabled">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="activeUnlockOption = UnlockOption.MasterPassword"
|
||||
>
|
||||
{{ "unlockWithMasterPassword" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<button type="button" bitButton bitFormButton block (click)="logOut()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
|
||||
<!-- MP Unlock -->
|
||||
<ng-container
|
||||
*ngIf="
|
||||
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
|
||||
"
|
||||
>
|
||||
<form [bitSubmit]="submit" [formGroup]="formGroup">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
||||
<input
|
||||
type="password"
|
||||
formControlName="masterPassword"
|
||||
bitInput
|
||||
appAutofocus
|
||||
name="masterPassword"
|
||||
class="tw-font-mono"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton
|
||||
bitSuffix
|
||||
bitPasswordInputToggle
|
||||
[(toggled)]="showPassword"
|
||||
></button>
|
||||
|
||||
<!-- [attr.aria-pressed]="showPassword" -->
|
||||
</bit-form-field>
|
||||
|
||||
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" block>
|
||||
{{ "unlock" | i18n }}
|
||||
</button>
|
||||
|
||||
<p class="tw-text-center">{{ "or" | i18n }}</p>
|
||||
|
||||
<ng-container *ngIf="unlockOptions.biometrics.enabled">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="activeUnlockOption = UnlockOption.Biometrics"
|
||||
>
|
||||
<span> {{ biometricUnlockBtnText | i18n }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="unlockOptions.pin.enabled">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="activeUnlockOption = UnlockOption.Pin"
|
||||
>
|
||||
{{ "unlockWithPin" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<button type="button" bitButton bitFormButton block (click)="logOut()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
638
libs/auth/src/angular/lock/lock.component.ts
Normal file
638
libs/auth/src/angular/lock/lock.component.ts
Normal file
@@ -0,0 +1,638 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, NgZone, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { BehaviorSubject, firstValueFrom, Subject, switchMap, take, takeUntil } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
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 { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import {
|
||||
MasterPasswordVerification,
|
||||
MasterPasswordVerificationResponse,
|
||||
} from "@bitwarden/common/auth/types/verification";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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 { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { PinServiceAbstraction } from "../../common/abstractions";
|
||||
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
|
||||
|
||||
import {
|
||||
UnlockOption,
|
||||
LockComponentService,
|
||||
UnlockOptions,
|
||||
UnlockOptionValue,
|
||||
} from "./lock-component.service";
|
||||
|
||||
const BroadcasterSubscriptionId = "LockComponent";
|
||||
|
||||
const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
|
||||
[ClientType.Web]: "vault",
|
||||
[ClientType.Desktop]: "vault",
|
||||
[ClientType.Browser]: "/tabs/current",
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "bit-lock",
|
||||
templateUrl: "lock.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
IconButtonModule,
|
||||
],
|
||||
})
|
||||
export class LockV2Component implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
activeAccount: { id: UserId | undefined } & AccountInfo;
|
||||
|
||||
clientType: ClientType;
|
||||
ClientType = ClientType;
|
||||
|
||||
unlockOptions: UnlockOptions = null;
|
||||
|
||||
UnlockOption = UnlockOption;
|
||||
|
||||
private _activeUnlockOptionBSubject: BehaviorSubject<UnlockOptionValue> =
|
||||
new BehaviorSubject<UnlockOptionValue>(null);
|
||||
|
||||
activeUnlockOption$ = this._activeUnlockOptionBSubject.asObservable();
|
||||
|
||||
set activeUnlockOption(value: UnlockOptionValue) {
|
||||
this._activeUnlockOptionBSubject.next(value);
|
||||
}
|
||||
|
||||
get activeUnlockOption(): UnlockOptionValue {
|
||||
return this._activeUnlockOptionBSubject.value;
|
||||
}
|
||||
|
||||
private invalidPinAttempts = 0;
|
||||
|
||||
biometricUnlockBtnText: string;
|
||||
|
||||
// masterPassword = "";
|
||||
showPassword = false;
|
||||
private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions = undefined;
|
||||
|
||||
forcePasswordResetRoute = "update-temp-password";
|
||||
|
||||
formGroup: FormGroup;
|
||||
|
||||
// Desktop properties:
|
||||
private deferFocus: boolean = null;
|
||||
private biometricAsked = false;
|
||||
|
||||
// Browser extension properties:
|
||||
private isInitialLockScreen = (window as any).previousPopupUrl == null;
|
||||
|
||||
defaultUnlockOptionSetForUser = false;
|
||||
|
||||
unlockingViaBiometrics = false;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private pinService: PinServiceAbstraction,
|
||||
private userVerificationService: UserVerificationService,
|
||||
private cryptoService: CryptoService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private router: Router,
|
||||
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 lockComponentService: LockComponentService,
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
|
||||
// desktop deps
|
||||
private broadcasterService: BroadcasterService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.listenForActiveUnlockOptionChanges();
|
||||
|
||||
// Listen for active account changes
|
||||
this.listenForActiveAccountChanges();
|
||||
|
||||
// Identify client
|
||||
this.clientType = this.platformUtilsService.getClientType();
|
||||
|
||||
if (this.clientType === "desktop") {
|
||||
await this.desktopOnInit();
|
||||
}
|
||||
}
|
||||
|
||||
// Base component methods
|
||||
private listenForActiveUnlockOptionChanges() {
|
||||
this.activeUnlockOption$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((activeUnlockOption: UnlockOptionValue) => {
|
||||
if (activeUnlockOption === UnlockOption.Pin) {
|
||||
this.buildPinForm();
|
||||
} else if (activeUnlockOption === UnlockOption.MasterPassword) {
|
||||
this.buildMasterPasswordForm();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private buildMasterPasswordForm() {
|
||||
this.formGroup = this.formBuilder.group(
|
||||
{
|
||||
masterPassword: ["", [Validators.required]],
|
||||
},
|
||||
{ updateOn: "submit" },
|
||||
);
|
||||
}
|
||||
|
||||
private buildPinForm() {
|
||||
this.formGroup = this.formBuilder.group(
|
||||
{
|
||||
pin: ["", [Validators.required]],
|
||||
},
|
||||
{ updateOn: "submit" },
|
||||
);
|
||||
}
|
||||
|
||||
private listenForActiveAccountChanges() {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
switchMap((account) => {
|
||||
return this.handleActiveAccountChange(account);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private async handleActiveAccountChange(activeAccount: { id: UserId | undefined } & AccountInfo) {
|
||||
this.activeAccount = activeAccount;
|
||||
|
||||
this.resetDataOnActiveAccountChange();
|
||||
|
||||
this.setEmailAsPageSubtitle(activeAccount.email);
|
||||
|
||||
this.unlockOptions = await firstValueFrom(
|
||||
this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id),
|
||||
);
|
||||
|
||||
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: {
|
||||
subtitle: email,
|
||||
translate: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private setDefaultActiveUnlockOption(unlockOptions: UnlockOptions) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
await this.desktopAutoPromptBiometrics();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.clientType === "browser") {
|
||||
if (
|
||||
this.unlockOptions.biometrics.enabled &&
|
||||
autoPromptBiometrics &&
|
||||
this.isInitialLockScreen // only autoprompt biometrics on initial lock screen
|
||||
) {
|
||||
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();
|
||||
}
|
||||
|
||||
await this.unlockViaMasterPassword();
|
||||
};
|
||||
|
||||
async logOut() {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "logOut" },
|
||||
content: { key: "logOutConfirmation" },
|
||||
acceptButtonText: { key: "logOut" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
this.messagingService.send("logout", { userId: this.activeAccount.id });
|
||||
}
|
||||
}
|
||||
|
||||
async unlockViaBiometrics(): Promise<void> {
|
||||
this.unlockingViaBiometrics = true;
|
||||
|
||||
if (!this.unlockOptions.biometrics.enabled) {
|
||||
this.unlockingViaBiometrics = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.biometricStateService.setUserPromptCancelled();
|
||||
const userKey = await this.cryptoService.getUserKeyFromStorage(
|
||||
KeySuffixOptions.Biometric,
|
||||
this.activeAccount.id,
|
||||
);
|
||||
|
||||
// If user cancels biometric prompt, userKey is undefined.
|
||||
if (userKey) {
|
||||
await this.setUserKeyAndContinue(userKey, false);
|
||||
}
|
||||
|
||||
this.unlockingViaBiometrics = false;
|
||||
} catch (e) {
|
||||
// Cancelling is a valid action.
|
||||
if (e?.message === "canceled") {
|
||||
this.unlockingViaBiometrics = false;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
togglePassword() {
|
||||
this.showPassword = !this.showPassword;
|
||||
const input = document.getElementById(
|
||||
this.unlockOptions.pin.enabled ? "pin" : "masterPassword",
|
||||
);
|
||||
if (this.ngZone.isStable) {
|
||||
input.focus();
|
||||
} else {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
this.ngZone.onStable.pipe(take(1)).subscribe(() => input.focus());
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
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",
|
||||
title: null,
|
||||
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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private validateMasterPassword(): boolean {
|
||||
if (this.formGroup.invalid) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("masterPasswordRequired"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async unlockViaMasterPassword() {
|
||||
if (!this.validateMasterPassword()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const masterPassword = this.formGroup.controls.masterPassword.value;
|
||||
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: masterPassword,
|
||||
} as MasterPasswordVerification;
|
||||
|
||||
let passwordValid = false;
|
||||
let masterPasswordVerificationResponse: MasterPasswordVerificationResponse;
|
||||
try {
|
||||
masterPasswordVerificationResponse =
|
||||
await this.userVerificationService.verifyUserByMasterPassword(
|
||||
verification,
|
||||
this.activeAccount.id,
|
||||
this.activeAccount.email,
|
||||
);
|
||||
|
||||
this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse(
|
||||
masterPasswordVerificationResponse.policyOptions,
|
||||
);
|
||||
passwordValid = true;
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
if (!passwordValid) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("invalidMasterPassword"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
|
||||
masterPasswordVerificationResponse.masterKey,
|
||||
);
|
||||
await this.setUserKeyAndContinue(userKey, true);
|
||||
}
|
||||
|
||||
private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) {
|
||||
await this.cryptoService.setUserKey(key, 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(evaluatePasswordAfterUnlock);
|
||||
}
|
||||
|
||||
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
|
||||
await this.biometricStateService.resetUserPromptCancelled();
|
||||
this.messagingService.send("unlocked");
|
||||
|
||||
if (evaluatePasswordAfterUnlock) {
|
||||
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.policyService.masterPasswordPolicyOptions$(),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.requirePasswordChange()) {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
await this.masterPasswordService.setForceSetPasswordReason(
|
||||
ForceSetPasswordReason.WeakMasterPassword,
|
||||
userId,
|
||||
);
|
||||
await this.router.navigate([this.forcePasswordResetRoute]);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Do not prevent unlock if there is an error evaluating policies
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Vault can be de-synced since notifications get ignored while locked. Need to check whether sync is required using the sync service.
|
||||
await this.syncService.fullSync(false);
|
||||
|
||||
if (this.clientType === "browser") {
|
||||
const previousUrl = this.lockComponentService.getPreviousUrl();
|
||||
if (previousUrl) {
|
||||
await this.router.navigateByUrl(previousUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// determine success route based on client type
|
||||
const successRoute = clientTypeToSuccessRouteRecord[this.clientType];
|
||||
await this.router.navigate([successRoute]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the master password meets the enforced policy requirements
|
||||
* If not, returns false
|
||||
*/
|
||||
private requirePasswordChange(): boolean {
|
||||
if (
|
||||
this.enforcedMasterPasswordOptions == undefined ||
|
||||
!this.enforcedMasterPasswordOptions.enforceOnLogin
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const masterPassword = this.formGroup.controls.masterPassword.value;
|
||||
|
||||
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
|
||||
masterPassword,
|
||||
this.activeAccount.email,
|
||||
)?.score;
|
||||
|
||||
return !this.policyService.evaluateMasterPassword(
|
||||
passwordStrength,
|
||||
masterPassword,
|
||||
this.enforcedMasterPasswordOptions,
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
// Desktop methods:
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
|
||||
async desktopOnInit() {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user