1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-12 14:56:58 +01:00

feat(auth): [PM-13659] implement 2FA timeout handling across clients

Add timeout state management for two-factor authentication flows in web, desktop,
and browser extension clients. Includes:

- New timeout screen component with 5-minute session limit
- Updated UI elements and styling
- Comprehensive test coverage

Refs: PM-13659
This commit is contained in:
Alec Rippberger 2024-12-03 13:55:40 -06:00 committed by GitHub
parent 2e53a645c9
commit c073e91f17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 165 additions and 4 deletions

View File

@ -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": {

View File

@ -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,

View File

@ -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",

View File

@ -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"

View File

@ -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()],

View File

@ -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"
},

View File

@ -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: `
<p class="tw-text-center">
{{ "authenticationSessionTimedOut" | i18n }}
</p>
<a routerLink="/login" bitButton block buttonType="primary">
{{ "logIn" | i18n }}
</a>
`,
})
export class TwoFactorTimeoutComponent {}

View File

@ -86,9 +86,12 @@ describe("TwoFactorComponent", () => {
};
let selectedUserDecryptionOptions: BehaviorSubject<UserDecryptionOptions>;
let twoFactorTimeoutSubject: BehaviorSubject<boolean>;
beforeEach(() => {
twoFactorTimeoutSubject = new BehaviorSubject<boolean>(false);
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
mockLoginStrategyService.twoFactorTimeout$ = twoFactorTimeoutSubject;
mockRouter = mock<Router>();
mockI18nService = mock<I18nService>();
mockApiService = mock<ApiService>();
@ -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"]);
});
});

View File

@ -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() {

View File

@ -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";

View File

@ -0,0 +1,8 @@
import { svgIcon } from "@bitwarden/components";
export const TwoFactorTimeoutIcon = svgIcon`
<svg viewBox="0 0 120 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path class="tw-fill-primary-600" d="M36.1675 38.8485C40.71 28.9376 50.8182 22.0428 62.554 22.0428C78.5399 22.0428 91.499 34.8328 91.499 50.6101C91.499 66.3874 78.5399 79.1774 62.554 79.1774C50.5663 79.1774 40.2756 71.9847 35.8807 61.7246C35.3972 60.5957 34.0779 60.0675 32.9341 60.5447C31.7903 61.022 31.255 62.324 31.7386 63.4529C36.8123 75.2975 48.6962 83.6158 62.554 83.6158C81.0235 83.6158 95.9961 68.8387 95.9961 50.6101C95.9961 32.3816 81.0235 17.6044 62.554 17.6044C48.3746 17.6044 36.2605 26.3121 31.3944 38.6029L27.8472 32.2954C27.2902 31.305 26.0358 30.9537 25.0454 31.5107C24.055 32.0676 23.7036 33.3221 24.2606 34.3125L30.0681 44.6391C30.6251 45.6295 31.8795 45.9809 32.8699 45.4239L43.1965 39.6164C44.187 39.0594 44.5383 37.805 43.9813 36.8146C43.4243 35.8242 42.1699 35.4728 41.1795 36.0298L36.1675 38.8485Z" />
<path class="tw-fill-primary-600" d="M72.2625 60.9479C71.8946 61.1944 71.4577 61.3265 71.0118 61.3265C70.4411 61.3265 69.8927 61.1086 69.4781 60.7189L60.6723 52.4767C60.4538 52.272 60.2799 52.0232 60.1595 51.7502C60.0391 51.4772 59.9767 51.1822 59.9767 50.8828V30.3303C59.9767 29.7469 60.2108 29.1877 60.6299 28.7739C61.049 28.36 61.6153 28.1288 62.206 28.1288C62.7968 28.1288 63.363 28.3622 63.7822 28.7739C64.2013 29.1855 64.4353 29.7469 64.4353 30.3303V49.9318L72.5456 57.5268C72.8688 57.8306 73.094 58.2225 73.1899 58.6518C73.2857 59.081 73.2478 59.5301 73.0829 59.9396C72.9179 60.3491 72.6303 60.7013 72.2625 60.9479Z" />
</svg>
`;

View File

@ -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<MasterKey>;
/**
* Emits true if the two factor session has expired.
*/
twoFactorTimeout$: Observable<boolean>;
}

View File

@ -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<CacheData | null>;
private loginStrategyCacheExpirationState: GlobalState<Date | null>;
private authRequestPushNotificationState: GlobalState<string>;
private twoFactorTimeoutSubject = new BehaviorSubject<boolean>(false);
twoFactorTimeout$: Observable<boolean> = 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<AuthResult> {
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<void> {
await this.currentAuthnTypeState.update((_) => null);
await this.loginStrategyCacheState.update((_) => null);
this.twoFactorTimeoutSubject.next(false);
await this.clearSessionTimeout();
}