1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

WIP store extension state to cache

This commit is contained in:
Alec Rippberger
2025-02-28 14:55:25 -06:00
parent bec17c0445
commit 29d3a4d18e
14 changed files with 392 additions and 10 deletions

View File

@@ -2,6 +2,7 @@ import { Component } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { TwoFactorOptionsComponentV1 as BaseTwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-options-v1.component";
import { TwoFactorFormCacheServiceAbstraction } from "@bitwarden/auth/angular";
import {
TwoFactorProviderDetails,
TwoFactorService,
@@ -22,6 +23,7 @@ export class TwoFactorOptionsComponentV1 extends BaseTwoFactorOptionsComponent {
platformUtilsService: PlatformUtilsService,
environmentService: EnvironmentService,
private activatedRoute: ActivatedRoute,
private twoFactorFormCacheService: TwoFactorFormCacheServiceAbstraction,
) {
super(twoFactorService, router, i18nService, platformUtilsService, window, environmentService);
}
@@ -34,6 +36,14 @@ export class TwoFactorOptionsComponentV1 extends BaseTwoFactorOptionsComponent {
await super.choose(p);
await this.twoFactorService.setSelectedProvider(p.type);
const persistedData = await this.twoFactorFormCacheService.getFormData();
await this.twoFactorFormCacheService.saveFormData({
token: persistedData?.token || undefined,
remember: persistedData?.remember ?? undefined,
selectedProviderType: p.type,
emailSent: false,
});
this.navigateTo2FA();
}

View File

@@ -12,6 +12,7 @@ import {
LoginEmailServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { TwoFactorFormCacheServiceAbstraction } from "@bitwarden/auth/angular";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@@ -67,6 +68,7 @@ export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnIn
toastService: ToastService,
@Inject(WINDOW) protected win: Window,
private browserMessagingApi: ZonedMessageListenerService,
private twoFactorFormCacheService: TwoFactorFormCacheServiceAbstraction,
) {
super(
loginStrategyService,
@@ -127,6 +129,20 @@ export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnIn
return;
}
// Load form data from cache if available
const persistedData = await this.twoFactorFormCacheService.getFormData();
if (persistedData) {
if (persistedData.token) {
this.token = persistedData.token;
}
if (persistedData.remember !== undefined) {
this.remember = persistedData.remember;
}
if (persistedData.selectedProviderType !== undefined) {
this.selectedProviderType = persistedData.selectedProviderType;
}
}
await super.ngOnInit();
if (this.selectedProviderType == null) {
return;
@@ -187,7 +203,15 @@ export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnIn
super.ngOnDestroy();
}
anotherMethod() {
async anotherMethod() {
// Save form data to cache before navigating to another method
await this.twoFactorFormCacheService.saveFormData({
token: this.token,
remember: this.remember,
selectedProviderType: this.selectedProviderType,
emailSent: this.selectedProviderType === TwoFactorProviderType.Email,
});
const sso = this.route.snapshot.queryParamMap.get("sso") === "true";
if (sso) {
@@ -257,4 +281,25 @@ export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnIn
encodeURIComponent(JSON.stringify(duoHandOffMessage));
this.platformUtilsService.launchUri(launchUrl);
}
// Override the submit method to persist form data to cache before submitting
async submit() {
// Persist form data before submitting
await this.twoFactorFormCacheService.saveFormData({
token: this.token,
remember: this.remember,
selectedProviderType: this.selectedProviderType,
emailSent: this.selectedProviderType === TwoFactorProviderType.Email,
});
await super.submit();
}
// Override the doSubmit to clear cached data on successful login
async doSubmit() {
await super.doSubmit();
// Clear cached data on successful login
await this.twoFactorFormCacheService.clearFormData();
}
}

View File

@@ -0,0 +1,56 @@
import { Observable, from, of, switchMap } from "rxjs";
import { TwoFactorFormCacheServiceAbstraction, TwoFactorFormData } from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
const STORAGE_KEY = "twoFactorFormData";
export class ExtensionTwoFactorFormCacheService implements TwoFactorFormCacheServiceAbstraction {
constructor(
private storageService: AbstractStorageService,
private configService: ConfigService,
) {}
isEnabled$(): Observable<boolean> {
return from(this.configService.getFeatureFlag(FeatureFlag.PM9115_TwoFactorFormPersistence));
}
async isEnabled(): Promise<boolean> {
return await this.configService.getFeatureFlag(FeatureFlag.PM9115_TwoFactorFormPersistence);
}
formData$(): Observable<TwoFactorFormData | null> {
return this.isEnabled$().pipe(
switchMap((enabled) => {
if (!enabled) {
return of(null);
}
return from(this.storageService.get<TwoFactorFormData>(STORAGE_KEY));
}),
);
}
async saveFormData(data: TwoFactorFormData): Promise<void> {
if (!(await this.isEnabled())) {
return;
}
await this.storageService.save(STORAGE_KEY, data);
}
async getFormData(): Promise<TwoFactorFormData | null> {
if (!(await this.isEnabled())) {
return null;
}
return await this.storageService.get<TwoFactorFormData>(STORAGE_KEY);
}
async clearFormData(): Promise<void> {
await this.storageService.remove(STORAGE_KEY);
}
}

View File

@@ -0,0 +1,66 @@
import { Injectable } from "@angular/core";
import { Observable, from, of, switchMap } from "rxjs";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
export interface TwoFactorFormData {
token?: string;
remember?: boolean;
selectedProviderType?: TwoFactorProviderType;
emailSent?: boolean;
}
const STORAGE_KEY = "twoFactorFormData";
@Injectable({
providedIn: "root",
})
export class ExtensionTwoFactorFormCacheService {
constructor(
private storageService: AbstractStorageService,
private configService: ConfigService,
) {}
isEnabled$(): Observable<boolean> {
return from(this.configService.getFeatureFlag(FeatureFlag.PM9115_TwoFactorFormPersistence));
}
async isEnabled(): Promise<boolean> {
return await this.configService.getFeatureFlag(FeatureFlag.PM9115_TwoFactorFormPersistence);
}
formData$(): Observable<TwoFactorFormData | null> {
return this.isEnabled$().pipe(
switchMap((enabled) => {
if (!enabled) {
return of(null);
}
return from(this.storageService.get<TwoFactorFormData>(STORAGE_KEY));
}),
);
}
async saveFormData(data: TwoFactorFormData): Promise<void> {
if (!(await this.isEnabled())) {
return;
}
await this.storageService.save(STORAGE_KEY, data);
}
async getFormData(): Promise<TwoFactorFormData | null> {
if (!(await this.isEnabled())) {
return null;
}
return await this.storageService.get<TwoFactorFormData>(STORAGE_KEY);
}
async clearFormData(): Promise<void> {
await this.storageService.remove(STORAGE_KEY);
}
}

View File

@@ -176,6 +176,8 @@ import { VaultFilterService } from "../../vault/services/vault-filter.service";
import { DebounceNavigationService } from "./debounce-navigation.service";
import { InitService } from "./init.service";
import { PopupCloseWarningService } from "./popup-close-warning.service";
import { TwoFactorFormCacheServiceAbstraction } from "@bitwarden/auth/angular";
import { ExtensionTwoFactorFormCacheService } from "../../auth/services/extension-two-factor-form-cache.service";
const OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE = new SafeInjectionToken<
AbstractStorageService & ObservableStorageService
@@ -557,6 +559,11 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionTwoFactorAuthWebAuthnComponentService,
deps: [PlatformUtilsService],
}),
safeProvider({
provide: TwoFactorFormCacheServiceAbstraction,
useClass: ExtensionTwoFactorFormCacheService,
deps: [AbstractStorageService, ConfigService],
}),
safeProvider({
provide: TwoFactorAuthDuoComponentService,
useClass: ExtensionTwoFactorAuthDuoComponentService,
@@ -650,6 +657,11 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionLoginDecryptionOptionsService,
deps: [MessagingServiceAbstraction, Router],
}),
safeProvider({
provide: TwoFactorFormCacheServiceAbstraction,
useClass: ExtensionTwoFactorFormCacheService,
deps: [AbstractStorageService, ConfigService],
}),
];
@NgModule({

View File

@@ -0,0 +1 @@
export * from "./two-factor-form-cache.service.abstraction";

View File

@@ -0,0 +1,36 @@
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
/**
* Interface for two-factor form data
*/
interface TwoFactorFormData {
token?: string;
remember?: boolean;
selectedProviderType?: TwoFactorProviderType;
emailSent?: boolean;
}
/**
* Abstract service for two-factor form caching
*/
export abstract class TwoFactorFormCacheServiceAbstraction {
/**
* Check if the form persistence feature is enabled
*/
abstract isEnabled(): Promise<boolean>;
/**
* Save form data to persistent storage
*/
abstract saveFormData(data: TwoFactorFormData): Promise<void>;
/**
* Retrieve form data from persistent storage
*/
abstract getFormData(): Promise<TwoFactorFormData | null>;
/**
* Clear form data from persistent storage
*/
abstract clearFormData(): Promise<void>;
}

View File

@@ -1,6 +1,13 @@
<ng-container>
<bit-form-field>
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
<input bitInput type="text" appAutofocus appInputVerbatim [formControl]="tokenFormControl" />
<input
bitInput
type="text"
appAutofocus
appInputVerbatim
[formControl]="tokenFormControl"
(keyup)="onTokenChange($event)"
/>
</bit-form-field>
</ng-container>

View File

@@ -1,6 +1,6 @@
import { DialogModule } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { Component, Input, Output, EventEmitter } from "@angular/core";
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -32,4 +32,10 @@ import {
})
export class TwoFactorAuthAuthenticatorComponent {
@Input({ required: true }) tokenFormControl: FormControl | undefined = undefined;
@Output() tokenChange = new EventEmitter<{ token: string }>();
onTokenChange(event: Event) {
const tokenValue = (event.target as HTMLInputElement).value || "";
this.tokenChange.emit({ token: tokenValue });
}
}

View File

@@ -1,6 +1,13 @@
<bit-form-field class="!tw-mb-0">
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
<input bitInput type="text" appAutofocus appInputVerbatim [formControl]="tokenFormControl" />
<input
bitInput
type="text"
appAutofocus
appInputVerbatim
[formControl]="tokenFormControl"
(keyup)="onTokenChange($event)"
/>
</bit-form-field>
<div class="tw-mb-4">

View File

@@ -1,6 +1,6 @@
import { DialogModule } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { Component, Input, OnInit, Output, EventEmitter } from "@angular/core";
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -22,6 +22,7 @@ import {
ToastService,
} from "@bitwarden/components";
import { TwoFactorFormCacheServiceAbstraction } from "../../abstractions/two-factor-form-cache.service.abstraction";
import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service";
@Component({
@@ -44,10 +45,10 @@ import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-comp
})
export class TwoFactorAuthEmailComponent implements OnInit {
@Input({ required: true }) tokenFormControl: FormControl | undefined = undefined;
@Output() tokenChange = new EventEmitter<{ token: string }>();
twoFactorEmail: string | undefined = undefined;
emailPromise: Promise<any> | undefined = undefined;
tokenValue: string = "";
emailPromise: Promise<any> | undefined;
constructor(
protected i18nService: I18nService,
@@ -59,6 +60,7 @@ export class TwoFactorAuthEmailComponent implements OnInit {
protected appIdService: AppIdService,
private toastService: ToastService,
private twoFactorAuthEmailComponentService: TwoFactorAuthEmailComponentService,
private twoFactorFormCacheService: TwoFactorFormCacheServiceAbstraction,
) {}
async ngOnInit(): Promise<void> {
@@ -78,11 +80,22 @@ export class TwoFactorAuthEmailComponent implements OnInit {
this.twoFactorEmail = email2faProviderData.Email;
if (providers.size > 1) {
// Check if email has already been sent according to the cache
let cachedData;
if (this.twoFactorFormCacheService) {
cachedData = await this.twoFactorFormCacheService.getFormData();
}
if (providers.size > 1 && !cachedData?.emailSent) {
await this.sendEmail(false);
}
}
onTokenChange(event: Event) {
const tokenValue = (event.target as HTMLInputElement).value || "";
this.tokenChange.emit({ token: tokenValue });
}
async sendEmail(doToast: boolean) {
if (this.emailPromise !== undefined) {
return;
@@ -113,6 +126,16 @@ export class TwoFactorAuthEmailComponent implements OnInit {
request.authRequestId = (await this.loginStrategyService.getAuthRequestId()) ?? "";
this.emailPromise = this.apiService.postTwoFactorEmail(request);
await this.emailPromise;
// Update cache to indicate email was sent
if (this.twoFactorFormCacheService) {
const cachedData = (await this.twoFactorFormCacheService.getFormData()) || {};
await this.twoFactorFormCacheService.saveFormData({
...cachedData,
emailSent: true,
});
}
if (doToast) {
this.toastService.showToast({
variant: "success",

View File

@@ -2,5 +2,6 @@ export * from "./two-factor-auth-component.service";
export * from "./default-two-factor-auth-component.service";
export * from "./two-factor-auth.component";
export * from "./two-factor-auth.guard";
export * from "./abstractions";
export * from "./child-components";

View File

@@ -13,11 +13,13 @@
>
<app-two-factor-auth-email
[tokenFormControl]="tokenFormControl"
(tokenChange)="saveFormDataWithPartialData($event)"
*ngIf="selectedProviderType === providerType.Email"
/>
<app-two-factor-auth-authenticator
[tokenFormControl]="tokenFormControl"
(tokenChange)="saveFormDataWithPartialData($event)"
*ngIf="selectedProviderType === providerType.Authenticator"
/>
<app-two-factor-auth-yubikey
@@ -36,7 +38,7 @@
/>
<bit-form-control *ngIf="!hideRememberMe()">
<bit-label>{{ "dontAskAgainOnThisDeviceFor30Days" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="remember" />
<input type="checkbox" bitCheckbox formControlName="remember" (change)="saveFormData()" />
</bit-form-control>
<app-two-factor-auth-webauthn

View File

@@ -54,6 +54,7 @@ import {
TwoFactorAuthSecurityKeyIcon,
TwoFactorAuthDuoIcon,
} from "../icons/two-factor-auth";
import { TwoFactorFormCacheServiceAbstraction } from "./abstractions/two-factor-form-cache.service.abstraction";
import { TwoFactorAuthAuthenticatorComponent } from "./child-components/two-factor-auth-authenticator.component";
import { TwoFactorAuthDuoComponent } from "./child-components/two-factor-auth-duo/two-factor-auth-duo.component";
@@ -70,6 +71,16 @@ import {
TwoFactorOptionsDialogResult,
} from "./two-factor-options.component";
/**
* Interface for the cache data structure
*/
interface TwoFactorFormCacheData {
token?: string;
remember?: boolean;
selectedProviderType?: TwoFactorProviderType;
emailSent?: boolean;
}
@Component({
standalone: true,
selector: "app-two-factor-auth",
@@ -160,6 +171,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private environmentService: EnvironmentService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private twoFactorFormCacheService: TwoFactorFormCacheServiceAbstraction,
) {}
async ngOnInit() {
@@ -168,7 +180,29 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
this.listenForAuthnSessionTimeout();
await this.setSelected2faProviderType();
// Load persisted form data if available
let loadedCachedProviderType = false;
if (this.twoFactorFormCacheService) {
const persistedData = await this.twoFactorFormCacheService.getFormData();
if (persistedData) {
if (persistedData.token) {
this.form.patchValue({ token: persistedData.token });
}
if (persistedData.remember !== undefined) {
this.form.patchValue({ remember: persistedData.remember });
}
if (persistedData.selectedProviderType !== undefined) {
this.selectedProviderType = persistedData.selectedProviderType;
loadedCachedProviderType = true;
}
}
}
// Only set default 2FA provider type if we don't have one from cache
if (!loadedCachedProviderType) {
await this.setSelected2faProviderType();
}
await this.set2faProvidersAndData();
await this.setAnonLayoutDataByTwoFactorProviderType();
@@ -181,6 +215,45 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
this.loading = false;
}
/**
* Save specific form data fields to the cache
*/
async saveFormDataWithPartialData(data: Partial<TwoFactorFormCacheData>) {
if (this.twoFactorFormCacheService) {
// Get current cached data
const currentData = (await this.twoFactorFormCacheService.getFormData()) || {};
// Only update fields that are present in the data object
const updatedData: TwoFactorFormCacheData = {
...currentData,
...Object.entries(data).reduce((acc, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
}, {} as any),
};
await this.twoFactorFormCacheService.saveFormData(updatedData);
}
}
/**
* Save all current form data to the cache
*/
async saveFormData() {
if (this.twoFactorFormCacheService) {
const formData: TwoFactorFormCacheData = {
token: this.tokenFormControl.value || undefined,
remember: this.rememberFormControl.value ?? undefined,
selectedProviderType: this.selectedProviderType,
emailSent: this.selectedProviderType === TwoFactorProviderType.Email,
};
await this.saveFormDataWithPartialData(formData);
}
}
private async setSelected2faProviderType() {
const webAuthnSupported = this.platformUtilsService.supportsWebAuthn(this.win);
@@ -268,6 +341,16 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
// In all flows but WebAuthn, the remember value is taken from the form.
const rememberValue = remember ?? this.rememberFormControl.value ?? false;
// Persist form data before submitting
if (this.twoFactorFormCacheService) {
await this.twoFactorFormCacheService.saveFormData({
token: tokenValue,
remember: rememberValue,
selectedProviderType: this.selectedProviderType,
emailSent: this.selectedProviderType === TwoFactorProviderType.Email,
});
}
try {
this.formPromise = this.loginStrategyService.logInTwoFactor(
new TokenTwoFactorRequest(this.selectedProviderType, tokenValue, rememberValue),
@@ -275,6 +358,12 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
);
const authResult: AuthResult = await this.formPromise;
this.logService.info("Successfully submitted two factor token");
// Clear persisted data on successful login
if (this.twoFactorFormCacheService) {
await this.twoFactorFormCacheService.clearFormData();
}
await this.handleAuthResult(authResult);
} catch {
this.logService.error("Error submitting two factor token");
@@ -287,6 +376,16 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
};
async selectOtherTwoFactorMethod() {
// Persist current form data before navigating to another method
if (this.twoFactorFormCacheService) {
await this.twoFactorFormCacheService.saveFormData({
token: this.tokenFormControl.value || undefined,
remember: this.rememberFormControl.value ?? undefined,
selectedProviderType: this.selectedProviderType,
emailSent: this.selectedProviderType === TwoFactorProviderType.Email,
});
}
const dialogRef = TwoFactorOptionsComponent.open(this.dialogService);
const response: TwoFactorOptionsDialogResult | string | undefined = await lastValueFrom(
dialogRef.closed,
@@ -300,6 +399,17 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
this.selectedProviderType = response.type;
await this.setAnonLayoutDataByTwoFactorProviderType();
// Update the persisted provider type when a new one is chosen
if (this.twoFactorFormCacheService) {
const persistedData = await this.twoFactorFormCacheService.getFormData();
await this.twoFactorFormCacheService.saveFormData({
token: persistedData?.token || undefined,
remember: persistedData?.remember ?? undefined,
selectedProviderType: response.type,
emailSent: false, // Reset email sent state when switching providers
});
}
this.form.reset();
this.form.updateValueAndValidity();
}