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

[PM-20637] Trigger password reprompt when updating a reprompt cipher via notification (#14575)

* reprompt user on cipher update when enabled

Co-authored-by: Daniel Riera <driera@livefront.com>

* update tests

* add test

---------

Co-authored-by: Daniel Riera <driera@livefront.com>
This commit is contained in:
Jonathan Prusik
2025-05-12 11:13:49 -04:00
committed by GitHub
parent 07725853a2
commit 2487e9b98d
8 changed files with 134 additions and 17 deletions

View File

@@ -17,6 +17,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
@@ -828,6 +829,7 @@ describe("NotificationBackground", () => {
id: "testId",
name: "testItemName",
login: { username: "testUser" },
reprompt: CipherRepromptType.None,
});
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
@@ -842,6 +844,7 @@ describe("NotificationBackground", () => {
message.edit,
sender.tab,
"testId",
false,
);
expect(updateWithServerSpy).toHaveBeenCalled();
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
@@ -855,6 +858,55 @@ describe("NotificationBackground", () => {
);
});
it("prompts the user for master password entry if the notification message type is for ChangePassword and the cipher reprompt is enabled", async () => {
const tab = createChromeTabMock({ id: 1, url: "https://example.com" });
const sender = mock<chrome.runtime.MessageSender>({ tab });
const message: NotificationBackgroundExtensionMessage = {
command: "bgSaveCipher",
edit: false,
folder: "folder-id",
};
const queueMessage = mock<AddChangePasswordQueueMessage>({
type: NotificationQueueMessageType.ChangePassword,
tab,
domain: "example.com",
newPassword: "newPassword",
});
notificationBackground["notificationQueue"] = [queueMessage];
const cipherView = mock<CipherView>({
id: "testId",
name: "testItemName",
login: { username: "testUser" },
reprompt: CipherRepromptType.Password,
});
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
sendMockExtensionMessage(message, sender);
await flushPromises();
expect(editItemSpy).not.toHaveBeenCalled();
expect(autofillService.isPasswordRepromptRequired).toHaveBeenCalled();
expect(createWithServerSpy).not.toHaveBeenCalled();
expect(updatePasswordSpy).toHaveBeenCalledWith(
cipherView,
queueMessage.newPassword,
message.edit,
sender.tab,
"testId",
false,
);
expect(updateWithServerSpy).not.toHaveBeenCalled();
expect(tabSendMessageDataSpy).not.toHaveBeenCalledWith(
sender.tab,
"saveCipherAttemptCompleted",
{
itemName: "testItemName",
cipherId: cipherView.id,
task: undefined,
},
);
});
it("completes password update notification with a security task notice if any are present for the cipher, and dismisses tasks for the updated cipher", async () => {
const mockCipherId = "testId";
const mockOrgId = "testOrgId";
@@ -905,6 +957,7 @@ describe("NotificationBackground", () => {
id: mockCipherId,
organizationId: mockOrgId,
name: "Test Item",
reprompt: CipherRepromptType.None,
});
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
@@ -919,6 +972,7 @@ describe("NotificationBackground", () => {
message.edit,
sender.tab,
mockCipherId,
false,
);
expect(updateWithServerSpy).toHaveBeenCalled();
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
@@ -1000,6 +1054,7 @@ describe("NotificationBackground", () => {
message.edit,
sender.tab,
"testId",
false,
);
expect(editItemSpy).toHaveBeenCalled();
expect(updateWithServerSpy).not.toHaveBeenCalled();
@@ -1170,7 +1225,7 @@ describe("NotificationBackground", () => {
newPassword: "newPassword",
});
notificationBackground["notificationQueue"] = [queueMessage];
const cipherView = mock<CipherView>();
const cipherView = mock<CipherView>({ reprompt: CipherRepromptType.None });
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
const errorMessage = "fetch error";
updateWithServerSpy.mockImplementation(() => {

View File

@@ -14,6 +14,7 @@ import {
ExtensionCommand,
ExtensionCommandType,
NOTIFICATION_BAR_LIFESPAN_MS,
UPDATE_PASSWORD,
} from "@bitwarden/common/autofill/constants";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
@@ -104,6 +105,8 @@ export default class NotificationBackground {
this.removeTabFromNotificationQueue(sender.tab),
bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab),
bgSaveCipher: ({ message, sender }) => this.handleSaveCipherMessage(message, sender),
bgHandleReprompt: ({ message, sender }: any) =>
this.handleCipherUpdateRepromptResponse(message),
bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab),
checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab),
collectPageDetailsResponse: ({ message }) =>
@@ -631,6 +634,17 @@ export default class NotificationBackground {
await this.saveOrUpdateCredentials(sender.tab, message.edit, message.folder);
}
async handleCipherUpdateRepromptResponse(message: NotificationBackgroundExtensionMessage) {
if (message.success) {
await this.saveOrUpdateCredentials(message.tab, false, undefined, true);
} else {
await BrowserApi.tabSendMessageData(message.tab, "saveCipherAttemptCompleted", {
error: "Password reprompt failed",
});
return;
}
}
/**
* Saves or updates credentials based on the message within the
* notification queue that is associated with the specified tab.
@@ -639,7 +653,12 @@ export default class NotificationBackground {
* @param edit - Identifies if the credentials should be edited or simply added
* @param folderId - The folder to add the cipher to
*/
private async saveOrUpdateCredentials(tab: chrome.tabs.Tab, edit: boolean, folderId?: string) {
private async saveOrUpdateCredentials(
tab: chrome.tabs.Tab,
edit: boolean,
folderId?: string,
skipReprompt: boolean = false,
) {
for (let i = this.notificationQueue.length - 1; i >= 0; i--) {
const queueMessage = this.notificationQueue[i];
if (
@@ -654,18 +673,26 @@ export default class NotificationBackground {
continue;
}
this.notificationQueue.splice(i, 1);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (queueMessage.type === NotificationQueueMessageType.ChangePassword) {
const cipherView = await this.getDecryptedCipherById(queueMessage.cipherId, activeUserId);
await this.updatePassword(cipherView, queueMessage.newPassword, edit, tab, activeUserId);
await this.updatePassword(
cipherView,
queueMessage.newPassword,
edit,
tab,
activeUserId,
skipReprompt,
);
return;
}
this.notificationQueue.splice(i, 1);
// If the vault was locked, check if a cipher needs updating instead of creating a new one
if (queueMessage.wasVaultLocked) {
const allCiphers = await this.cipherService.getAllDecryptedForUrl(
@@ -725,6 +752,7 @@ export default class NotificationBackground {
edit: boolean,
tab: chrome.tabs.Tab,
userId: UserId,
skipReprompt: boolean = false,
) {
cipherView.login.password = newPassword;
@@ -758,6 +786,12 @@ export default class NotificationBackground {
}
: undefined;
if (cipherView.reprompt && !skipReprompt) {
await this.autofillService.isPasswordRepromptRequired(cipherView, tab, UPDATE_PASSWORD);
return;
}
await this.cipherService.updateWithServer(cipher);
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {

View File

@@ -15,6 +15,7 @@ export type NotificationsExtensionMessage = {
typeData?: NotificationTypeData;
height?: number;
error?: string;
closedByUser?: boolean;
fadeOutNotification?: boolean;
};
};

View File

@@ -106,13 +106,15 @@ export class OverlayNotificationsContentService
* @param message - The message containing the data for closing the notification bar.
*/
private handleCloseNotificationBarMessage(message: NotificationsExtensionMessage) {
const closedByUser =
typeof message.data?.closedByUser === "boolean" ? message.data.closedByUser : true;
if (message.data?.fadeOutNotification) {
setElementStyles(this.notificationBarIframeElement, { opacity: "0" }, true);
globalThis.setTimeout(() => this.closeNotificationBar(true), 150);
globalThis.setTimeout(() => this.closeNotificationBar(closedByUser), 150);
return;
}
this.closeNotificationBar(true);
this.closeNotificationBar(closedByUser);
}
/**

View File

@@ -87,5 +87,9 @@ export abstract class AutofillService {
cipherType?: CipherType,
) => Promise<string | null>;
setAutoFillOnPageLoadOrgPolicy: () => Promise<void>;
isPasswordRepromptRequired: (cipher: CipherView, tab: chrome.tabs.Tab) => Promise<boolean>;
isPasswordRepromptRequired: (
cipher: CipherView,
tab: chrome.tabs.Tab,
action?: string,
) => Promise<boolean>;
}

View File

@@ -593,15 +593,20 @@ export default class AutofillService implements AutofillServiceInterface {
*
* @param cipher - The cipher to autofill
* @param tab - The tab to autofill
* @param action - override for default action once reprompt is completed successfully
*/
async isPasswordRepromptRequired(cipher: CipherView, tab: chrome.tabs.Tab): Promise<boolean> {
async isPasswordRepromptRequired(
cipher: CipherView,
tab: chrome.tabs.Tab,
action?: string,
): Promise<boolean> {
const userHasMasterPasswordAndKeyHash =
await this.userVerificationService.hasMasterPasswordAndMasterKeyHash();
if (cipher.reprompt === CipherRepromptType.Password && userHasMasterPasswordAndKeyHash) {
if (!this.isDebouncingPasswordRepromptPopout()) {
await this.openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id,
action: "autofill",
action: action ?? "autofill",
});
}

View File

@@ -19,6 +19,7 @@ import {
COPY_USERNAME_ID,
COPY_VERIFICATION_CODE_ID,
SHOW_AUTOFILL_BUTTON,
UPDATE_PASSWORD,
} from "@bitwarden/common/autofill/constants";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -49,6 +50,7 @@ import {
PasswordRepromptService,
} from "@bitwarden/vault";
import { sendExtensionMessage } from "../../../../../autofill/utils/index";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
@@ -72,7 +74,8 @@ type LoadAction =
| typeof SHOW_AUTOFILL_BUTTON
| typeof COPY_USERNAME_ID
| typeof COPY_PASSWORD_ID
| typeof COPY_VERIFICATION_CODE_ID;
| typeof COPY_VERIFICATION_CODE_ID
| typeof UPDATE_PASSWORD;
@Component({
selector: "app-view-v2",
@@ -294,7 +297,7 @@ export class ViewV2Component {
// Both vaultPopupAutofillService and copyCipherFieldService will perform password re-prompting internally.
switch (loadAction) {
case "show-autofill-button":
case SHOW_AUTOFILL_BUTTON:
// This action simply shows the cipher view, no need to do anything.
if (
this.cipher.reprompt !== CipherRepromptType.None &&
@@ -303,30 +306,42 @@ export class ViewV2Component {
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
}
return;
case "autofill":
case AUTOFILL_ID:
actionSuccess = await this.vaultPopupAutofillService.doAutofill(this.cipher, false);
break;
case "copy-username":
case COPY_USERNAME_ID:
actionSuccess = await this.copyCipherFieldService.copy(
this.cipher.login.username,
"username",
this.cipher,
);
break;
case "copy-password":
case COPY_PASSWORD_ID:
actionSuccess = await this.copyCipherFieldService.copy(
this.cipher.login.password,
"password",
this.cipher,
);
break;
case "copy-totp":
case COPY_VERIFICATION_CODE_ID:
actionSuccess = await this.copyCipherFieldService.copy(
this.cipher.login.totp,
"totp",
this.cipher,
);
break;
case UPDATE_PASSWORD: {
const repromptSuccess = await this.passwordRepromptService.showPasswordPrompt();
await sendExtensionMessage("bgHandleReprompt", {
tab: await chrome.tabs.get(senderTabId),
success: repromptSuccess,
});
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
break;
}
}
if (BrowserPopupUtils.inPopout(window)) {

View File

@@ -38,7 +38,7 @@ export const ClearClipboardDelay = {
FiveMinutes: 300,
} as const;
/* Context Menu item Ids */
/* Ids for context menu items and messaging events */
export const AUTOFILL_CARD_ID = "autofill-card";
export const AUTOFILL_ID = "autofill";
export const SHOW_AUTOFILL_BUTTON = "show-autofill-button";
@@ -54,6 +54,7 @@ export const GENERATE_PASSWORD_ID = "generate-password";
export const NOOP_COMMAND_SUFFIX = "noop";
export const ROOT_ID = "root";
export const SEPARATOR_ID = "separator";
export const UPDATE_PASSWORD = "update-password";
export const NOTIFICATION_BAR_LIFESPAN_MS = 150000; // 150 seconds