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:
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user