diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ab1c6377e6a..39bc6ed9b86 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 77a720557c6..b158a83c566 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -6,6 +6,7 @@ import { EnvironmentSelectorRouteData, ExtensionDefaultOverlayPosition, } from "@bitwarden/angular/auth/components/environment-selector.component"; +import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component"; import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect"; import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { @@ -38,6 +39,7 @@ import { VaultIcon, LoginDecryptionOptionsComponent, DevicesIcon, + TwoFactorTimeoutIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -199,6 +201,29 @@ const routes: Routes = [ ], }, ), + { + path: "", + component: ExtensionAnonLayoutWrapperComponent, + children: [ + { + path: "2fa-timeout", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + children: [ + { + path: "", + component: TwoFactorTimeoutComponent, + }, + ], + data: { + pageTitle: { + key: "authenticationTimeout", + }, + pageIcon: TwoFactorTimeoutIcon, + elevation: 1, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, + ], + }, { path: "2fa-options", component: TwoFactorOptionsComponent, diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index e61335859c4..db9ece317c8 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -5,6 +5,7 @@ import { DesktopDefaultOverlayPosition, EnvironmentSelectorComponent, } from "@bitwarden/angular/auth/components/environment-selector.component"; +import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component"; import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { authGuard, @@ -35,6 +36,7 @@ import { VaultIcon, LoginDecryptionOptionsComponent, DevicesIcon, + TwoFactorTimeoutIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -96,6 +98,22 @@ const routes: Routes = [ ], }, ), + { + path: "2fa-timeout", + component: AnonLayoutWrapperComponent, + children: [ + { + path: "", + component: TwoFactorTimeoutComponent, + }, + ], + data: { + pageIcon: TwoFactorTimeoutIcon, + pageTitle: { + key: "authenticationTimeout", + }, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, { path: "register", component: RegisterComponent }, { path: "vault", diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index e9bebb8bfc0..e4c235dada9 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -919,6 +919,12 @@ "baseUrl": { "message": "Server URL" }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "selfHostBaseUrl": { "message": "Self-host server URL", "description": "Label for field requesting a self-hosted integration service URL" diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 6e2e97d8e06..8aea628ddde 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { Route, RouterModule, Routes } from "@angular/router"; +import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component"; import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { authGuard, @@ -26,6 +27,7 @@ import { LoginSecondaryContentComponent, LockV2Component, LockIcon, + TwoFactorTimeoutIcon, UserLockIcon, LoginViaAuthRequestComponent, DevicesIcon, @@ -507,7 +509,6 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, ), - { path: "2fa", canActivate: [unauthGuardFn()], @@ -527,6 +528,28 @@ const routes: Routes = [ }, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, + { + path: "2fa-timeout", + canActivate: [unauthGuardFn()], + children: [ + { + path: "", + component: TwoFactorTimeoutComponent, + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + data: { + pageIcon: TwoFactorTimeoutIcon, + pageTitle: { + key: "authenticationTimeout", + }, + titleId: "authenticationTimeout", + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, { path: "recover-2fa", canActivate: [unauthGuardFn()], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 7025e194786..cab0e703a7d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1137,6 +1137,12 @@ "logInToBitwarden": { "message": "Log in to Bitwarden" }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "verifyIdentity": { "message": "Verify your Identity" }, diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-expired.component.ts b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-expired.component.ts new file mode 100644 index 00000000000..faa08cf073b --- /dev/null +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-expired.component.ts @@ -0,0 +1,25 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule } from "@bitwarden/components"; + +/** + * This component is used to display a message to the user that their authentication session has expired. + * It provides a button to navigate to the login page. + */ +@Component({ + selector: "app-two-factor-expired", + standalone: true, + imports: [CommonModule, JslibModule, ButtonModule, RouterModule], + template: ` +

+ {{ "authenticationSessionTimedOut" | i18n }} +

+ + {{ "logIn" | i18n }} + + `, +}) +export class TwoFactorTimeoutComponent {} diff --git a/libs/angular/src/auth/components/two-factor.component.spec.ts b/libs/angular/src/auth/components/two-factor.component.spec.ts index e21d119adf8..5a1903d6671 100644 --- a/libs/angular/src/auth/components/two-factor.component.spec.ts +++ b/libs/angular/src/auth/components/two-factor.component.spec.ts @@ -86,9 +86,12 @@ describe("TwoFactorComponent", () => { }; let selectedUserDecryptionOptions: BehaviorSubject; + let twoFactorTimeoutSubject: BehaviorSubject; beforeEach(() => { + twoFactorTimeoutSubject = new BehaviorSubject(false); mockLoginStrategyService = mock(); + mockLoginStrategyService.twoFactorTimeout$ = twoFactorTimeoutSubject; mockRouter = mock(); mockI18nService = mock(); mockApiService = mock(); @@ -492,4 +495,10 @@ describe("TwoFactorComponent", () => { }); }); }); + + it("navigates to the timeout route when timeout expires", async () => { + twoFactorTimeoutSubject.next(true); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["2fa-timeout"]); + }); }); diff --git a/libs/angular/src/auth/components/two-factor.component.ts b/libs/angular/src/auth/components/two-factor.component.ts index eaff9d665fd..33269e28e96 100644 --- a/libs/angular/src/auth/components/two-factor.component.ts +++ b/libs/angular/src/auth/components/two-factor.component.ts @@ -1,4 +1,5 @@ -import { Directive, Inject, OnDestroy, OnInit } from "@angular/core"; +import { Directive, Inject, OnInit, OnDestroy } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, NavigationExtras, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; @@ -68,6 +69,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI protected changePasswordRoute = "set-password"; protected forcePasswordResetRoute = "update-temp-password"; protected successRoute = "vault"; + protected twoFactorTimeoutRoute = "2fa-timeout"; get isDuoProvider(): boolean { return ( @@ -99,6 +101,21 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI ) { super(environmentService, i18nService, platformUtilsService, toastService); this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win); + + // Add subscription to twoFactorTimeout$ and navigate to twoFactorTimeoutRoute if expired + this.loginStrategyService.twoFactorTimeout$ + .pipe(takeUntilDestroyed()) + .subscribe(async (expired) => { + if (!expired) { + return; + } + + try { + await this.router.navigate([this.twoFactorTimeoutRoute]); + } catch (err) { + this.logService.error(`Failed to navigate to ${this.twoFactorTimeoutRoute} route`, err); + } + }); } async ngOnInit() { diff --git a/libs/auth/src/angular/icons/index.ts b/libs/auth/src/angular/icons/index.ts index 9c444df5702..05bb630fcb3 100644 --- a/libs/auth/src/angular/icons/index.ts +++ b/libs/auth/src/angular/icons/index.ts @@ -10,3 +10,4 @@ export * from "./vault.icon"; export * from "./registration-user-add.icon"; export * from "./registration-lock-alt.icon"; export * from "./registration-expired-link.icon"; +export * from "./two-factor-timeout.icon"; diff --git a/libs/auth/src/angular/icons/two-factor-timeout.icon.ts b/libs/auth/src/angular/icons/two-factor-timeout.icon.ts new file mode 100644 index 00000000000..71d0aa549dc --- /dev/null +++ b/libs/auth/src/angular/icons/two-factor-timeout.icon.ts @@ -0,0 +1,8 @@ +import { svgIcon } from "@bitwarden/components"; + +export const TwoFactorTimeoutIcon = svgIcon` + + + + +`; diff --git a/libs/auth/src/common/abstractions/login-strategy.service.ts b/libs/auth/src/common/abstractions/login-strategy.service.ts index a46636532bf..e86cd6b0b0d 100644 --- a/libs/auth/src/common/abstractions/login-strategy.service.ts +++ b/libs/auth/src/common/abstractions/login-strategy.service.ts @@ -71,4 +71,8 @@ export abstract class LoginStrategyServiceAbstraction { * Creates a master key from the provided master password and email. */ makePreloginKey: (masterPassword: string, email: string) => Promise; + /** + * Emits true if the two factor session has expired. + */ + twoFactorTimeout$: Observable; } diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index b86d1e3f3b4..99e3c057e11 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -6,6 +6,7 @@ import { Observable, shareReplay, Subscription, + BehaviorSubject, } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -68,7 +69,7 @@ import { CACHE_KEY, } from "./login-strategy.state"; -const sessionTimeoutLength = 2 * 60 * 1000; // 2 minutes +const sessionTimeoutLength = 5 * 60 * 1000; // 5 minutes export class LoginStrategyService implements LoginStrategyServiceAbstraction { private sessionTimeoutSubscription: Subscription; @@ -76,6 +77,9 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { private loginStrategyCacheState: GlobalState; private loginStrategyCacheExpirationState: GlobalState; private authRequestPushNotificationState: GlobalState; + private twoFactorTimeoutSubject = new BehaviorSubject(false); + + twoFactorTimeout$: Observable = this.twoFactorTimeoutSubject.asObservable(); private loginStrategy$: Observable< | UserApiLoginStrategy @@ -123,7 +127,14 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { ); this.taskSchedulerService.registerTaskHandler( ScheduledTaskNames.loginStrategySessionTimeout, - () => this.clearCache(), + async () => { + this.twoFactorTimeoutSubject.next(true); + try { + await this.clearCache(); + } catch (e) { + this.logService.error("Failed to clear cache during session timeout", e); + } + }, ); this.currentAuthType$ = this.currentAuthnTypeState.state$; @@ -189,6 +200,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { | WebAuthnLoginCredentials, ): Promise { await this.clearCache(); + this.twoFactorTimeoutSubject.next(false); await this.currentAuthnTypeState.update((_) => credentials.type); @@ -273,6 +285,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { private async clearCache(): Promise { await this.currentAuthnTypeState.update((_) => null); await this.loginStrategyCacheState.update((_) => null); + this.twoFactorTimeoutSubject.next(false); await this.clearSessionTimeout(); }