Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-14347][PM-14348] New Device Verification Logic #12451

Merged
merged 15 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router";
import { BehaviorSubject } from "rxjs";

import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";

import { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service";
import { VaultProfileService } from "../services/vault-profile.service";

import { NewDeviceVerificationNoticeGuard } from "./new-device-verification-notice.guard";

describe("NewDeviceVerificationNoticeGuard", () => {
const _state = Object.freeze({}) as RouterStateSnapshot;
const emptyRoute = Object.freeze({ queryParams: {} }) as ActivatedRouteSnapshot;
const eightDaysAgo = new Date();
eightDaysAgo.setDate(eightDaysAgo.getDate() - 8);

const account = {
id: "account-id",
} as unknown as Account;

const activeAccount$ = new BehaviorSubject<Account | null>(account);

const createUrlTree = jest.fn();
const getFeatureFlag = jest.fn().mockImplementation((key) => {
if (key === FeatureFlag.NewDeviceVerificationTemporaryDismiss) {
return Promise.resolve(true);
}

return Promise.resolve(false);
});
const isSelfHost = jest.fn().mockResolvedValue(false);
const getProfileTwoFactorEnabled = jest.fn().mockResolvedValue(false);
const policyAppliesToActiveUser$ = jest.fn().mockReturnValue(new BehaviorSubject<boolean>(false));
const noticeState$ = jest.fn().mockReturnValue(new BehaviorSubject(null));
const getProfileCreationDate = jest.fn().mockResolvedValue(eightDaysAgo);

beforeEach(() => {
getFeatureFlag.mockClear();
isSelfHost.mockClear();
getProfileCreationDate.mockClear();
getProfileTwoFactorEnabled.mockClear();
policyAppliesToActiveUser$.mockClear();
createUrlTree.mockClear();

TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { createUrlTree } },
{ provide: ConfigService, useValue: { getFeatureFlag } },
{ provide: NewDeviceVerificationNoticeService, useValue: { noticeState$ } },
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: PlatformUtilsService, useValue: { isSelfHost } },
{ provide: PolicyService, useValue: { policyAppliesToActiveUser$ } },
{
provide: VaultProfileService,
useValue: { getProfileCreationDate, getProfileTwoFactorEnabled },
},
],
});
});

function newDeviceGuard(route?: ActivatedRouteSnapshot) {
// Run the guard within injection context so `inject` works as you'd expect
// Pass state object to make TypeScript happy
return TestBed.runInInjectionContext(async () =>
NewDeviceVerificationNoticeGuard(route ?? emptyRoute, _state),
);
}

describe("fromNewDeviceVerification", () => {
const route = {
queryParams: { fromNewDeviceVerification: "true" },
} as unknown as ActivatedRouteSnapshot;

it("returns `true` when `fromNewDeviceVerification` is present", async () => {
expect(await newDeviceGuard(route)).toBe(true);
});

it("does not execute other logic", async () => {
// `fromNewDeviceVerification` param should exit early,
// not foolproof but a quick way to test that other logic isn't executed
await newDeviceGuard(route);

expect(getFeatureFlag).not.toHaveBeenCalled();
expect(isSelfHost).not.toHaveBeenCalled();
expect(getProfileTwoFactorEnabled).not.toHaveBeenCalled();
expect(getProfileCreationDate).not.toHaveBeenCalled();
expect(policyAppliesToActiveUser$).not.toHaveBeenCalled();
});
});

describe("missing current account", () => {
afterAll(() => {
// reset `activeAccount$` observable
activeAccount$.next(account);
});

it("redirects to login when account is missing", async () => {
activeAccount$.next(null);

await newDeviceGuard();

expect(createUrlTree).toHaveBeenCalledWith(["/login"]);
});
});

it("returns `true` when 2FA is enabled", async () => {
getProfileTwoFactorEnabled.mockResolvedValueOnce(true);

expect(await newDeviceGuard()).toBe(true);
});

it("returns `true` when the user is self hosted", async () => {
isSelfHost.mockReturnValueOnce(true);

expect(await newDeviceGuard()).toBe(true);
});

it("returns `true` SSO is required", async () => {
policyAppliesToActiveUser$.mockReturnValueOnce(new BehaviorSubject(true));

expect(await newDeviceGuard()).toBe(true);
expect(policyAppliesToActiveUser$).toHaveBeenCalledWith(PolicyType.RequireSso);
});

it("returns `true` when the profile was created less than a week ago", async () => {
const sixDaysAgo = new Date();
sixDaysAgo.setDate(sixDaysAgo.getDate() - 6);

getProfileCreationDate.mockResolvedValueOnce(sixDaysAgo);

expect(await newDeviceGuard()).toBe(true);
});

describe("temp flag", () => {
beforeEach(() => {
getFeatureFlag.mockImplementation((key) => {
if (key === FeatureFlag.NewDeviceVerificationTemporaryDismiss) {
return Promise.resolve(true);
}

return Promise.resolve(false);
});
});

afterAll(() => {
getFeatureFlag.mockReturnValue(false);
});

it("redirects to notice when the user has not dismissed it", async () => {
noticeState$.mockReturnValueOnce(new BehaviorSubject(null));

await newDeviceGuard();

expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
expect(noticeState$).toHaveBeenCalledWith(account.id);
});

it("redirects to notice when the user dismissed it more than 7 days ago", async () => {
const eighteenDaysAgo = new Date();
eighteenDaysAgo.setDate(eighteenDaysAgo.getDate() - 18);

noticeState$.mockReturnValueOnce(
new BehaviorSubject({ last_dismissal: eighteenDaysAgo.toISOString() }),
);

await newDeviceGuard();

expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
});

it("returns true when the user dismissed less than 7 days ago", async () => {
const fourDaysAgo = new Date();
fourDaysAgo.setDate(fourDaysAgo.getDate() - 4);

noticeState$.mockReturnValueOnce(
new BehaviorSubject({ last_dismissal: fourDaysAgo.toISOString() }),
);

expect(await newDeviceGuard()).toBe(true);
});
});

describe("permanent flag", () => {
beforeEach(() => {
getFeatureFlag.mockImplementation((key) => {
if (key === FeatureFlag.NewDeviceVerificationPermanentDismiss) {
return Promise.resolve(true);
}

return Promise.resolve(false);
});
});

afterAll(() => {
getFeatureFlag.mockReturnValue(false);
});

it("redirects when the user has not dismissed", async () => {
noticeState$.mockReturnValueOnce(new BehaviorSubject(null));

await newDeviceGuard();

expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);

noticeState$.mockReturnValueOnce(new BehaviorSubject({ permanent_dismissal: null }));

await newDeviceGuard();

expect(createUrlTree).toHaveBeenCalledTimes(2);
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
});

it("returns `true` when the user has dismissed", async () => {
noticeState$.mockReturnValueOnce(new BehaviorSubject({ permanent_dismissal: true }));

expect(await newDeviceGuard()).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { inject } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivateFn, Router } from "@angular/router";
import { Observable, firstValueFrom, map } from "rxjs";
import { Observable, firstValueFrom } from "rxjs";

import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";

import { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service";
import { VaultProfileService } from "../services/vault-profile.service";

export const NewDeviceVerificationNoticeGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
Expand All @@ -15,6 +19,9 @@
const configService = inject(ConfigService);
const newDeviceVerificationNoticeService = inject(NewDeviceVerificationNoticeService);
const accountService = inject(AccountService);
const platformUtilsService = inject(PlatformUtilsService);
const policyService = inject(PolicyService);
const vaultProfileService = inject(VaultProfileService);

if (route.queryParams["fromNewDeviceVerification"]) {
return true;
Expand All @@ -27,25 +34,88 @@
FeatureFlag.NewDeviceVerificationPermanentDismiss,
);

const currentAcct$: Observable<Account | null> = accountService.activeAccount$.pipe(
map((acct) => acct),
);
if (!tempNoticeFlag && !permNoticeFlag) {
return true;

Check warning on line 38 in libs/angular/src/vault/guards/new-device-verification-notice.guard.ts

View check run for this annotation

Codecov / codecov/patch

libs/angular/src/vault/guards/new-device-verification-notice.guard.ts#L38

Added line #L38 was not covered by tests
}

const currentAcct$: Observable<Account | null> = accountService.activeAccount$;
const currentAcct = await firstValueFrom(currentAcct$);

if (!currentAcct) {
return router.createUrlTree(["/login"]);
}

const has2FAEnabled = await hasATwoFactorProviderEnabled(vaultProfileService, currentAcct.id);
const isSelfHosted = await platformUtilsService.isSelfHost();
const requiresSSO = await isSSORequired(policyService);
const isProfileLessThanWeekOld = await profileIsLessThanWeekOld(
vaultProfileService,
currentAcct.id,
);

// When any of the following are true, the device verification notice is
// not applicable for the user.
if (has2FAEnabled || isSelfHosted || requiresSSO || isProfileLessThanWeekOld) {
return true;
}

const userItems$ = newDeviceVerificationNoticeService.noticeState$(currentAcct.id);
const userItems = await firstValueFrom(userItems$);

// Show the notice when:
// - The temp notice flag is enabled
// - The user hasn't dismissed the notice or the user dismissed it more than 7 days ago
if (
userItems?.last_dismissal == null &&
(userItems?.permanent_dismissal == null || !userItems?.permanent_dismissal) &&
(tempNoticeFlag || permNoticeFlag)
tempNoticeFlag &&
(!userItems?.last_dismissal || isMoreThan7DaysAgo(userItems?.last_dismissal))
) {
return router.createUrlTree(["/new-device-notice"]);
}

// Show the notice when:
// - The permanent notice flag is enabled
// - The user hasn't dismissed the notice
if (permNoticeFlag && !userItems?.permanent_dismissal) {
return router.createUrlTree(["/new-device-notice"]);
}

return true;
};

/** Returns true has one 2FA provider enabled */
async function hasATwoFactorProviderEnabled(
vaultProfileService: VaultProfileService,
userId: string,
): Promise<boolean> {
return vaultProfileService.getProfileTwoFactorEnabled(userId);
}

/** Returns true when the user's profile is less than a week old */
async function profileIsLessThanWeekOld(
vaultProfileService: VaultProfileService,
userId: string,
): Promise<boolean> {
const creationDate = await vaultProfileService.getProfileCreationDate(userId);
return !isMoreThan7DaysAgo(creationDate);
}

/** Returns true when the user is required to login via SSO */
async function isSSORequired(policyService: PolicyService) {
return firstValueFrom(policyService.policyAppliesToActiveUser$(PolicyType.RequireSso));
}

/** Returns the true when the date given is older than 7 days */
function isMoreThan7DaysAgo(date?: string | Date): boolean {
if (!date) {
return false;

Check warning on line 110 in libs/angular/src/vault/guards/new-device-verification-notice.guard.ts

View check run for this annotation

Codecov / codecov/patch

libs/angular/src/vault/guards/new-device-verification-notice.guard.ts#L110

Added line #L110 was not covered by tests
}

const inputDate = new Date(date).getTime();
const today = new Date().getTime();

const differenceInMS = today - inputDate;
const msInADay = 1000 * 60 * 60 * 24;
const differenceInDays = Math.round(differenceInMS / msInADay);

return differenceInDays > 7;
}
Loading
Loading