1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

PM-19741 Adds a notification at login for at-risk passwords. (#14555)

Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>
This commit is contained in:
Miles Blackwood
2025-06-11 10:20:53 -04:00
committed by GitHub
parent 90b07728d7
commit d8c544fd65
22 changed files with 769 additions and 80 deletions

View File

@@ -2515,6 +2515,10 @@
"change": {
"message": "Change"
},
"changePassword": {
"message": "Change password",
"description": "Change password button for browser at risk notification on login."
},
"changeButtonTitle": {
"message": "Change password - $ITEMNAME$",
"placeholders": {
@@ -2524,6 +2528,9 @@
}
}
},
"atRiskPassword": {
"message": "At-risk password"
},
"atRiskPasswords": {
"message": "At-risk passwords"
},
@@ -2558,6 +2565,26 @@
}
}
},
"atRiskChangePrompt": {
"message": "Your password for this site is at-risk. $ORGANIZATION$ has requested that you change it.",
"placeholders": {
"organization": {
"content": "$1",
"example": "Acme Corp"
}
},
"description": "Notification body when a login triggers an at-risk password change request and the change password domain is known."
},
"atRiskNavigatePrompt": {
"message": "$ORGANIZATION$ wants you to change this password because it is at-risk. Navigate to your account settings to change the password.",
"placeholders": {
"organization": {
"content": "$1",
"example": "Acme Corp"
}
},
"description": "Notification body when a login triggers an at-risk password change request and no change password domain is provided."
},
"reviewAndChangeAtRiskPassword": {
"message": "Review and change one at-risk password"
},

View File

@@ -1,6 +1,9 @@
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { SecurityTask } from "@bitwarden/common/vault/tasks";
import { CollectionView } from "../../content/components/common-types";
import { NotificationQueueMessageTypes } from "../../enums/notification-queue-message-type.enum";
@@ -32,10 +35,17 @@ interface AddUnlockVaultQueueMessage extends NotificationQueueMessage {
type: "unlock";
}
interface AtRiskPasswordQueueMessage extends NotificationQueueMessage {
type: "at-risk-password";
organizationName: string;
passwordChangeUri?: string;
}
type NotificationQueueMessageItem =
| AddLoginQueueMessage
| AddChangePasswordQueueMessage
| AddUnlockVaultQueueMessage;
| AddUnlockVaultQueueMessage
| AtRiskPasswordQueueMessage;
type LockedVaultPendingNotificationsData = {
commandToRetry: {
@@ -50,6 +60,13 @@ type LockedVaultPendingNotificationsData = {
target: string;
};
type AtRiskPasswordNotificationsData = {
activeUserId: UserId;
cipher: CipherView;
securityTask: SecurityTask;
uri: string;
};
type AdjustNotificationBarMessageData = {
height: number;
};
@@ -76,7 +93,8 @@ type NotificationBackgroundExtensionMessage = {
data?: Partial<LockedVaultPendingNotificationsData> &
Partial<AdjustNotificationBarMessageData> &
Partial<ChangePasswordMessageData> &
Partial<UnlockVaultMessageData>;
Partial<UnlockVaultMessageData> &
Partial<AtRiskPasswordNotificationsData>;
login?: AddLoginMessageData;
folder?: string;
edit?: boolean;
@@ -101,10 +119,20 @@ type NotificationBackgroundExtensionMessageHandlers = {
sender,
}: BackgroundOnMessageHandlerParams) => Promise<CollectionView[]>;
bgCloseNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgOpenAtRisksPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgOpenAtRiskPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgAdjustNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgAddLogin: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgChangedPassword: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgTriggerAddLoginNotification: ({
message,
sender,
}: BackgroundOnMessageHandlerParams) => Promise<boolean>;
bgTriggerChangedPasswordNotification: ({
message,
sender,
}: BackgroundOnMessageHandlerParams) => Promise<boolean>;
bgTriggerAtRiskPasswordNotification: ({
message,
sender,
}: BackgroundOnMessageHandlerParams) => Promise<boolean>;
bgRemoveTabFromNotificationQueue: ({ sender }: BackgroundSenderParam) => void;
bgSaveCipher: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
bgOpenAddEditVaultItemPopout: ({

View File

@@ -275,7 +275,7 @@ describe("NotificationBackground", () => {
});
});
describe("bgAddLogin message handler", () => {
describe("bgTriggerAddLoginNotification message handler", () => {
let tab: chrome.tabs.Tab;
let sender: chrome.runtime.MessageSender;
let getEnableAddedLoginPromptSpy: jest.SpyInstance;
@@ -305,7 +305,7 @@ describe("NotificationBackground", () => {
it("skips attempting to add the login if the user is logged out", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgAddLogin",
command: "bgTriggerAddLoginNotification",
login: { username: "test", password: "password", url: "https://example.com" },
};
activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut);
@@ -319,7 +319,7 @@ describe("NotificationBackground", () => {
it("skips attempting to add the login if the login data does not contain a valid url", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgAddLogin",
command: "bgTriggerAddLoginNotification",
login: { username: "test", password: "password", url: "" },
};
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
@@ -333,7 +333,7 @@ describe("NotificationBackground", () => {
it("skips attempting to add the login if the user with a locked vault has disabled the login notification", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgAddLogin",
command: "bgTriggerAddLoginNotification",
login: { username: "test", password: "password", url: "https://example.com" },
};
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
@@ -350,7 +350,7 @@ describe("NotificationBackground", () => {
it("skips attempting to add the login if the user with an unlocked vault has disabled the login notification", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgAddLogin",
command: "bgTriggerAddLoginNotification",
login: { username: "test", password: "password", url: "https://example.com" },
};
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
@@ -368,7 +368,7 @@ describe("NotificationBackground", () => {
it("skips attempting to change the password for an existing login if the user has disabled changing the password notification", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgAddLogin",
command: "bgTriggerAddLoginNotification",
login: { username: "test", password: "password", url: "https://example.com" },
};
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
@@ -390,7 +390,7 @@ describe("NotificationBackground", () => {
it("skips attempting to change the password for an existing login if the password has not changed", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgAddLogin",
command: "bgTriggerAddLoginNotification",
login: { username: "test", password: "password", url: "https://example.com" },
};
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
@@ -410,7 +410,10 @@ describe("NotificationBackground", () => {
it("adds the login to the queue if the user has a locked account", async () => {
const login = { username: "test", password: "password", url: "https://example.com" };
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
const message: NotificationBackgroundExtensionMessage = {
command: "bgTriggerAddLoginNotification",
login,
};
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
@@ -426,7 +429,10 @@ describe("NotificationBackground", () => {
password: "password",
url: "https://example.com",
} as any;
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
const message: NotificationBackgroundExtensionMessage = {
command: "bgTriggerAddLoginNotification",
login,
};
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
@@ -441,7 +447,10 @@ describe("NotificationBackground", () => {
it("adds a change password message to the queue if the user has changed an existing cipher's password", async () => {
const login = { username: "tEsT", password: "password", url: "https://example.com" };
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
const message: NotificationBackgroundExtensionMessage = {
command: "bgTriggerAddLoginNotification",
login,
};
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
getEnableAddedLoginPromptSpy.mockResolvedValueOnce(true);
getEnableChangedPasswordPromptSpy.mockResolvedValueOnce(true);
@@ -464,7 +473,7 @@ describe("NotificationBackground", () => {
});
});
describe("bgChangedPassword message handler", () => {
describe("bgTriggerChangedPasswordNotification message handler", () => {
let tab: chrome.tabs.Tab;
let sender: chrome.runtime.MessageSender;
let pushChangePasswordToQueueSpy: jest.SpyInstance;
@@ -482,7 +491,7 @@ describe("NotificationBackground", () => {
it("skips attempting to add the change password message to the queue if the passed url is not valid", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgChangedPassword",
command: "bgTriggerChangedPasswordNotification",
data: { newPassword: "newPassword", currentPassword: "currentPassword", url: "" },
};
@@ -494,7 +503,7 @@ describe("NotificationBackground", () => {
it("adds a change password message to the queue if the user does not have an unlocked account", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgChangedPassword",
command: "bgTriggerChangedPasswordNotification",
data: {
newPassword: "newPassword",
currentPassword: "currentPassword",
@@ -517,7 +526,7 @@ describe("NotificationBackground", () => {
it("skips adding a change password message to the queue if the multiple ciphers exist for the passed URL and the current password is not found within the list of ciphers", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgChangedPassword",
command: "bgTriggerChangedPasswordNotification",
data: {
newPassword: "newPassword",
currentPassword: "currentPassword",
@@ -538,7 +547,7 @@ describe("NotificationBackground", () => {
it("skips adding a change password message if more than one existing cipher is found with a matching password ", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgChangedPassword",
command: "bgTriggerChangedPasswordNotification",
data: {
newPassword: "newPassword",
currentPassword: "currentPassword",
@@ -560,7 +569,7 @@ describe("NotificationBackground", () => {
it("adds a change password message to the queue if a single cipher matches the passed current password", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgChangedPassword",
command: "bgTriggerChangedPasswordNotification",
data: {
newPassword: "newPassword",
currentPassword: "currentPassword",
@@ -588,7 +597,7 @@ describe("NotificationBackground", () => {
it("skips adding a change password message if no current password is passed in the message and more than one cipher is found for a url", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgChangedPassword",
command: "bgTriggerChangedPasswordNotification",
data: {
newPassword: "newPassword",
url: "https://example.com",
@@ -609,7 +618,7 @@ describe("NotificationBackground", () => {
it("adds a change password message to the queue if no current password is passed with the message, but a single cipher is matched for the uri", async () => {
const message: NotificationBackgroundExtensionMessage = {
command: "bgChangedPassword",
command: "bgTriggerChangedPasswordNotification",
data: {
newPassword: "newPassword",
url: "https://example.com",

View File

@@ -3,7 +3,10 @@
import { firstValueFrom, switchMap, map, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -55,6 +58,7 @@ import {
import { CollectionView } from "../content/components/common-types";
import { NotificationQueueMessageType } from "../enums/notification-queue-message-type.enum";
import { AutofillService } from "../services/abstractions/autofill.service";
import { TemporaryNotificationChangeLoginService } from "../services/notification-change-login-password.service";
import {
AddChangePasswordQueueMessage,
@@ -81,14 +85,18 @@ export default class NotificationBackground {
ExtensionCommand.AutofillIdentity,
]);
private readonly extensionMessageHandlers: NotificationBackgroundExtensionMessageHandlers = {
bgAddLogin: ({ message, sender }) => this.addLogin(message, sender),
bgAdjustNotificationBar: ({ message, sender }) =>
this.handleAdjustNotificationBarMessage(message, sender),
bgChangedPassword: ({ message, sender }) => this.changedPassword(message, sender),
bgTriggerAddLoginNotification: ({ message, sender }) =>
this.triggerAddLoginNotification(message, sender),
bgTriggerChangedPasswordNotification: ({ message, sender }) =>
this.triggerChangedPasswordNotification(message, sender),
bgTriggerAtRiskPasswordNotification: ({ message, sender }) =>
this.triggerAtRiskPasswordNotification(message, sender),
bgCloseNotificationBar: ({ message, sender }) =>
this.handleCloseNotificationBarMessage(message, sender),
bgOpenAtRisksPasswords: ({ message, sender }) =>
this.handleOpenAtRisksPasswordsMessage(message, sender),
bgOpenAtRiskPasswords: ({ message, sender }) =>
this.handleOpenAtRiskPasswordsMessage(message, sender),
bgGetActiveUserServerConfig: () => this.getActiveUserServerConfig(),
bgGetDecryptedCiphers: () => this.getNotificationCipherData(),
bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(),
@@ -341,12 +349,17 @@ export default class NotificationBackground {
tab: chrome.tabs.Tab,
notificationQueueMessage: NotificationQueueMessageItem,
) {
const notificationType = notificationQueueMessage.type;
const {
type: notificationType,
wasVaultLocked: isVaultLocked,
launchTimestamp,
...params
} = notificationQueueMessage;
const typeData: NotificationTypeData = {
isVaultLocked: notificationQueueMessage.wasVaultLocked,
isVaultLocked,
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
launchTimestamp: notificationQueueMessage.launchTimestamp,
launchTimestamp,
};
switch (notificationType) {
@@ -358,6 +371,7 @@ export default class NotificationBackground {
await BrowserApi.tabSendMessageData(tab, "openNotificationBar", {
type: notificationType,
typeData,
params,
});
}
@@ -375,6 +389,48 @@ export default class NotificationBackground {
}
}
/**
* Sends a message to trigger the at risk password notification
*
* @param message - The extension message
* @param sender - The contextual sender of the message
*/
async triggerAtRiskPasswordNotification(
message: NotificationBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
): Promise<boolean> {
const { activeUserId, securityTask, cipher } = message.data;
const domain = Utils.getDomain(sender.tab.url);
const passwordChangeUri =
await new TemporaryNotificationChangeLoginService().getChangePasswordUrl(cipher);
const authStatus = await this.getAuthStatus();
const wasVaultLocked = authStatus === AuthenticationStatus.Locked;
const organization = await firstValueFrom(
this.organizationService
.organizations$(activeUserId)
.pipe(getOrganizationById(securityTask.organizationId)),
);
this.removeTabFromNotificationQueue(sender.tab);
const launchTimestamp = new Date().getTime();
const queueMessage: NotificationQueueMessageItem = {
domain,
wasVaultLocked,
type: NotificationQueueMessageType.AtRiskPassword,
passwordChangeUri,
organizationName: organization.name,
tab: sender.tab,
launchTimestamp,
expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS),
};
this.notificationQueue.push(queueMessage);
await this.checkNotificationQueue(sender.tab);
return true;
}
/**
* Adds a login message to the notification queue, prompting the user to save
* the login if it does not already exist in the vault. If the cipher exists
@@ -383,20 +439,20 @@ export default class NotificationBackground {
* @param message - The message to add to the queue
* @param sender - The contextual sender of the message
*/
async addLogin(
async triggerAddLoginNotification(
message: NotificationBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
): Promise<boolean> {
const authStatus = await this.getAuthStatus();
if (authStatus === AuthenticationStatus.LoggedOut) {
return;
return false;
}
const loginInfo = message.login;
const normalizedUsername = loginInfo.username ? loginInfo.username.toLowerCase() : "";
const loginDomain = Utils.getDomain(loginInfo.url);
if (loginDomain == null) {
return;
return false;
}
const addLoginIsEnabled = await this.getEnableAddedLoginPrompt();
@@ -406,14 +462,14 @@ export default class NotificationBackground {
await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab, true);
}
return;
return false;
}
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (activeUserId == null) {
return;
return false;
}
const ciphers = await this.cipherService.getAllDecryptedForUrl(loginInfo.url, activeUserId);
@@ -422,7 +478,7 @@ export default class NotificationBackground {
);
if (addLoginIsEnabled && usernameMatches.length === 0) {
await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab);
return;
return true;
}
const changePasswordIsEnabled = await this.getEnableChangedPasswordPrompt();
@@ -438,7 +494,9 @@ export default class NotificationBackground {
loginInfo.password,
sender.tab,
);
return true;
}
return false;
}
private async pushAddLoginToQueue(
@@ -472,14 +530,14 @@ export default class NotificationBackground {
* @param message - The message to add to the queue
* @param sender - The contextual sender of the message
*/
async changedPassword(
async triggerChangedPasswordNotification(
message: NotificationBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
const changeData = message.data as ChangePasswordMessageData;
const loginDomain = Utils.getDomain(changeData.url);
if (loginDomain == null) {
return;
return false;
}
if ((await this.getAuthStatus()) < AuthenticationStatus.Unlocked) {
@@ -490,7 +548,7 @@ export default class NotificationBackground {
sender.tab,
true,
);
return;
return true;
}
let id: string = null;
@@ -498,7 +556,7 @@ export default class NotificationBackground {
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (activeUserId == null) {
return;
return false;
}
const ciphers = await this.cipherService.getAllDecryptedForUrl(changeData.url, activeUserId);
@@ -514,7 +572,9 @@ export default class NotificationBackground {
}
if (id != null) {
await this.pushChangePasswordToQueue(id, loginDomain, changeData.newPassword, sender.tab);
return true;
}
return false;
}
/**
@@ -900,7 +960,7 @@ export default class NotificationBackground {
return null;
}
private async getSecurityTasks(userId: UserId) {
async getSecurityTasks(userId: UserId) {
let tasks: SecurityTask[] = [];
if (userId) {
@@ -1074,7 +1134,7 @@ export default class NotificationBackground {
* @param message - The extension message
* @param sender - The contextual sender of the message
*/
private async handleOpenAtRisksPasswordsMessage(
private async handleOpenAtRiskPasswordsMessage(
message: NotificationBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {

View File

@@ -1,9 +1,12 @@
import { mock, MockProxy } from "jest-mock-extended";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EnvironmentServerConfigData } from "@bitwarden/common/platform/models/data/server-config.data";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { BrowserApi } from "../../platform/browser/browser-api";
import AutofillField from "../models/autofill-field";
@@ -24,6 +27,9 @@ import { OverlayNotificationsBackground } from "./overlay-notifications.backgrou
describe("OverlayNotificationsBackground", () => {
let logService: MockProxy<LogService>;
let notificationBackground: NotificationBackground;
let taskService: TaskService;
let accountService: AccountService;
let cipherService: CipherService;
let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
let getEnableAddedLoginPromptSpy: jest.SpyInstance;
let overlayNotificationsBackground: OverlayNotificationsBackground;
@@ -32,6 +38,9 @@ describe("OverlayNotificationsBackground", () => {
jest.useFakeTimers();
logService = mock<LogService>();
notificationBackground = mock<NotificationBackground>();
taskService = mock<TaskService>();
accountService = mock<AccountService>();
cipherService = mock<CipherService>();
getEnableChangedPasswordPromptSpy = jest
.spyOn(notificationBackground, "getEnableChangedPasswordPrompt")
.mockResolvedValue(true);
@@ -41,6 +50,9 @@ describe("OverlayNotificationsBackground", () => {
overlayNotificationsBackground = new OverlayNotificationsBackground(
logService,
notificationBackground,
taskService,
accountService,
cipherService,
);
await overlayNotificationsBackground.init();
});
@@ -329,8 +341,11 @@ describe("OverlayNotificationsBackground", () => {
tab: { id: 1 },
url: "https://example.com",
});
notificationChangedPasswordSpy = jest.spyOn(notificationBackground, "changedPassword");
notificationAddLoginSpy = jest.spyOn(notificationBackground, "addLogin");
notificationChangedPasswordSpy = jest.spyOn(
notificationBackground,
"triggerChangedPasswordNotification",
);
notificationAddLoginSpy = jest.spyOn(notificationBackground, "triggerAddLoginNotification");
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },

View File

@@ -1,9 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Subject, switchMap, timer } from "rxjs";
import { firstValueFrom, Subject, switchMap, timer } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/common/vault/tasks";
import { BrowserApi } from "../../platform/browser/browser-api";
import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils";
@@ -19,6 +25,12 @@ import {
} from "./abstractions/overlay-notifications.background";
import NotificationBackground from "./notification.background";
type LoginSecurityTaskInfo = {
securityTask: SecurityTask;
cipher: CipherView;
uri: ModifyLoginCipherFormData["uri"];
};
export class OverlayNotificationsBackground implements OverlayNotificationsBackgroundInterface {
private websiteOriginsWithFields: WebsiteOriginsWithFields = new Map();
private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set();
@@ -35,6 +47,9 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
constructor(
private logService: LogService,
private notificationBackground: NotificationBackground,
private taskService: TaskService,
private accountService: AccountService,
private cipherService: CipherService,
) {}
/**
@@ -259,8 +274,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
const modifyLoginData = this.modifyLoginCipherFormData.get(tabId);
return (
!modifyLoginData ||
!this.shouldTriggerAddLoginNotification(modifyLoginData) ||
!this.shouldTriggerChangePasswordNotification(modifyLoginData)
!this.shouldAttemptAddLoginNotification(modifyLoginData) ||
!this.shouldAttemptChangedPasswordNotification(modifyLoginData)
);
};
@@ -404,10 +419,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
modifyLoginData: ModifyLoginCipherFormData,
tab: chrome.tabs.Tab,
) => {
if (this.shouldTriggerChangePasswordNotification(modifyLoginData)) {
let result: string;
if (this.shouldAttemptChangedPasswordNotification(modifyLoginData)) {
// These notifications are temporarily setup as "messages" to the notification background.
// This will be structured differently in a future refactor.
await this.notificationBackground.changedPassword(
const success = await this.notificationBackground.triggerChangedPasswordNotification(
{
command: "bgChangedPassword",
data: {
@@ -418,14 +434,15 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
},
{ tab },
);
this.clearCompletedWebRequest(requestId, tab);
return;
if (!success) {
result = "Unqualified changedPassword notification attempt.";
}
}
if (this.shouldTriggerAddLoginNotification(modifyLoginData)) {
await this.notificationBackground.addLogin(
if (this.shouldAttemptAddLoginNotification(modifyLoginData)) {
const success = await this.notificationBackground.triggerAddLoginNotification(
{
command: "bgAddLogin",
command: "bgTriggerAddLoginNotification",
login: {
url: modifyLoginData.uri,
username: modifyLoginData.username,
@@ -434,8 +451,44 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
},
{ tab },
);
this.clearCompletedWebRequest(requestId, tab);
if (!success) {
result = "Unqualified addLogin notification attempt.";
}
}
const shouldGetTasks =
(await this.notificationBackground.getNotificationFlag()) && !modifyLoginData.newPassword;
if (shouldGetTasks) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (activeUserId) {
const loginSecurityTaskInfo = await this.getSecurityTaskAndCipherForLoginData(
modifyLoginData,
activeUserId,
);
if (loginSecurityTaskInfo) {
await this.notificationBackground.triggerAtRiskPasswordNotification(
{
command: "bgTriggerAtRiskPasswordNotification",
data: {
activeUserId,
cipher: loginSecurityTaskInfo.cipher,
securityTask: loginSecurityTaskInfo.securityTask,
},
},
{ tab },
);
} else {
result = "Unqualified atRiskPassword notification attempt.";
}
}
}
this.clearCompletedWebRequest(requestId, tab);
return result;
};
/**
@@ -443,7 +496,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
*
* @param modifyLoginData - The modified login form data
*/
private shouldTriggerChangePasswordNotification = (
private shouldAttemptChangedPasswordNotification = (
modifyLoginData: ModifyLoginCipherFormData,
) => {
return modifyLoginData?.newPassword && !modifyLoginData.username;
@@ -454,10 +507,66 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
*
* @param modifyLoginData - The modified login form data
*/
private shouldTriggerAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => {
private shouldAttemptAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => {
return modifyLoginData?.username && (modifyLoginData.password || modifyLoginData.newPassword);
};
/**
* If there is a security task for this cipher at login, return the task, cipher view, and uri.
*
* @param modifyLoginData - The modified login form data
* @param activeUserId - The currently logged in user ID
*/
private async getSecurityTaskAndCipherForLoginData(
modifyLoginData: ModifyLoginCipherFormData,
activeUserId: UserId,
): Promise<LoginSecurityTaskInfo | null> {
const tasks: SecurityTask[] = await this.notificationBackground.getSecurityTasks(activeUserId);
if (!tasks?.length) {
return null;
}
const urlCiphers: CipherView[] = await this.cipherService.getAllDecryptedForUrl(
modifyLoginData.uri,
activeUserId,
);
if (!urlCiphers?.length) {
return null;
}
const securityTaskForLogin = urlCiphers.reduce(
(taskInfo: LoginSecurityTaskInfo | null, cipher: CipherView) => {
if (
// exit early if info was found already
taskInfo ||
// exit early if the cipher was deleted
cipher.deletedDate ||
// exit early if the entered login info doesn't match an existing cipher
modifyLoginData.username !== cipher.login.username ||
modifyLoginData.password !== cipher.login.password
) {
return taskInfo;
}
// Find the first security task for the cipherId belonging to the entered login
const cipherSecurityTask = tasks.find(
({ cipherId, status }) =>
cipher.id === cipherId && // match security task cipher id to url cipher id
status === SecurityTaskStatus.Pending, // security task has not been completed
);
if (cipherSecurityTask) {
return { securityTask: cipherSecurityTask, cipher, uri: modifyLoginData.uri };
}
return taskInfo;
},
null,
);
return securityTaskForLogin;
}
/**
* Clears the completed web request and removes the modified login form data for the tab.
*

View File

@@ -10,6 +10,7 @@ export type ActionButtonProps = {
disabled?: boolean;
theme: Theme;
handleClick: (e: Event) => void;
fullWidth?: boolean;
};
export function ActionButton({
@@ -17,6 +18,7 @@ export function ActionButton({
disabled = false,
theme,
handleClick,
fullWidth = true,
}: ActionButtonProps) {
const handleButtonClick = (event: Event) => {
if (!disabled) {
@@ -26,7 +28,7 @@ export function ActionButton({
return html`
<button
class=${actionButtonStyles({ disabled, theme })}
class=${actionButtonStyles({ disabled, theme, fullWidth })}
title=${buttonText}
type="button"
@click=${handleButtonClick}
@@ -36,14 +38,22 @@ export function ActionButton({
`;
}
const actionButtonStyles = ({ disabled, theme }: { disabled: boolean; theme: Theme }) => css`
const actionButtonStyles = ({
disabled,
theme,
fullWidth,
}: {
disabled: boolean;
theme: Theme;
fullWidth: boolean;
}) => css`
${typography.body2}
user-select: none;
border: 1px solid transparent;
border-radius: ${border.radius.full};
padding: ${spacing["1"]} ${spacing["3"]};
width: 100%;
width: ${fullWidth ? "100%" : "auto"};
overflow: hidden;
text-align: center;
text-overflow: ellipsis;

View File

@@ -0,0 +1,29 @@
import { css } from "@emotion/css";
import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { spacing, themes } from "../../constants/styles";
import { ExternalLink } from "../../icons";
export function AdditionalTasksButtonContent({
buttonText,
theme,
}: {
buttonText: string;
theme: Theme;
}) {
return html`
<div class=${additionalTasksButtonContentStyles({ theme })}>
<span>${buttonText}</span>
${ExternalLink({ theme, color: themes[theme].text.contrast })}
</div>
`;
}
export const additionalTasksButtonContentStyles = ({ theme }: { theme: Theme }) => css`
gap: ${spacing[2]};
display: flex;
align-items: center;
white-space: nowrap;
`;

View File

@@ -103,6 +103,12 @@ export const mockTasks = [
export const mockI18n = {
appName: "Bitwarden",
atRiskPassword: "At-risk password",
atRiskNavigatePrompt:
"$ORGANIZATION$ wants you to change this password because it is at-risk. Navigate to your account settings to change the password.",
atRiskChangePrompt:
"Your password for this site is at-risk. $ORGANIZATION$ has requested that you change it.",
changePassword: "Change password",
close: "Close",
collection: "Collection",
folder: "Folder",

View File

@@ -0,0 +1,44 @@
import { Meta, StoryObj } from "@storybook/web-components";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import {
AtRiskNotification,
AtRiskNotificationProps,
} from "../../../notification/at-risk-password/container";
import { mockI18n, mockBrowserI18nGetMessage } from "../../mock-data";
export default {
title: "Components/Notifications/At-Risk Notification",
argTypes: {
theme: { control: "select", options: [...Object.values(ThemeTypes)] },
},
args: {
theme: ThemeTypes.Light,
handleCloseNotification: () => alert("Close notification action triggered"),
params: {
passwordChangeUri: "https://webtests.dev/.well-known/change-password", // Remove to see "navigate" version of notification
organizationName: "Acme Co.",
},
i18n: mockI18n,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=485-20160&m=dev",
},
},
} as Meta<AtRiskNotificationProps>;
const Template = (args: AtRiskNotificationProps) => AtRiskNotification({ ...args });
export const Default: StoryObj<AtRiskNotificationProps> = {
render: Template,
};
window.chrome = {
...window.chrome,
i18n: {
getMessage: mockBrowserI18nGetMessage,
},
} as typeof chrome;

View File

@@ -0,0 +1,49 @@
import createEmotion from "@emotion/css/create-instance";
import { html, nothing } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { spacing, themes } from "../../constants/styles";
import { Warning } from "../../illustrations";
import { AtRiskNotificationMessage } from "./message";
export const componentClassPrefix = "at-risk-notification-body";
const { css } = createEmotion({
key: componentClassPrefix,
});
export type AtRiskNotificationBodyProps = {
riskMessage: string;
theme: Theme;
};
export function AtRiskNotificationBody({ riskMessage, theme }: AtRiskNotificationBodyProps) {
return html`
<div class=${atRiskNotificationBodyStyles({ theme })}>
<div class=${iconContainerStyles}>${Warning()}</div>
${riskMessage
? AtRiskNotificationMessage({
message: riskMessage,
theme,
})
: nothing}
</div>
`;
}
const iconContainerStyles = css`
> svg {
width: 50px;
height: auto;
}
`;
const atRiskNotificationBodyStyles = ({ theme }: { theme: Theme }) => css`
gap: ${spacing[4]};
display: flex;
align-items: center;
justify-content: flex-start;
background-color: ${themes[theme].background.alt};
padding: 12px;
`;

View File

@@ -0,0 +1,72 @@
import { css } from "@emotion/css";
import { html, nothing } from "lit";
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
import { NotificationBarIframeInitData } from "../../../../notification/abstractions/notification-bar";
import { I18n } from "../../common-types";
import { themes, spacing } from "../../constants/styles";
import {
NotificationHeader,
componentClassPrefix as notificationHeaderClassPrefix,
} from "../header";
import { AtRiskNotificationBody } from "./body";
import { AtRiskNotificationFooter } from "./footer";
export type AtRiskNotificationProps = NotificationBarIframeInitData & {
handleCloseNotification: (e: Event) => void;
} & {
i18n: I18n;
};
export function AtRiskNotification({
handleCloseNotification,
i18n,
theme = ThemeTypes.Light,
params,
}: AtRiskNotificationProps) {
const { passwordChangeUri, organizationName } = params;
const riskMessage = chrome.i18n.getMessage(
passwordChangeUri ? "atRiskChangePrompt" : "atRiskNavigatePrompt",
organizationName,
);
return html`
<div class=${atRiskNotificationContainerStyles(theme)}>
${NotificationHeader({
handleCloseNotification,
i18n,
message: i18n.atRiskPassword,
theme,
})}
${AtRiskNotificationBody({
theme,
riskMessage,
})}
${passwordChangeUri
? AtRiskNotificationFooter({
i18n,
theme,
passwordChangeUri: params?.passwordChangeUri,
})
: nothing}
</div>
`;
}
const atRiskNotificationContainerStyles = (theme: Theme) => css`
position: absolute;
right: 20px;
border: 1px solid ${themes[theme].secondary["300"]};
border-radius: ${spacing["4"]};
box-shadow: -2px 4px 6px 0px #0000001a;
background-color: ${themes[theme].background.alt};
width: 400px;
overflow: hidden;
[class*="${notificationHeaderClassPrefix}-"] {
border-radius: ${spacing["4"]} ${spacing["4"]} 0 0;
border-bottom: 0.5px solid ${themes[theme].secondary["300"]};
}
`;

View File

@@ -0,0 +1,42 @@
import { css } from "@emotion/css";
import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { ActionButton } from "../../buttons/action-button";
import { AdditionalTasksButtonContent } from "../../buttons/additional-tasks/button-content";
import { I18n } from "../../common-types";
import { spacing } from "../../constants/styles";
export type AtRiskNotificationFooterProps = {
i18n: I18n;
theme: Theme;
passwordChangeUri: string;
};
export function AtRiskNotificationFooter({
i18n,
theme,
passwordChangeUri,
}: AtRiskNotificationFooterProps) {
return html`<div class=${atRiskNotificationFooterStyles}>
${passwordChangeUri &&
ActionButton({
handleClick: () => {
open(passwordChangeUri, "_blank");
},
buttonText: AdditionalTasksButtonContent({ buttonText: i18n.changePassword, theme }),
theme,
fullWidth: false,
})}
</div>`;
}
const atRiskNotificationFooterStyles = css`
display: flex;
padding: ${spacing[2]} ${spacing[4]} ${spacing[4]} ${spacing[4]};
:last-child {
border-radius: 0 0 ${spacing["4"]} ${spacing["4"]};
}
`;

View File

@@ -0,0 +1,44 @@
import { css } from "@emotion/css";
import { html, nothing } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { themes } from "../../constants/styles";
export type AtRiskNotificationMessageProps = {
message?: string;
theme: Theme;
};
export function AtRiskNotificationMessage({ message, theme }: AtRiskNotificationMessageProps) {
return html`
<div>
${message
? html`
<span title=${message} class=${atRiskNotificationMessageStyles(theme)}>
${message}
</span>
`
: nothing}
</div>
`;
}
const baseTextStyles = css`
overflow-x: hidden;
text-align: left;
text-overflow: ellipsis;
line-height: 24px;
font-family: Roboto, sans-serif;
font-size: 16px;
`;
const atRiskNotificationMessageStyles = (theme: Theme) => css`
${baseTextStyles}
color: ${themes[theme].text.main};
font-weight: 400;
white-space: normal;
word-break: break-word;
display: inline;
`;

View File

@@ -2,6 +2,7 @@ const NotificationQueueMessageType = {
AddLogin: "add",
ChangePassword: "change",
UnlockVault: "unlock",
AtRiskPassword: "at-risk-password",
} as const;
type NotificationQueueMessageTypes =

View File

@@ -11,6 +11,7 @@ const NotificationTypes = {
Add: "add",
Change: "change",
Unlock: "unlock",
AtRiskPassword: "at-risk-password",
} as const;
type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes];
@@ -30,7 +31,8 @@ type NotificationBarIframeInitData = {
organizations?: OrgView[];
removeIndividualVault?: boolean;
theme?: Theme;
type?: string; // @TODO use `NotificationType`
type?: NotificationType;
params?: AtRiskPasswordNotificationParams | any;
};
type NotificationBarWindowMessage = {
@@ -50,7 +52,13 @@ type NotificationBarWindowMessageHandlers = {
saveCipherAttemptCompleted: ({ message }: { message: NotificationBarWindowMessage }) => void;
};
type AtRiskPasswordNotificationParams = {
passwordChangeUri?: string;
organizationName: string;
};
export {
AtRiskPasswordNotificationParams,
NotificationTaskInfo,
NotificationTypes,
NotificationType,

View File

@@ -7,6 +7,7 @@ import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view
import { AdjustNotificationBarMessageData } from "../background/abstractions/notification.background";
import { NotificationCipherData } from "../content/components/cipher/types";
import { CollectionView, I18n, OrgView } from "../content/components/common-types";
import { AtRiskNotification } from "../content/components/notification/at-risk-password/container";
import { NotificationConfirmationContainer } from "../content/components/notification/confirmation/container";
import { NotificationContainer } from "../content/components/notification/container";
import { selectedFolder as selectedFolderSignal } from "../content/components/signals/selected-folder";
@@ -56,21 +57,24 @@ function applyNotificationBarStyle() {
function getI18n() {
return {
appName: chrome.i18n.getMessage("appName"),
atRiskPassword: chrome.i18n.getMessage("atRiskPassword"),
changePassword: chrome.i18n.getMessage("changePassword"),
close: chrome.i18n.getMessage("close"),
collection: chrome.i18n.getMessage("collection"),
folder: chrome.i18n.getMessage("folder"),
loginSaveConfirmation: chrome.i18n.getMessage("loginSaveConfirmation"),
loginSaveSuccess: chrome.i18n.getMessage("loginSaveSuccess"),
loginUpdatedConfirmation: chrome.i18n.getMessage("loginUpdatedConfirmation"),
loginUpdateSuccess: chrome.i18n.getMessage("loginUpdateSuccess"),
loginUpdateTaskSuccess: chrome.i18n.getMessage("loginUpdateTaskSuccess"),
loginUpdateTaskSuccessAdditional: chrome.i18n.getMessage("loginUpdateTaskSuccessAdditional"),
nextSecurityTaskAction: chrome.i18n.getMessage("nextSecurityTaskAction"),
newItem: chrome.i18n.getMessage("newItem"),
never: chrome.i18n.getMessage("never"),
myVault: chrome.i18n.getMessage("myVault"),
never: chrome.i18n.getMessage("never"),
newItem: chrome.i18n.getMessage("newItem"),
nextSecurityTaskAction: chrome.i18n.getMessage("nextSecurityTaskAction"),
notificationAddDesc: chrome.i18n.getMessage("notificationAddDesc"),
notificationAddSave: chrome.i18n.getMessage("notificationAddSave"),
notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"),
notificationUpdate: chrome.i18n.getMessage("notificationChangeSave"),
notificationEdit: chrome.i18n.getMessage("edit"),
notificationEditTooltip: chrome.i18n.getMessage("notificationEditTooltip"),
notificationLoginSaveConfirmation: chrome.i18n.getMessage("notificationLoginSaveConfirmation"),
@@ -79,6 +83,7 @@ function getI18n() {
),
notificationUnlock: chrome.i18n.getMessage("notificationUnlock"),
notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"),
notificationUpdate: chrome.i18n.getMessage("notificationChangeSave"),
notificationViewAria: chrome.i18n.getMessage("notificationViewAria"),
saveAction: chrome.i18n.getMessage("notificationAddSave"),
saveAsNewLoginAction: chrome.i18n.getMessage("saveAsNewLoginAction"),
@@ -87,8 +92,8 @@ function getI18n() {
saveLogin: chrome.i18n.getMessage("saveLogin"),
typeLogin: chrome.i18n.getMessage("typeLogin"),
unlockToSave: chrome.i18n.getMessage("unlockToSave"),
updateLoginAction: chrome.i18n.getMessage("updateLoginAction"),
updateLogin: chrome.i18n.getMessage("updateLogin"),
updateLoginAction: chrome.i18n.getMessage("updateLoginAction"),
vault: chrome.i18n.getMessage("vault"),
view: chrome.i18n.getMessage("view"),
};
@@ -124,6 +129,7 @@ export function getNotificationHeaderMessage(i18n: I18n, type?: NotificationType
[NotificationTypes.Add]: i18n.saveLogin,
[NotificationTypes.Change]: i18n.updateLogin,
[NotificationTypes.Unlock]: i18n.unlockToSave,
[NotificationTypes.AtRiskPassword]: i18n.atRiskPassword,
}[type]
: undefined;
}
@@ -143,6 +149,7 @@ export function getConfirmationHeaderMessage(i18n: I18n, type?: NotificationType
[NotificationTypes.Add]: i18n.loginSaveSuccess,
[NotificationTypes.Change]: i18n.loginUpdateSuccess,
[NotificationTypes.Unlock]: "",
[NotificationTypes.AtRiskPassword]: "",
}[type]
: undefined;
}
@@ -193,6 +200,7 @@ export function getNotificationTestId(
[NotificationTypes.Unlock]: "unlock-notification-bar",
[NotificationTypes.Add]: "save-notification-bar",
[NotificationTypes.Change]: "update-notification-bar",
[NotificationTypes.AtRiskPassword]: "at-risk-password-notification-bar",
}[notificationType];
}
@@ -262,7 +270,24 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
);
}
// Handle AtRiskPasswordNotification render
if (notificationBarIframeInitData.type === NotificationTypes.AtRiskPassword) {
return render(
AtRiskNotification({
...notificationBarIframeInitData,
type: notificationBarIframeInitData.type as NotificationType,
theme: resolvedTheme,
i18n,
params: initData.params,
handleCloseNotification,
}),
document.body,
);
}
// Default scenario: add or update password
const orgId = selectedVaultSignal.get();
await Promise.all([
new Promise<OrgView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetOrgData" }, resolve),
@@ -533,7 +558,7 @@ function handleSaveCipherConfirmation(message: NotificationBarWindowMessage) {
...notificationBarIframeInitData,
error,
handleCloseNotification,
handleOpenTasks: () => sendPlatformMessage({ command: "bgOpenAtRisksPasswords" }),
handleOpenTasks: () => sendPlatformMessage({ command: "bgOpenAtRiskPasswords" }),
handleOpenVault: (e: Event) =>
cipherId ? openViewVaultItemPopout(cipherId) : openAddEditVaultItemPopout(e, {}),
headerMessage,

View File

@@ -17,6 +17,7 @@ export type NotificationsExtensionMessage = {
error?: string;
closedByUser?: boolean;
fadeOutNotification?: boolean;
params: object;
};
};

View File

@@ -2,7 +2,11 @@
// @ts-strict-ignore
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { NotificationBarIframeInitData } from "../../../notification/abstractions/notification-bar";
import {
NotificationBarIframeInitData,
NotificationType,
NotificationTypes,
} from "../../../notification/abstractions/notification-bar";
import { sendExtensionMessage, setElementStyles } from "../../../utils";
import {
NotificationsExtensionMessage,
@@ -15,8 +19,7 @@ export class OverlayNotificationsContentService
{
private notificationBarElement: HTMLElement | null = null;
private notificationBarIframeElement: HTMLIFrameElement | null = null;
private currentNotificationBarType: string | null = null;
private removeTabFromNotificationQueueTypes = new Set(["add", "change"]);
private currentNotificationBarType: NotificationType | null = null;
private notificationRefreshFlag: boolean = false;
private notificationBarElementStyles: Partial<CSSStyleDeclaration> = {
height: "82px",
@@ -79,17 +82,19 @@ export class OverlayNotificationsContentService
return;
}
const { type, typeData } = message.data;
const { type, typeData, params } = message.data;
if (this.currentNotificationBarType && type !== this.currentNotificationBarType) {
this.closeNotificationBar();
}
const initData = {
type,
type: type as NotificationType,
isVaultLocked: typeData.isVaultLocked,
theme: typeData.theme,
removeIndividualVault: typeData.removeIndividualVault,
importType: typeData.importType,
launchTimestamp: typeData.launchTimestamp,
params,
};
if (globalThis.document.readyState === "loading") {
@@ -291,10 +296,13 @@ export class OverlayNotificationsContentService
this.notificationBarElement.remove();
this.notificationBarElement = null;
if (
closedByUserAction &&
this.removeTabFromNotificationQueueTypes.has(this.currentNotificationBarType)
) {
const removableNotificationTypes = new Set([
NotificationTypes.Add,
NotificationTypes.Change,
NotificationTypes.AtRiskPassword,
] as NotificationType[]);
if (closedByUserAction && removableNotificationTypes.has(this.currentNotificationBarType)) {
void sendExtensionMessage("bgRemoveTabFromNotificationQueue");
}

View File

@@ -0,0 +1,99 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
// Duplicates Default Change Login Password Service, for now
// Since the former is an Angular injectable service, and we
// need to use the function inside of lit components.
// If primary service can be abstracted, that would be ideal.
export class TemporaryNotificationChangeLoginService {
async getChangePasswordUrl(cipher: CipherView, fallback = false): Promise<string | null> {
// Ensure we have a cipher with at least one URI
if (cipher.type !== CipherType.Login || cipher.login == null || !cipher.login.hasUris) {
return null;
}
// Filter for valid URLs that are HTTP(S)
const urls = cipher.login.uris
.map((m) => Utils.getUrl(m.uri))
.filter((m) => m != null && (m.protocol === "http:" || m.protocol === "https:"));
if (urls.length === 0) {
return null;
}
for (const url of urls) {
const [reliable, wellKnownChangeUrl] = await Promise.all([
this.hasReliableHttpStatusCode(url.origin),
this.getWellKnownChangePasswordUrl(url.origin),
]);
// Some servers return a 200 OK for a resource that should not exist
// Which means we cannot trust the well-known URL is valid, so we skip it
// to avoid potentially sending users to a 404 page
if (reliable && wellKnownChangeUrl != null) {
return wellKnownChangeUrl;
}
}
// No reliable well-known URL found, fallback to the first URL
// @TODO reimplement option in original service to indicate if no URL found.
// return urls[0].href; (originally)
return fallback ? urls[0].href : null;
}
/**
* Checks if the server returns a non-200 status code for a resource that should not exist.
* See https://w3c.github.io/webappsec-change-password-url/response-code-reliability.html#semantics
* @param urlOrigin The origin of the URL to check
*/
private async hasReliableHttpStatusCode(urlOrigin: string): Promise<boolean> {
try {
const url = new URL(
"./.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200",
urlOrigin,
);
const request = new Request(url, {
method: "GET",
mode: "same-origin",
credentials: "omit",
cache: "no-store",
redirect: "follow",
});
const response = await fetch(request);
return !response.ok;
} catch {
return false;
}
}
/**
* Builds a well-known change password URL for the given origin. Attempts to fetch the URL to ensure a valid response
* is returned. Returns null if the request throws or the response is not 200 OK.
* See https://w3c.github.io/webappsec-change-password-url/
* @param urlOrigin The origin of the URL to check
*/
private async getWellKnownChangePasswordUrl(urlOrigin: string): Promise<string | null> {
try {
const url = new URL("./.well-known/change-password", urlOrigin);
const request = new Request(url, {
method: "GET",
mode: "same-origin",
credentials: "omit",
cache: "no-store",
redirect: "follow",
});
const response = await fetch(request);
return response.ok ? url.toString() : null;
} catch {
return null;
}
}
}

View File

@@ -1,5 +1,5 @@
// This test skips all the initilization of the background script and just
// focuses on making sure we don't accidently delete the initilization of
// This test skips all the initialization of the background script and just
// focuses on making sure we don't accidentally delete the initialization of
// background vault syncing. This has happened before!
describe("MainBackground sync task scheduling", () => {
it("includes code to schedule the sync interval task", () => {

View File

@@ -1230,6 +1230,9 @@ export default class MainBackground {
this.overlayNotificationsBackground = new OverlayNotificationsBackground(
this.logService,
this.notificationBackground,
this.taskService,
this.accountService,
this.cipherService,
);
this.autoSubmitLoginBackground = new AutoSubmitLoginBackground(