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:
@@ -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 {}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
71
libs/angular/src/auth/guards/active-auth.guard.spec.ts
Normal file
71
libs/angular/src/auth/guards/active-auth.guard.spec.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
28
libs/angular/src/auth/guards/active-auth.guard.ts
Normal file
28
libs/angular/src/auth/guards/active-auth.guard.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user