mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-14571] At Risk Passwords - Badge Update (#15983)
* add exclamation badge for at risk passwords on tab
* add berry icon for the badge when pending tasks are present
* remove integration wtih autofill for pending task badge
* add ability to override Never match strategy
- This is helpful for non-autofill purposes but cipher matching is still needed. This will default to the domain.
* add at-risk-cipher badge updater service
* Revert "add exclamation badge for at risk passwords on tab"
This reverts commit a9643c03d5.
* remove nullish-coalescing
* ensure that all user related observables use the same user.id
---------
Co-authored-by: Shane Melton <smelton@bitwarden.com>
This commit is contained in:
@@ -302,6 +302,7 @@ import { OffscreenStorageService } from "../platform/storage/offscreen-storage.s
|
||||
import { SyncServiceListener } from "../platform/sync/sync-service.listener";
|
||||
import { BrowserSystemNotificationService } from "../platform/system-notifications/browser-system-notification.service";
|
||||
import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging";
|
||||
import { AtRiskCipherBadgeUpdaterService } from "../vault/services/at-risk-cipher-badge-updater.service";
|
||||
|
||||
import CommandsBackground from "./commands.background";
|
||||
import IdleBackground from "./idle.background";
|
||||
@@ -433,6 +434,7 @@ export default class MainBackground {
|
||||
badgeService: BadgeService;
|
||||
authStatusBadgeUpdaterService: AuthStatusBadgeUpdaterService;
|
||||
autofillBadgeUpdaterService: AutofillBadgeUpdaterService;
|
||||
atRiskCipherUpdaterService: AtRiskCipherBadgeUpdaterService;
|
||||
|
||||
onUpdatedRan: boolean;
|
||||
onReplacedRan: boolean;
|
||||
@@ -1838,6 +1840,14 @@ export default class MainBackground {
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.atRiskCipherUpdaterService = new AtRiskCipherBadgeUpdaterService(
|
||||
this.badgeService,
|
||||
this.accountService,
|
||||
this.cipherService,
|
||||
this.logService,
|
||||
this.taskService,
|
||||
);
|
||||
|
||||
this.tabsBackground = new TabsBackground(
|
||||
this,
|
||||
this.notificationBackground,
|
||||
@@ -1847,6 +1857,7 @@ export default class MainBackground {
|
||||
await this.overlayBackground.init();
|
||||
await this.tabsBackground.init();
|
||||
await this.autofillBadgeUpdaterService.init();
|
||||
await this.atRiskCipherUpdaterService.init();
|
||||
}
|
||||
|
||||
generatePassword = async (): Promise<string> => {
|
||||
|
||||
BIN
apps/browser/src/images/berry19.png
Normal file
BIN
apps/browser/src/images/berry19.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
apps/browser/src/images/berry38.png
Normal file
BIN
apps/browser/src/images/berry38.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +1,8 @@
|
||||
export const BadgeIcon = {
|
||||
Berry: {
|
||||
19: "/images/berry19.png",
|
||||
38: "/images/berry38.png",
|
||||
},
|
||||
LoggedOut: {
|
||||
19: "/images/icon19_gray.png",
|
||||
38: "/images/icon38_gray.png",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SecurityTask, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { BadgeService } from "../../platform/badge/badge.service";
|
||||
import { BadgeIcon } from "../../platform/badge/icon";
|
||||
import { BadgeStatePriority } from "../../platform/badge/priority";
|
||||
import { Unset } from "../../platform/badge/state";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
import { AtRiskCipherBadgeUpdaterService } from "./at-risk-cipher-badge-updater.service";
|
||||
|
||||
describe("AtRiskCipherBadgeUpdaterService", () => {
|
||||
let service: AtRiskCipherBadgeUpdaterService;
|
||||
|
||||
let setState: jest.Mock;
|
||||
let clearState: jest.Mock;
|
||||
let warning: jest.Mock;
|
||||
let getAllDecryptedForUrl: jest.Mock;
|
||||
let getTab: jest.Mock;
|
||||
let addListener: jest.Mock;
|
||||
|
||||
const activeAccount$ = new BehaviorSubject({ id: "test-account-id" });
|
||||
const cipherViews$ = new BehaviorSubject([]);
|
||||
const pendingTasks$ = new BehaviorSubject<SecurityTask[]>([]);
|
||||
const userId = "test-user-id" as UserId;
|
||||
|
||||
beforeEach(async () => {
|
||||
setState = jest.fn().mockResolvedValue(undefined);
|
||||
clearState = jest.fn().mockResolvedValue(undefined);
|
||||
warning = jest.fn();
|
||||
getAllDecryptedForUrl = jest.fn().mockResolvedValue([]);
|
||||
getTab = jest.fn();
|
||||
addListener = jest.fn();
|
||||
|
||||
jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener);
|
||||
jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab);
|
||||
|
||||
service = new AtRiskCipherBadgeUpdaterService(
|
||||
{ setState, clearState } as unknown as BadgeService,
|
||||
{ activeAccount$ } as unknown as AccountService,
|
||||
{ cipherViews$, getAllDecryptedForUrl } as unknown as CipherService,
|
||||
{ warning } as unknown as LogService,
|
||||
{ pendingTasks$ } as unknown as TaskService,
|
||||
);
|
||||
|
||||
await service.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("clears the tab state when there are no ciphers and no pending tasks", async () => {
|
||||
const tab = { id: 1 } as chrome.tabs.Tab;
|
||||
|
||||
await service["setTabState"](tab, userId, []);
|
||||
|
||||
expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1");
|
||||
});
|
||||
|
||||
it("sets state when there are pending tasks for the tab", async () => {
|
||||
const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab;
|
||||
const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask];
|
||||
getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]);
|
||||
|
||||
await service["setTabState"](tab, userId, pendingTasks);
|
||||
|
||||
expect(setState).toHaveBeenCalledWith(
|
||||
"at-risk-cipher-badge-3",
|
||||
BadgeStatePriority.High,
|
||||
{
|
||||
icon: BadgeIcon.Berry,
|
||||
text: Unset,
|
||||
backgroundColor: Unset,
|
||||
},
|
||||
3,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
|
||||
import { BadgeService } from "../../platform/badge/badge.service";
|
||||
import { BadgeIcon } from "../../platform/badge/icon";
|
||||
import { BadgeStatePriority } from "../../platform/badge/priority";
|
||||
import { Unset } from "../../platform/badge/state";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`;
|
||||
|
||||
export class AtRiskCipherBadgeUpdaterService {
|
||||
private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>();
|
||||
private tabUpdated$ = new Subject<chrome.tabs.Tab>();
|
||||
private tabRemoved$ = new Subject<number>();
|
||||
private tabActivated$ = new Subject<chrome.tabs.Tab>();
|
||||
|
||||
private activeUserData$ = this.accountService.activeAccount$.pipe(
|
||||
filterOutNullish(),
|
||||
switchMap((user) =>
|
||||
combineLatest([
|
||||
of(user.id),
|
||||
this.taskService
|
||||
.pendingTasks$(user.id)
|
||||
.pipe(
|
||||
map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)),
|
||||
),
|
||||
this.cipherService.cipherViews$(user.id).pipe(filterOutNullish()),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private badgeService: BadgeService,
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
private taskService: TaskService,
|
||||
) {
|
||||
combineLatest({
|
||||
replaced: this.tabReplaced$,
|
||||
activeUserData: this.activeUserData$,
|
||||
})
|
||||
.pipe(
|
||||
mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => {
|
||||
await this.clearTabState(replaced.removedTabId);
|
||||
await this.setTabState(replaced.addedTab, userId, pendingTasks);
|
||||
}),
|
||||
)
|
||||
.subscribe(() => {});
|
||||
|
||||
combineLatest({
|
||||
tab: this.tabActivated$,
|
||||
activeUserData: this.activeUserData$,
|
||||
})
|
||||
.pipe(
|
||||
mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => {
|
||||
await this.setTabState(tab, userId, pendingTasks);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
combineLatest({
|
||||
tab: this.tabUpdated$,
|
||||
activeUserData: this.activeUserData$,
|
||||
})
|
||||
.pipe(
|
||||
mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => {
|
||||
await this.setTabState(tab, userId, pendingTasks);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.tabRemoved$
|
||||
.pipe(
|
||||
mergeMap(async (tabId) => {
|
||||
await this.clearTabState(tabId);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
init() {
|
||||
BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => {
|
||||
const newTab = await BrowserApi.getTab(addedTabId);
|
||||
if (!newTab) {
|
||||
this.logService.warning(
|
||||
`Tab replaced event received but new tab not found (id: ${addedTabId})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tabReplaced$.next({
|
||||
removedTabId,
|
||||
addedTab: newTab,
|
||||
});
|
||||
});
|
||||
|
||||
BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => {
|
||||
if (changeInfo.url) {
|
||||
this.tabUpdated$.next(tab);
|
||||
}
|
||||
});
|
||||
|
||||
BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => {
|
||||
const tab = await BrowserApi.getTab(activeInfo.tabId);
|
||||
if (!tab) {
|
||||
this.logService.warning(
|
||||
`Tab activated event received but tab not found (id: ${activeInfo.tabId})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tabActivated$.next(tab);
|
||||
});
|
||||
|
||||
BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId));
|
||||
}
|
||||
|
||||
/** Sets the pending task state for the tab */
|
||||
private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) {
|
||||
if (!tab.id) {
|
||||
this.logService.warning("Tab event received but tab id is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
const ciphers = tab.url
|
||||
? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true)
|
||||
: [];
|
||||
|
||||
const hasPendingTasksForTab = pendingTasks.some((task) =>
|
||||
ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted),
|
||||
);
|
||||
|
||||
if (!hasPendingTasksForTab) {
|
||||
await this.clearTabState(tab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.badgeService.setState(
|
||||
StateName(tab.id),
|
||||
BadgeStatePriority.High,
|
||||
{
|
||||
icon: BadgeIcon.Berry,
|
||||
// Unset text and background color to use default badge appearance
|
||||
text: Unset,
|
||||
backgroundColor: Unset,
|
||||
},
|
||||
tab.id,
|
||||
);
|
||||
}
|
||||
|
||||
/** Clears the pending task state from a tab */
|
||||
private async clearTabState(tabId: number) {
|
||||
await this.badgeService.clearState(StateName(tabId));
|
||||
}
|
||||
}
|
||||
@@ -65,12 +65,16 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
userId: UserId,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch?: UriMatchStrategySetting,
|
||||
/** When true, will override the match strategy for the cipher if it is Never. */
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): Promise<CipherView[]>;
|
||||
abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
|
||||
ciphers: C[],
|
||||
url: string,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch?: UriMatchStrategySetting,
|
||||
/** When true, will override the match strategy for the cipher if it is Never. */
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): Promise<C[]>;
|
||||
abstract getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]>;
|
||||
/**
|
||||
|
||||
@@ -111,6 +111,33 @@ describe("LoginUriView", () => {
|
||||
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("overrides Never match strategy with Domain when parameter is set", () => {
|
||||
const loginUri = new LoginUriView();
|
||||
loginUri.uri = "https://example.org";
|
||||
loginUri.match = UriMatchStrategy.Never;
|
||||
|
||||
expect(loginUri.matchesUri("https://example.org", new Set(), undefined, true)).toBe(true);
|
||||
expect(loginUri.matchesUri("https://example.org", new Set(), undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("overrides Never match strategy when passed in as default strategy", () => {
|
||||
const loginUriNoMatch = new LoginUriView();
|
||||
loginUriNoMatch.uri = "https://example.org";
|
||||
|
||||
expect(
|
||||
loginUriNoMatch.matchesUri(
|
||||
"https://example.org",
|
||||
new Set(),
|
||||
UriMatchStrategy.Never,
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
loginUriNoMatch.matchesUri("https://example.org", new Set(), UriMatchStrategy.Never),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("using host matching", () => {
|
||||
|
||||
@@ -142,6 +142,8 @@ export class LoginUriView implements View {
|
||||
targetUri: string,
|
||||
equivalentDomains: Set<string>,
|
||||
defaultUriMatch: UriMatchStrategySetting = null,
|
||||
/** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): boolean {
|
||||
if (!this.uri || !targetUri) {
|
||||
return false;
|
||||
@@ -150,6 +152,12 @@ export class LoginUriView implements View {
|
||||
let matchType = this.match ?? defaultUriMatch;
|
||||
matchType ??= UriMatchStrategy.Domain;
|
||||
|
||||
// Override the match strategy with `Domain` when it is `Never` and `overrideNeverMatchStrategy` is true.
|
||||
// This is useful in scenarios when the cipher should be matched to rely other information other than autofill.
|
||||
if (overrideNeverMatchStrategy && matchType === UriMatchStrategy.Never) {
|
||||
matchType = UriMatchStrategy.Domain;
|
||||
}
|
||||
|
||||
const targetDomain = Utils.getDomain(targetUri);
|
||||
const matchDomains = equivalentDomains.add(targetDomain);
|
||||
|
||||
|
||||
@@ -82,12 +82,16 @@ export class LoginView extends ItemView {
|
||||
targetUri: string,
|
||||
equivalentDomains: Set<string>,
|
||||
defaultUriMatch: UriMatchStrategySetting = null,
|
||||
/** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): boolean {
|
||||
if (this.uris == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.uris.some((uri) => uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch));
|
||||
return this.uris.some((uri) =>
|
||||
uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy),
|
||||
);
|
||||
}
|
||||
|
||||
static fromJSON(obj: Partial<DeepJsonify<LoginView>>): LoginView {
|
||||
|
||||
@@ -601,6 +601,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
userId: UserId,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch: UriMatchStrategySetting = null,
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): Promise<CipherView[]> {
|
||||
return await firstValueFrom(
|
||||
this.cipherViews$(userId).pipe(
|
||||
@@ -612,6 +613,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
url,
|
||||
includeOtherTypes,
|
||||
defaultMatch,
|
||||
overrideNeverMatchStrategy,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -623,6 +625,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
url: string,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch: UriMatchStrategySetting = null,
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): Promise<C[]> {
|
||||
if (url == null && includeOtherTypes == null) {
|
||||
return [];
|
||||
@@ -647,7 +650,13 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
if (cipherIsLogin) {
|
||||
return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch);
|
||||
return CipherViewLikeUtils.matchesUri(
|
||||
cipher,
|
||||
url,
|
||||
equivalentDomains,
|
||||
defaultMatch,
|
||||
overrideNeverMatchStrategy,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -174,13 +174,19 @@ export class CipherViewLikeUtils {
|
||||
targetUri: string,
|
||||
equivalentDomains: Set<string>,
|
||||
defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain,
|
||||
overrideNeverMatchStrategy?: true,
|
||||
): boolean => {
|
||||
if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.isCipherListView(cipher)) {
|
||||
return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch);
|
||||
return cipher.login.matchesUri(
|
||||
targetUri,
|
||||
equivalentDomains,
|
||||
defaultUriMatch,
|
||||
overrideNeverMatchStrategy,
|
||||
);
|
||||
}
|
||||
|
||||
const login = this.getLogin(cipher);
|
||||
@@ -198,7 +204,7 @@ export class CipherViewLikeUtils {
|
||||
});
|
||||
|
||||
return loginUriViews.some((uriView) =>
|
||||
uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch),
|
||||
uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user