1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-08 03:23:50 +00:00

[PM-28536] Add phishing blocker setting to account security (#17527)

* added phishing blocker toggle

* design improvements

* Fix TypeScript strict mode errors in PhishingDetectionSettingsServiceAbstraction

* Camel case messages

* Update PhishingDetectionService.initialize parameter ordering

* Add comments to PhishingDetectionSettingsServiceAbstraction

* Change state from global to user settings

* Remove clear on logout phishing-detection-settings

* PM-28536 making a change from getActive to getUser because of method being deprecated

* Moved phishing detection services to own file

* Added new phishing detection availability service to expose complex enable logic

* Add test cases for PhishingDetectionAvailabilityService

* Remove phishing detection availability in favor of one settings service

* Extract phishing detection settings service abstraction to own file

* Update phishing detection-settings service to include availability logic. Updated dependencies

* Add test cases for phishing detection element. Added missing dependencies in testbed setup

* Update services in extension

* Switch checkbox to bit-switch component

* Remove comment

* Remove comment

* Fix prettier vs lint spacing

* Replace deprecated active user state. Updated test cases

* Fix account-security test failing

* Update comments

* Renamed variable

* Removed obsolete message

* Remove unused variable

* Removed unused import

---------

Co-authored-by: Leslie Tilton <23057410+Banrion@users.noreply.github.com>
Co-authored-by: Graham Walker <gwalker@bitwarden.com>
Co-authored-by: Tom <144813356+ttalty@users.noreply.github.com>
This commit is contained in:
Max
2025-12-15 16:51:31 +01:00
committed by GitHub
parent 898c5d366a
commit 721f253ef9
11 changed files with 555 additions and 62 deletions

View File

@@ -0,0 +1,37 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/user-core";
/**
* Abstraction for phishing detection settings
*/
export abstract class PhishingDetectionSettingsServiceAbstraction {
/**
* An observable for whether phishing detection is available for the active user account.
*
* Access is granted only when the PhishingDetection feature flag is enabled and
* at least one of the following is true for the active account:
* - the user has a personal premium subscription
* - the user is a member of a Family org (ProductTierType.Families)
* - the user is a member of an Enterprise org with `usePhishingBlocker` enabled
*
* Note: Non-specified organization types (e.g., Team orgs) do not grant access.
*/
abstract readonly available$: Observable<boolean>;
/**
* An observable for whether phishing detection is on for the active user account
*
* This is true when {@link available$} is true and when {@link enabled$} is true
*/
abstract readonly on$: Observable<boolean>;
/**
* An observable for whether phishing detection is enabled
*/
abstract readonly enabled$: Observable<boolean>;
/**
* Sets whether phishing detection is enabled
*
* @param enabled True to enable, false to disable
*/
abstract setEnabled: (userId: UserId, enabled: boolean) => Promise<void>;
}

View File

@@ -0,0 +1,203 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { UserId } from "../../../types/guid";
import { PhishingDetectionSettingsService } from "./phishing-detection-settings.service";
describe("PhishingDetectionSettingsService", () => {
// Mock services
let mockAccountService: MockProxy<AccountService>;
let mockBillingService: MockProxy<BillingAccountProfileStateService>;
let mockConfigService: MockProxy<ConfigService>;
let mockOrganizationService: MockProxy<OrganizationService>;
// RxJS Subjects we control in the tests
let activeAccountSubject: BehaviorSubject<Account | null>;
let featureFlagSubject: BehaviorSubject<boolean>;
let premiumStatusSubject: BehaviorSubject<boolean>;
let organizationsSubject: BehaviorSubject<Organization[]>;
let service: PhishingDetectionSettingsService;
let stateProvider: FakeStateProvider;
// Constant mock data
const familyOrg = mock<Organization>({
canAccess: true,
isMember: true,
usersGetPremium: true,
productTierType: ProductTierType.Families,
usePhishingBlocker: true,
});
const teamOrg = mock<Organization>({
canAccess: true,
isMember: true,
usersGetPremium: true,
productTierType: ProductTierType.Teams,
usePhishingBlocker: true,
});
const enterpriseOrg = mock<Organization>({
canAccess: true,
isMember: true,
usersGetPremium: true,
productTierType: ProductTierType.Enterprise,
usePhishingBlocker: true,
});
const mockUserId = "mock-user-id" as UserId;
const account = mock<Account>({ id: mockUserId });
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
beforeEach(() => {
// Initialize subjects
activeAccountSubject = new BehaviorSubject<Account | null>(null);
featureFlagSubject = new BehaviorSubject<boolean>(false);
premiumStatusSubject = new BehaviorSubject<boolean>(false);
organizationsSubject = new BehaviorSubject<Organization[]>([]);
// Default implementations for required functions
mockAccountService = mock<AccountService>();
mockAccountService.activeAccount$ = activeAccountSubject.asObservable();
mockBillingService = mock<BillingAccountProfileStateService>();
mockBillingService.hasPremiumPersonally$.mockReturnValue(premiumStatusSubject.asObservable());
mockConfigService = mock<ConfigService>();
mockConfigService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
mockOrganizationService = mock<OrganizationService>();
mockOrganizationService.organizations$.mockReturnValue(organizationsSubject.asObservable());
stateProvider = new FakeStateProvider(accountService);
service = new PhishingDetectionSettingsService(
mockAccountService,
mockBillingService,
mockConfigService,
mockOrganizationService,
stateProvider,
);
});
// Helper to easily get the result of the observable we are testing
const getAccess = () => firstValueFrom(service.available$);
describe("enabled$", () => {
it("should default to true if an account is logged in", async () => {
activeAccountSubject.next(account);
const result = await firstValueFrom(service.enabled$);
expect(result).toBe(true);
});
it("should return the stored value", async () => {
activeAccountSubject.next(account);
await service.setEnabled(mockUserId, false);
const resultDisabled = await firstValueFrom(service.enabled$);
expect(resultDisabled).toBe(false);
await service.setEnabled(mockUserId, true);
const resultEnabled = await firstValueFrom(service.enabled$);
expect(resultEnabled).toBe(true);
});
});
describe("setEnabled", () => {
it("should update the stored value", async () => {
activeAccountSubject.next(account);
await service.setEnabled(mockUserId, false);
let result = await firstValueFrom(service.enabled$);
expect(result).toBe(false);
await service.setEnabled(mockUserId, true);
result = await firstValueFrom(service.enabled$);
expect(result).toBe(true);
});
});
it("returns false immediately when the feature flag is disabled, regardless of other conditions", async () => {
activeAccountSubject.next(account);
premiumStatusSubject.next(true);
organizationsSubject.next([familyOrg]);
featureFlagSubject.next(false);
await expect(getAccess()).resolves.toBe(false);
});
it("returns false if there is no active account present yet", async () => {
activeAccountSubject.next(null); // No active account
featureFlagSubject.next(true); // Flag is on
await expect(getAccess()).resolves.toBe(false);
});
it("returns true when feature flag is enabled and user has premium personally", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
organizationsSubject.next([]);
premiumStatusSubject.next(true);
await expect(getAccess()).resolves.toBe(true);
});
it("returns true when feature flag is enabled and user is in a Family Organization", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
premiumStatusSubject.next(false); // User has no personal premium
organizationsSubject.next([familyOrg]);
await expect(getAccess()).resolves.toBe(true);
});
it("returns true when feature flag is enabled and user is in an Enterprise org with phishing blocker enabled", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
premiumStatusSubject.next(false);
organizationsSubject.next([enterpriseOrg]);
await expect(getAccess()).resolves.toBe(true);
});
it("returns false when user has no access through personal premium or organizations", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
premiumStatusSubject.next(false);
organizationsSubject.next([teamOrg]); // Team org does not give access
await expect(getAccess()).resolves.toBe(false);
});
it("shares/caches the available$ result between multiple subscribers", async () => {
// Use a plain Subject for this test so we control when the premium observable emits
// and avoid the BehaviorSubject's initial emission which can race with subscriptions.
// Provide the Subject directly as the mock return value for the billing service
const oneTimePremium = new Subject<boolean>();
mockBillingService.hasPremiumPersonally$.mockReturnValueOnce(oneTimePremium.asObservable());
activeAccountSubject.next(account);
featureFlagSubject.next(true);
organizationsSubject.next([]);
const p1 = firstValueFrom(service.available$);
const p2 = firstValueFrom(service.available$);
// Trigger the pipeline
oneTimePremium.next(true);
const [first, second] = await Promise.all([p1, p2]);
expect(first).toBe(true);
expect(second).toBe(true);
// The billing function should have been called at most once due to caching
expect(mockBillingService.hasPremiumPersonally$).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,116 @@
import { combineLatest, Observable, of, switchMap } from "rxjs";
import { catchError, distinctUntilChanged, map, shareReplay } from "rxjs/operators";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserId } from "@bitwarden/user-core";
import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { PhishingDetectionSettingsServiceAbstraction } from "../abstractions/phishing-detection-settings.service.abstraction";
const ENABLE_PHISHING_DETECTION = new UserKeyDefinition(
PHISHING_DETECTION_DISK,
"enablePhishingDetection",
{
deserializer: (value: boolean) => value ?? true, // Default: enabled
clearOn: [],
},
);
export class PhishingDetectionSettingsService implements PhishingDetectionSettingsServiceAbstraction {
readonly available$: Observable<boolean>;
readonly enabled$: Observable<boolean>;
readonly on$: Observable<boolean>;
constructor(
private accountService: AccountService,
private billingService: BillingAccountProfileStateService,
private configService: ConfigService,
private organizationService: OrganizationService,
private stateProvider: StateProvider,
) {
this.available$ = this.buildAvailablePipeline$().pipe(
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.enabled$ = this.buildEnabledPipeline$().pipe(
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.on$ = combineLatest([this.available$, this.enabled$]).pipe(
map(([available, enabled]) => available && enabled),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
async setEnabled(userId: UserId, enabled: boolean): Promise<void> {
await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled);
}
/**
* Builds the observable pipeline to determine if phishing detection is available to the user
*
* @returns An observable pipeline that determines if phishing detection is available
*/
private buildAvailablePipeline$(): Observable<boolean> {
return combineLatest([
this.accountService.activeAccount$,
this.configService.getFeatureFlag$(FeatureFlag.PhishingDetection),
]).pipe(
switchMap(([account, featureEnabled]) => {
if (!account || !featureEnabled) {
return of(false);
}
return combineLatest([
this.billingService.hasPremiumPersonally$(account.id).pipe(catchError(() => of(false))),
this.organizationService.organizations$(account.id).pipe(catchError(() => of([]))),
]).pipe(
map(([hasPremium, organizations]) => hasPremium || this.orgGrantsAccess(organizations)),
catchError(() => of(false)),
);
}),
);
}
/**
* Builds the observable pipeline to determine if phishing detection is enabled by the user
*
* @returns True if phishing detection is enabled for the active user
*/
private buildEnabledPipeline$(): Observable<boolean> {
return this.accountService.activeAccount$.pipe(
switchMap((account) => {
if (!account) {
return of(false);
}
return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id);
}),
map((enabled) => enabled ?? true),
);
}
/**
* Determines if any of the user's organizations grant access to phishing detection
*
* @param organizations The organizations the user is a member of
* @returns True if any organization grants access to phishing detection
*/
private orgGrantsAccess(organizations: Organization[]): boolean {
return organizations.some((org) => {
if (!org.canAccess || !org.isMember || !org.usersGetPremium) {
return false;
}
return (
org.productTierType === ProductTierType.Families ||
(org.productTierType === ProductTierType.Enterprise && org.usePhishingBlocker)
);
});
}
}