1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 22:33:35 +00:00

feat(auth): [PM-8221] implement device verification for unknown devices

Add device verification flow that requires users to enter an OTP when logging in from an unrecognized device. This includes:

- New device verification route and guard
- Email OTP verification component
- Authentication timeout handling

PM-8221
This commit is contained in:
Alec Rippberger
2025-01-23 12:57:48 -06:00
committed by GitHub
parent f50f5ef70b
commit aa1c0ca0ee
35 changed files with 852 additions and 86 deletions

View File

@@ -10,7 +10,7 @@ import { ButtonModule } from "@bitwarden/components";
* It provides a button to navigate to the login page.
*/
@Component({
selector: "app-two-factor-expired",
selector: "app-authentication-timeout",
standalone: true,
imports: [CommonModule, JslibModule, ButtonModule, RouterModule],
template: `
@@ -22,4 +22,4 @@ import { ButtonModule } from "@bitwarden/components";
</a>
`,
})
export class TwoFactorTimeoutComponent {}
export class AuthenticationTimeoutComponent {}

View File

@@ -86,12 +86,12 @@ describe("TwoFactorComponent", () => {
};
let selectedUserDecryptionOptions: BehaviorSubject<UserDecryptionOptions>;
let twoFactorTimeoutSubject: BehaviorSubject<boolean>;
let authenticationSessionTimeoutSubject: BehaviorSubject<boolean>;
beforeEach(() => {
twoFactorTimeoutSubject = new BehaviorSubject<boolean>(false);
authenticationSessionTimeoutSubject = new BehaviorSubject<boolean>(false);
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
mockLoginStrategyService.twoFactorTimeout$ = twoFactorTimeoutSubject;
mockLoginStrategyService.authenticationSessionTimeout$ = authenticationSessionTimeoutSubject;
mockRouter = mock<Router>();
mockI18nService = mock<I18nService>();
mockApiService = mock<ApiService>();
@@ -153,7 +153,9 @@ describe("TwoFactorComponent", () => {
}),
};
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(
mockUserDecryptionOpts.withMasterPassword,
);
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
TestBed.configureTestingModule({
@@ -497,8 +499,8 @@ describe("TwoFactorComponent", () => {
});
it("navigates to the timeout route when timeout expires", async () => {
twoFactorTimeoutSubject.next(true);
authenticationSessionTimeoutSubject.next(true);
expect(mockRouter.navigate).toHaveBeenCalledWith(["2fa-timeout"]);
expect(mockRouter.navigate).toHaveBeenCalledWith(["authentication-timeout"]);
});
});

View File

@@ -71,7 +71,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
protected changePasswordRoute = "set-password";
protected forcePasswordResetRoute = "update-temp-password";
protected successRoute = "vault";
protected twoFactorTimeoutRoute = "2fa-timeout";
protected twoFactorTimeoutRoute = "authentication-timeout";
get isDuoProvider(): boolean {
return (
@@ -104,8 +104,8 @@ 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$
// Add subscription to authenticationSessionTimeout$ and navigate to twoFactorTimeoutRoute if expired
this.loginStrategyService.authenticationSessionTimeout$
.pipe(takeUntilDestroyed())
.subscribe(async (expired) => {
if (!expired) {

View File

@@ -0,0 +1,71 @@
import { Component } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { activeAuthGuard } from "./active-auth.guard";
@Component({ template: "" })
class EmptyComponent {}
describe("activeAuthGuard", () => {
const setup = (authType: AuthenticationType | null) => {
const loginStrategyService: MockProxy<LoginStrategyServiceAbstraction> =
mock<LoginStrategyServiceAbstraction>();
const currentAuthTypeSubject = new BehaviorSubject<AuthenticationType | null>(authType);
loginStrategyService.currentAuthType$ = currentAuthTypeSubject;
const logService: MockProxy<LogService> = mock<LogService>();
const testBed = TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: "", component: EmptyComponent },
{
path: "protected-route",
component: EmptyComponent,
canActivate: [activeAuthGuard()],
},
{ path: "login", component: EmptyComponent },
]),
],
providers: [
{ provide: LoginStrategyServiceAbstraction, useValue: loginStrategyService },
{ provide: LogService, useValue: logService },
],
declarations: [EmptyComponent],
});
return {
router: testBed.inject(Router),
logService,
loginStrategyService,
};
};
it("creates the guard", () => {
const { router } = setup(AuthenticationType.Password);
expect(router).toBeTruthy();
});
it("allows access with an active login session", async () => {
const { router } = setup(AuthenticationType.Password);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/protected-route");
});
it("redirects to login with no active session", async () => {
const { router, logService } = setup(null);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/login");
expect(logService.error).toHaveBeenCalledWith("No active login session found.");
});
});

View File

@@ -0,0 +1,28 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
/**
* Guard that ensures there is an active login session before allowing access
* to the new device verification route.
* If not, redirects to login.
*/
export function activeAuthGuard(): CanActivateFn {
return async () => {
const loginStrategyService = inject(LoginStrategyServiceAbstraction);
const logService = inject(LogService);
const router = inject(Router);
// Check if we have a valid login session
const authType = await firstValueFrom(loginStrategyService.currentAuthType$);
if (authType === null) {
logService.error("No active login session found.");
return router.createUrlTree(["/login"]);
}
return true;
};
}

View File

@@ -1,4 +1,5 @@
export * from "./auth.guard";
export * from "./active-auth.guard";
export * from "./lock.guard";
export * from "./redirect.guard";
export * from "./tde-decryption-required.guard";

View File

@@ -39,6 +39,8 @@ import {
DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService,
LoginSuccessHandlerService,
PasswordLoginStrategy,
PasswordLoginStrategyData,
LoginApprovalComponentServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
@@ -1436,6 +1438,37 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultLoginSuccessHandlerService,
deps: [SyncService, UserAsymmetricKeysRegenerationService],
}),
safeProvider({
provide: PasswordLoginStrategy,
useClass: PasswordLoginStrategy,
deps: [
PasswordLoginStrategyData,
PasswordStrengthServiceAbstraction,
PolicyServiceAbstraction,
LoginStrategyServiceAbstraction,
AccountServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
KeyService,
EncryptService,
ApiServiceAbstraction,
TokenServiceAbstraction,
AppIdServiceAbstraction,
PlatformUtilsServiceAbstraction,
MessagingServiceAbstraction,
LogService,
StateServiceAbstraction,
TwoFactorServiceAbstraction,
InternalUserDecryptionOptionsServiceAbstraction,
BillingAccountProfileStateService,
VaultTimeoutSettingsServiceAbstraction,
KdfConfigService,
],
}),
safeProvider({
provide: PasswordLoginStrategyData,
useClass: PasswordLoginStrategyData,
deps: [],
}),
];
@NgModule({