diff --git a/apps/browser/src/auth/popup/two-factor-options-v1.component.ts b/apps/browser/src/auth/popup/two-factor-options-v1.component.ts index 0c71421fc04..e2c85b095e3 100644 --- a/apps/browser/src/auth/popup/two-factor-options-v1.component.ts +++ b/apps/browser/src/auth/popup/two-factor-options-v1.component.ts @@ -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(); } diff --git a/apps/browser/src/auth/popup/two-factor-v1.component.ts b/apps/browser/src/auth/popup/two-factor-v1.component.ts index 723432501e1..28abfada9c7 100644 --- a/apps/browser/src/auth/popup/two-factor-v1.component.ts +++ b/apps/browser/src/auth/popup/two-factor-v1.component.ts @@ -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(); + } } diff --git a/apps/browser/src/auth/services/extension-two-factor-form-cache.service.ts b/apps/browser/src/auth/services/extension-two-factor-form-cache.service.ts new file mode 100644 index 00000000000..f86565742c9 --- /dev/null +++ b/apps/browser/src/auth/services/extension-two-factor-form-cache.service.ts @@ -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 { + return from(this.configService.getFeatureFlag(FeatureFlag.PM9115_TwoFactorFormPersistence)); + } + + async isEnabled(): Promise { + return await this.configService.getFeatureFlag(FeatureFlag.PM9115_TwoFactorFormPersistence); + } + + formData$(): Observable { + return this.isEnabled$().pipe( + switchMap((enabled) => { + if (!enabled) { + return of(null); + } + return from(this.storageService.get(STORAGE_KEY)); + }), + ); + } + + async saveFormData(data: TwoFactorFormData): Promise { + if (!(await this.isEnabled())) { + return; + } + + await this.storageService.save(STORAGE_KEY, data); + } + + async getFormData(): Promise { + if (!(await this.isEnabled())) { + return null; + } + + return await this.storageService.get(STORAGE_KEY); + } + + async clearFormData(): Promise { + await this.storageService.remove(STORAGE_KEY); + } +} diff --git a/apps/browser/src/auth/services/two-factor-form-persistence.service.ts b/apps/browser/src/auth/services/two-factor-form-persistence.service.ts new file mode 100644 index 00000000000..5ae95d719b8 --- /dev/null +++ b/apps/browser/src/auth/services/two-factor-form-persistence.service.ts @@ -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 { + return from(this.configService.getFeatureFlag(FeatureFlag.PM9115_TwoFactorFormPersistence)); + } + + async isEnabled(): Promise { + return await this.configService.getFeatureFlag(FeatureFlag.PM9115_TwoFactorFormPersistence); + } + + formData$(): Observable { + return this.isEnabled$().pipe( + switchMap((enabled) => { + if (!enabled) { + return of(null); + } + return from(this.storageService.get(STORAGE_KEY)); + }), + ); + } + + async saveFormData(data: TwoFactorFormData): Promise { + if (!(await this.isEnabled())) { + return; + } + + await this.storageService.save(STORAGE_KEY, data); + } + + async getFormData(): Promise { + if (!(await this.isEnabled())) { + return null; + } + + return await this.storageService.get(STORAGE_KEY); + } + + async clearFormData(): Promise { + await this.storageService.remove(STORAGE_KEY); + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 3bdb3b79d1c..07dc4e71e1f 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -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({ diff --git a/libs/auth/src/angular/two-factor-auth/abstractions/index.ts b/libs/auth/src/angular/two-factor-auth/abstractions/index.ts new file mode 100644 index 00000000000..9c8185b0b5a --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/abstractions/index.ts @@ -0,0 +1 @@ +export * from "./two-factor-form-cache.service.abstraction"; diff --git a/libs/auth/src/angular/two-factor-auth/abstractions/two-factor-form-cache.service.abstraction.ts b/libs/auth/src/angular/two-factor-auth/abstractions/two-factor-form-cache.service.abstraction.ts new file mode 100644 index 00000000000..c7b8515111a --- /dev/null +++ b/libs/auth/src/angular/two-factor-auth/abstractions/two-factor-form-cache.service.abstraction.ts @@ -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; + + /** + * Save form data to persistent storage + */ + abstract saveFormData(data: TwoFactorFormData): Promise; + + /** + * Retrieve form data from persistent storage + */ + abstract getFormData(): Promise; + + /** + * Clear form data from persistent storage + */ + abstract clearFormData(): Promise; +} diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.html b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.html index af3a8569efa..b52af7b3820 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.html +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.html @@ -1,6 +1,13 @@ {{ "verificationCode" | i18n }} - + diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.ts index a4986d086b2..0c41e2ec3d3 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator.component.ts @@ -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 }); + } } diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.html b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.html index 41873c32ed0..90f1d74ae48 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.html +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.html @@ -1,6 +1,13 @@ {{ "verificationCode" | i18n }} - +
diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts index 1b6ed7e2bb4..7448a68e707 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts @@ -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 | undefined = undefined; - tokenValue: string = ""; + emailPromise: Promise | 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 { @@ -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", diff --git a/libs/auth/src/angular/two-factor-auth/index.ts b/libs/auth/src/angular/two-factor-auth/index.ts index c5dc7b1a59d..cd4f7b2228c 100644 --- a/libs/auth/src/angular/two-factor-auth/index.ts +++ b/libs/auth/src/angular/two-factor-auth/index.ts @@ -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"; diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.html b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.html index ec03944a954..e0c2ffc642a 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.html +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.html @@ -13,11 +13,13 @@ > {{ "dontAskAgainOnThisDeviceFor30Days" | i18n }} - + ) { + 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(); }