1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-26 09:33:22 +00:00

Refactor TwoFactorFormCacheService

This commit is contained in:
Alec Rippberger
2025-04-07 10:42:21 -05:00
parent ac279ec2a4
commit 7adc4eaee5
16 changed files with 158 additions and 489 deletions

View File

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

View File

@@ -1,54 +0,0 @@
import { Observable, firstValueFrom } from "rxjs";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
/**
* Interface for two-factor form data
*/
export interface TwoFactorFormData {
token?: string;
remember?: boolean;
selectedProviderType?: TwoFactorProviderType;
emailSent?: boolean;
}
/**
* Abstract service for two-factor form caching
*/
export abstract class TwoFactorFormCacheService {
/**
* Observable that emits the current enabled state of the feature flag
*/
abstract isEnabled$(): Observable<boolean>;
/**
* Helper method that returns whether the feature is enabled
* @returns A promise that resolves to true if the feature is enabled
*/
async isEnabled(): Promise<boolean> {
return firstValueFrom(this.isEnabled$());
}
/**
* Save form data to cache
*/
abstract saveFormData(data: TwoFactorFormData): Promise<void>;
/**
* Observable that emits the current form data
*/
abstract formData$(): Observable<TwoFactorFormData | null>;
/**
* Helper method to retrieve form data
* @returns A promise that resolves to the form data
*/
async getFormData(): Promise<TwoFactorFormData | null> {
return firstValueFrom(this.formData$());
}
/**
* Clear form data from cache
*/
abstract clearFormData(): Promise<void>;
}

View File

@@ -22,8 +22,6 @@ import {
ToastService,
} from "@bitwarden/components";
import { TwoFactorFormCacheService } from "../../abstractions/two-factor-form-cache.service.abstraction";
import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service";
@Component({
@@ -45,7 +43,9 @@ import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-comp
})
export class TwoFactorAuthEmailComponent implements OnInit {
@Input({ required: true }) tokenFormControl: FormControl | undefined = undefined;
@Input({ required: true }) emailSent: boolean = false;
@Output() tokenChange = new EventEmitter<{ token: string }>();
@Output() emailSendEvent = new EventEmitter<void>();
twoFactorEmail: string | undefined = undefined;
emailPromise: Promise<any> | undefined;
@@ -60,7 +60,6 @@ export class TwoFactorAuthEmailComponent implements OnInit {
protected appIdService: AppIdService,
private toastService: ToastService,
private twoFactorAuthEmailComponentService: TwoFactorAuthEmailComponentService,
private twoFactorFormCacheService: TwoFactorFormCacheService,
) {}
async ngOnInit(): Promise<void> {
@@ -80,20 +79,15 @@ export class TwoFactorAuthEmailComponent implements OnInit {
this.twoFactorEmail = email2faProviderData.Email;
// Check if email has already been sent according to the cache
let emailAlreadySent = false;
try {
const cachedData = await this.twoFactorFormCacheService.getFormData();
emailAlreadySent = cachedData?.emailSent === true;
} catch (e) {
this.logService.error(e);
}
if (!emailAlreadySent) {
if (!this.emailSent) {
await this.sendEmail(false);
}
}
/**
* Emits the token value to the parent component
* @param event - The event object from the input field
*/
onTokenChange(event: Event) {
const tokenValue = (event.target as HTMLInputElement).value || "";
this.tokenChange.emit({ token: tokenValue });
@@ -130,17 +124,7 @@ export class TwoFactorAuthEmailComponent implements OnInit {
this.emailPromise = this.apiService.postTwoFactorEmail(request);
await this.emailPromise;
// Update cache to indicate email was sent
try {
const cachedData = (await this.twoFactorFormCacheService.getFormData()) || {};
await this.twoFactorFormCacheService.saveFormData({
...cachedData,
emailSent: true,
token: undefined,
});
} catch (e) {
this.logService.error(e);
}
this.emailSendEvent.emit();
if (doToast) {
this.toastService.showToast({

View File

@@ -2,6 +2,5 @@ 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

@@ -14,6 +14,8 @@
<app-two-factor-auth-email
[tokenFormControl]="tokenFormControl"
(tokenChange)="saveFormDataWithPartialData($event)"
[emailSent]="emailSent"
(emailSendEvent)="saveFormDataWithPartialData({ emailSent: true })"
*ngIf="selectedProviderType === providerType.Email"
/>

View File

@@ -46,6 +46,7 @@ import {
ToastService,
} from "@bitwarden/components";
import { DefaultTwoFactorFormCacheService } from "../../common/services/auth-request/default-two-factor-form-cache.service";
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
import {
TwoFactorAuthAuthenticatorIcon,
@@ -55,7 +56,6 @@ import {
TwoFactorAuthDuoIcon,
} from "../icons/two-factor-auth";
import { TwoFactorFormCacheService } from "./abstractions";
import { TwoFactorAuthAuthenticatorComponent } from "./child-components/two-factor-auth-authenticator.component";
import { TwoFactorAuthDuoComponent } from "./child-components/two-factor-auth-duo/two-factor-auth-duo.component";
import { TwoFactorAuthEmailComponent } from "./child-components/two-factor-auth-email/two-factor-auth-email.component";
@@ -101,7 +101,11 @@ interface TwoFactorFormCacheData {
TwoFactorAuthYubikeyComponent,
TwoFactorAuthWebAuthnComponent,
],
providers: [],
providers: [
{
provide: DefaultTwoFactorFormCacheService,
},
],
})
export class TwoFactorAuthComponent implements OnInit, OnDestroy {
@ViewChild("continueButton", { read: ElementRef, static: false }) continueButton:
@@ -110,6 +114,11 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
loading = true;
/**
* Whether the email has been sent according to the cache
*/
emailSent = false;
orgSsoIdentifier: string | undefined = undefined;
providerType = TwoFactorProviderType;
@@ -171,7 +180,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private environmentService: EnvironmentService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private twoFactorFormCacheService: TwoFactorFormCacheService,
private twoFactorFormCacheService: DefaultTwoFactorFormCacheService,
) {}
async ngOnInit() {
@@ -180,9 +189,12 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
this.listenForAuthnSessionTimeout();
// Initialize the cache
await this.twoFactorFormCacheService.init();
// Load persisted form data if available
let loadedCachedProviderType = false;
const persistedData = await this.twoFactorFormCacheService.getFormData();
const persistedData = this.twoFactorFormCacheService.getCachedTwoFactorFormData();
if (persistedData) {
if (persistedData.token) {
this.form.patchValue({ token: persistedData.token });
@@ -194,6 +206,9 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
this.selectedProviderType = persistedData.selectedProviderType;
loadedCachedProviderType = true;
}
if (persistedData.emailSent !== undefined) {
this.emailSent = persistedData.emailSent;
}
}
// Only set default 2FA provider type if we don't have one from cache
@@ -218,20 +233,17 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
*/
async saveFormDataWithPartialData(data: Partial<TwoFactorFormCacheData>) {
// Get current cached data
const currentData = (await this.twoFactorFormCacheService.getFormData()) || {};
const currentData = this.twoFactorFormCacheService.getCachedTwoFactorFormData();
// 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);
this.twoFactorFormCacheService.cacheTwoFactorFormData({
token: data?.token ?? currentData?.token ?? "",
remember: data?.remember ?? currentData?.remember ?? false,
selectedProviderType:
data?.selectedProviderType ??
currentData?.selectedProviderType ??
TwoFactorProviderType.Authenticator,
emailSent: data?.emailSent ?? currentData?.emailSent ?? false,
});
}
/**
@@ -335,7 +347,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
const rememberValue = remember ?? this.rememberFormControl.value ?? false;
// Persist form data before submitting
await this.twoFactorFormCacheService.saveFormData({
this.twoFactorFormCacheService.cacheTwoFactorFormData({
token: tokenValue,
remember: rememberValue,
selectedProviderType: this.selectedProviderType,
@@ -363,11 +375,11 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
async selectOtherTwoFactorMethod() {
// Persist current form data before navigating to another method
await this.twoFactorFormCacheService.saveFormData({
token: undefined,
remember: undefined,
this.twoFactorFormCacheService.cacheTwoFactorFormData({
token: "",
remember: false,
selectedProviderType: this.selectedProviderType,
emailSent: this.selectedProviderType === TwoFactorProviderType.Email,
emailSent: false,
});
const dialogRef = TwoFactorOptionsComponent.open(this.dialogService);
@@ -384,11 +396,11 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
await this.setAnonLayoutDataByTwoFactorProviderType();
// Update the persisted provider type when a new one is chosen
await this.twoFactorFormCacheService.saveFormData({
token: undefined,
remember: undefined,
this.twoFactorFormCacheService.cacheTwoFactorFormData({
token: "",
remember: false,
selectedProviderType: response.type,
emailSent: false, // Reset email sent state when switching providers
emailSent: false,
});
this.form.reset();
@@ -469,7 +481,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
private async handleAuthResult(authResult: AuthResult) {
// Clear form cache
await this.twoFactorFormCacheService.clearFormData();
this.twoFactorFormCacheService.clearCachedTwoFactorFormData();
if (await this.handleMigrateEncryptionKey(authResult)) {
return; // stop login process

View File

@@ -0,0 +1,90 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorFormView } from "@bitwarden/common/auth/models/view/two-factor-form.view";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
const TWO_FACTOR_FORM_CACHE_KEY = "two-factor-form-cache";
export interface TwoFactorFormData {
token?: string;
remember?: boolean;
selectedProviderType?: TwoFactorProviderType;
emailSent?: boolean;
}
/**
* This is a cache service used for the login via auth request component.
*
* There is sensitive information stored temporarily here. Cache will be cleared
* after 2 minutes.
*/
@Injectable()
export class DefaultTwoFactorFormCacheService {
private viewCacheService: ViewCacheService = inject(ViewCacheService);
private configService: ConfigService = inject(ConfigService);
/** True when the `PM9115_TwoFactorExtensionDataPersistence` flag is enabled */
private featureEnabled: boolean = false;
/**
* Signal for the cached TwoFactorFormData.
*/
private defaultTwoFactorFormCache: WritableSignal<TwoFactorFormView | null> =
this.viewCacheService.signal<TwoFactorFormView | null>({
key: TWO_FACTOR_FORM_CACHE_KEY,
initialValue: null,
deserializer: TwoFactorFormView.fromJSON,
});
constructor() {}
/**
* Must be called once before interacting with the cached data, otherwise methods will be noop.
*/
async init() {
this.featureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
}
/**
* Update the cache with the new TwoFactorFormData.
*/
cacheTwoFactorFormData(data: TwoFactorFormData): void {
if (!this.featureEnabled) {
return;
}
this.defaultTwoFactorFormCache.set({
token: data.token,
remember: data.remember,
selectedProviderType: data.selectedProviderType,
emailSent: data.emailSent,
} as TwoFactorFormView);
}
/**
* Clears the cached TwoFactorFormData.
*/
clearCachedTwoFactorFormData(): void {
if (!this.featureEnabled) {
return;
}
this.defaultTwoFactorFormCache.set(null);
}
/**
* Returns the cached TwoFactorFormData when available.
*/
getCachedTwoFactorFormData(): TwoFactorFormView | null {
if (!this.featureEnabled) {
return null;
}
return this.defaultTwoFactorFormCache();
}
}

View File

@@ -0,0 +1,19 @@
import { Jsonify } from "type-fest";
import { View } from "@bitwarden/common/models/view/view";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
/**
* This is a cache model for the two factor form.
*/
export class TwoFactorFormView implements View {
token: string | undefined = undefined;
remember: boolean | undefined = undefined;
selectedProviderType: TwoFactorProviderType | undefined = undefined;
emailSent: boolean | undefined = undefined;
static fromJSON(obj: Partial<Jsonify<TwoFactorFormView>>): TwoFactorFormView {
return Object.assign(new TwoFactorFormView(), obj);
}
}