1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[PM-4348] Migrate AuthGuards to functions (#9595)

* Migrate auth guards

* Fix remaining auth guard migration

* Fix unauth guard usage

* Add unit tests for auth guard and unauth guard

* Remove unused angular DI code

* Move auth related logic out fo sm guard

* Add tests

* Add more tests for unauth guard

* Fix incorrect merge
This commit is contained in:
Bernd Schoolmann
2024-07-25 23:00:29 +02:00
committed by GitHub
parent 96648b4897
commit ad26f0890a
17 changed files with 392 additions and 176 deletions

View File

@@ -0,0 +1,177 @@
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 { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
import { authGuard } from "./auth.guard";
describe("AuthGuard", () => {
const setup = (
authStatus: AuthenticationStatus,
forceSetPasswordReason: ForceSetPasswordReason,
keyConnectorServiceRequiresAccountConversion: boolean = false,
) => {
const authService: MockProxy<AuthService> = mock<AuthService>();
authService.getAuthStatus.mockResolvedValue(authStatus);
const messagingService: MockProxy<MessagingService> = mock<MessagingService>();
const keyConnectorService: MockProxy<KeyConnectorService> = mock<KeyConnectorService>();
keyConnectorService.getConvertAccountRequired.mockResolvedValue(
keyConnectorServiceRequiresAccountConversion,
);
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null);
accountService.activeAccount$ = activeAccountSubject;
activeAccountSubject.next(
Object.assign(
{
name: "Test User 1",
email: "test@email.com",
emailVerified: true,
} as AccountInfo,
{ id: "test-id" as UserId },
),
);
const forceSetPasswordReasonSubject = new BehaviorSubject<ForceSetPasswordReason>(
forceSetPasswordReason,
);
const masterPasswordService: MockProxy<MasterPasswordServiceAbstraction> =
mock<MasterPasswordServiceAbstraction>();
masterPasswordService.forceSetPasswordReason$.mockReturnValue(forceSetPasswordReasonSubject);
const testBed = TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: "", component: EmptyComponent },
{ path: "guarded-route", component: EmptyComponent, canActivate: [authGuard] },
{ path: "lock", component: EmptyComponent },
{ path: "set-password", component: EmptyComponent },
{ path: "update-temp-password", component: EmptyComponent },
{ path: "remove-password", component: EmptyComponent },
]),
],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: MessagingService, useValue: messagingService },
{ provide: KeyConnectorService, useValue: keyConnectorService },
{ provide: AccountService, useValue: accountService },
{ provide: MasterPasswordServiceAbstraction, useValue: masterPasswordService },
],
});
return {
router: testBed.inject(Router),
};
};
it("should be created", () => {
const { router } = setup(AuthenticationStatus.LoggedOut, ForceSetPasswordReason.None);
expect(router).toBeTruthy();
});
it("should return allow access to the guarded route when user is logged in & unlocked", async () => {
const { router } = setup(AuthenticationStatus.Unlocked, ForceSetPasswordReason.None);
await router.navigate(["guarded-route"]);
expect(router.url).toBe("/guarded-route");
});
it("should redirect to /lock when user is locked", async () => {
const { router } = setup(AuthenticationStatus.Locked, ForceSetPasswordReason.None);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/lock");
});
it("should redirect to / when user is logged out", async () => {
const { router } = setup(AuthenticationStatus.LoggedOut, ForceSetPasswordReason.None);
await router.navigate(["guarded-route"]);
expect(router.url).toBe("/");
});
it("should redirect to /remove-password if keyconnector service requires account conversion", async () => {
const { router } = setup(AuthenticationStatus.Unlocked, ForceSetPasswordReason.None, true);
await router.navigate(["guarded-route"]);
expect(router.url).toBe("/remove-password");
});
it("should redirect to set-password when user is TDE user without password and has password reset permission", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/set-password");
});
it("should redirect to update-temp-password when user has force set password reason", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.AdminForcePasswordReset,
);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/update-temp-password");
});
it("should redirect to update-temp-password when user has weak password", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.WeakMasterPassword,
);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/update-temp-password");
});
it("should allow navigation to set-password when the user is unlocked, is a TDE user without password, and has password reset permission", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
);
await router.navigate(["/set-password"]);
expect(router.url).toContain("/set-password");
});
it("should allow navigation to update-temp-password when the user is unlocked and has admin force password reset permission", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.AdminForcePasswordReset,
);
await router.navigate(["/update-temp-password"]);
expect(router.url).toContain("/update-temp-password");
});
it("should allow navigation to update-temp-password when the user is unlocked and has weak password", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.WeakMasterPassword,
);
await router.navigate(["/update-temp-password"]);
expect(router.url).toContain("/update-temp-password");
});
it("should allow navigation to remove-password when the user is unlocked and has 'none' password reset permission", async () => {
const { router } = setup(AuthenticationStatus.Unlocked, ForceSetPasswordReason.None);
await router.navigate(["/remove-password"]);
expect(router.url).toContain("/remove-password");
});
});

View File

@@ -1,5 +1,11 @@
import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router";
import { inject } from "@angular/core";
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
RouterStateSnapshot,
UrlTree,
} from "@angular/router";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -10,59 +16,57 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router,
private messagingService: MessagingService,
private keyConnectorService: KeyConnectorService,
private accountService: AccountService,
private masterPasswordService: MasterPasswordServiceAbstraction,
) {}
export const authGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
routerState: RouterStateSnapshot,
): Promise<boolean | UrlTree> => {
const authService = inject(AuthService);
const router = inject(Router);
const messagingService = inject(MessagingService);
const keyConnectorService = inject(KeyConnectorService);
const accountService = inject(AccountService);
const masterPasswordService = inject(MasterPasswordServiceAbstraction);
async canActivate(route: ActivatedRouteSnapshot, routerState: RouterStateSnapshot) {
const authStatus = await this.authService.getAuthStatus();
const authStatus = await authService.getAuthStatus();
if (authStatus === AuthenticationStatus.LoggedOut) {
this.messagingService.send("authBlocked", { url: routerState.url });
return false;
}
if (authStatus === AuthenticationStatus.Locked) {
if (routerState != null) {
this.messagingService.send("lockedUrl", { url: routerState.url });
}
return this.router.createUrlTree(["lock"], { queryParams: { promptBiometric: true } });
}
if (
!routerState.url.includes("remove-password") &&
(await this.keyConnectorService.getConvertAccountRequired())
) {
return this.router.createUrlTree(["/remove-password"]);
}
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(userId),
);
if (
forceSetPasswordReason ===
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission &&
!routerState.url.includes("set-password")
) {
return this.router.createUrlTree(["/set-password"]);
}
if (
forceSetPasswordReason !== ForceSetPasswordReason.None &&
!routerState.url.includes("update-temp-password")
) {
return this.router.createUrlTree(["/update-temp-password"]);
}
return true;
if (authStatus === AuthenticationStatus.LoggedOut) {
messagingService.send("authBlocked", { url: routerState.url });
return false;
}
}
if (authStatus === AuthenticationStatus.Locked) {
if (routerState != null) {
messagingService.send("lockedUrl", { url: routerState.url });
}
return router.createUrlTree(["lock"], { queryParams: { promptBiometric: true } });
}
if (
!routerState.url.includes("remove-password") &&
(await keyConnectorService.getConvertAccountRequired())
) {
return router.createUrlTree(["/remove-password"]);
}
const userId = (await firstValueFrom(accountService.activeAccount$)).id;
const forceSetPasswordReason = await firstValueFrom(
masterPasswordService.forceSetPasswordReason$(userId),
);
if (
forceSetPasswordReason ===
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission &&
!routerState.url.includes("set-password")
) {
return router.createUrlTree(["/set-password"]);
}
if (
forceSetPasswordReason !== ForceSetPasswordReason.None &&
!routerState.url.includes("update-temp-password")
) {
return router.createUrlTree(["/update-temp-password"]);
}
return true;
};

View File

@@ -0,0 +1,89 @@
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 { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { unauthGuardFn } from "./unauth.guard";
describe("UnauthGuard", () => {
const setup = (authStatus: AuthenticationStatus) => {
const authService: MockProxy<AuthService> = mock<AuthService>();
authService.getAuthStatus.mockResolvedValue(authStatus);
const activeAccountStatusObservable = new BehaviorSubject<AuthenticationStatus>(authStatus);
authService.activeAccountStatus$ = activeAccountStatusObservable;
const testBed = TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: "", component: EmptyComponent },
{
path: "unauth-guarded-route",
component: EmptyComponent,
canActivate: [unauthGuardFn()],
},
{ path: "vault", component: EmptyComponent },
{ path: "lock", component: EmptyComponent },
{ path: "testhomepage", component: EmptyComponent },
{ path: "testlocked", component: EmptyComponent },
{
path: "testOverrides",
component: EmptyComponent,
canActivate: [
unauthGuardFn({ homepage: () => "/testhomepage", locked: "/testlocked" }),
],
},
]),
],
providers: [{ provide: AuthService, useValue: authService }],
});
return {
router: testBed.inject(Router),
};
};
it("should be created", () => {
const { router } = setup(AuthenticationStatus.LoggedOut);
expect(router).toBeTruthy();
});
it("should redirect to /vault for guarded routes when logged in and unlocked", async () => {
const { router } = setup(AuthenticationStatus.Unlocked);
await router.navigateByUrl("unauth-guarded-route");
expect(router.url).toBe("/vault");
});
it("should allow access to guarded routes when logged out", async () => {
const { router } = setup(AuthenticationStatus.LoggedOut);
await router.navigateByUrl("unauth-guarded-route");
expect(router.url).toBe("/unauth-guarded-route");
});
it("should redirect to /lock for guarded routes when locked", async () => {
const { router } = setup(AuthenticationStatus.Locked);
await router.navigateByUrl("unauth-guarded-route");
expect(router.url).toBe("/lock");
});
it("should redirect to /testhomepage for guarded routes when testOverrides are provided and the account is unlocked", async () => {
const { router } = setup(AuthenticationStatus.Unlocked);
await router.navigateByUrl("testOverrides");
expect(router.url).toBe("/testhomepage");
});
it("should redirect to /testlocked for guarded routes when testOverrides are provided and the account is locked", async () => {
const { router } = setup(AuthenticationStatus.Locked);
await router.navigateByUrl("testOverrides");
expect(router.url).toBe("/testlocked");
});
});

View File

@@ -1,36 +1,10 @@
import { Injectable, inject } from "@angular/core";
import { CanActivate, CanActivateFn, Router, UrlTree } from "@angular/router";
import { inject } from "@angular/core";
import { CanActivateFn, Router, UrlTree } from "@angular/router";
import { Observable, map } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
/**
* @deprecated use unauthGuardFn function instead
*/
@Injectable()
export class UnauthGuard implements CanActivate {
protected homepage = "vault";
constructor(
private authService: AuthService,
private router: Router,
) {}
async canActivate() {
const authStatus = await this.authService.getAuthStatus();
if (authStatus === AuthenticationStatus.LoggedOut) {
return true;
}
if (authStatus === AuthenticationStatus.Locked) {
return this.router.createUrlTree(["lock"]);
}
return this.router.createUrlTree([this.homepage]);
}
}
type UnauthRoutes = {
homepage: () => string;
locked: string;

View File

@@ -269,8 +269,6 @@ import {
IndividualVaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { AuthGuard } from "../auth/guards/auth.guard";
import { UnauthGuard } from "../auth/guards/unauth.guard";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
@@ -306,8 +304,6 @@ import { ModalService } from "./modal.service";
* If you need help please ask for it, do NOT change the type of this array.
*/
const safeProviders: SafeProvider[] = [
safeProvider(AuthGuard),
safeProvider(UnauthGuard),
safeProvider(ModalService),
safeProvider(PasswordRepromptService),
safeProvider({ provide: WINDOW, useValue: window }),