1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +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 { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { UserId } from "@bitwarden/common/types/guid"; 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
@@ -828,6 +829,7 @@ describe("NotificationBackground", () => {
id: "testId", id: "testId",
name: "testItemName", name: "testItemName",
login: { username: "testUser" }, login: { username: "testUser" },
reprompt: CipherRepromptType.None,
}); });
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView); getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
@@ -842,6 +844,7 @@ describe("NotificationBackground", () => {
message.edit, message.edit,
sender.tab, sender.tab,
"testId", "testId",
false,
); );
expect(updateWithServerSpy).toHaveBeenCalled(); expect(updateWithServerSpy).toHaveBeenCalled();
expect(tabSendMessageDataSpy).toHaveBeenCalledWith( 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 () => { 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 mockCipherId = "testId";
const mockOrgId = "testOrgId"; const mockOrgId = "testOrgId";
@@ -905,6 +957,7 @@ describe("NotificationBackground", () => {
id: mockCipherId, id: mockCipherId,
organizationId: mockOrgId, organizationId: mockOrgId,
name: "Test Item", name: "Test Item",
reprompt: CipherRepromptType.None,
}); });
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView); getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
@@ -919,6 +972,7 @@ describe("NotificationBackground", () => {
message.edit, message.edit,
sender.tab, sender.tab,
mockCipherId, mockCipherId,
false,
); );
expect(updateWithServerSpy).toHaveBeenCalled(); expect(updateWithServerSpy).toHaveBeenCalled();
expect(tabSendMessageDataSpy).toHaveBeenCalledWith( expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
@@ -1000,6 +1054,7 @@ describe("NotificationBackground", () => {
message.edit, message.edit,
sender.tab, sender.tab,
"testId", "testId",
false,
); );
expect(editItemSpy).toHaveBeenCalled(); expect(editItemSpy).toHaveBeenCalled();
expect(updateWithServerSpy).not.toHaveBeenCalled(); expect(updateWithServerSpy).not.toHaveBeenCalled();
@@ -1170,7 +1225,7 @@ describe("NotificationBackground", () => {
newPassword: "newPassword", newPassword: "newPassword",
}); });
notificationBackground["notificationQueue"] = [queueMessage]; notificationBackground["notificationQueue"] = [queueMessage];
const cipherView = mock<CipherView>(); const cipherView = mock<CipherView>({ reprompt: CipherRepromptType.None });
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView); getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
const errorMessage = "fetch error"; const errorMessage = "fetch error";
updateWithServerSpy.mockImplementation(() => { updateWithServerSpy.mockImplementation(() => {

View File

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

View File

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

View File

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

View File

@@ -87,5 +87,9 @@ export abstract class AutofillService {
cipherType?: CipherType, cipherType?: CipherType,
) => Promise<string | null>; ) => Promise<string | null>;
setAutoFillOnPageLoadOrgPolicy: () => Promise<void>; 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 cipher - The cipher to autofill
* @param tab - The tab 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 = const userHasMasterPasswordAndKeyHash =
await this.userVerificationService.hasMasterPasswordAndMasterKeyHash(); await this.userVerificationService.hasMasterPasswordAndMasterKeyHash();
if (cipher.reprompt === CipherRepromptType.Password && userHasMasterPasswordAndKeyHash) { if (cipher.reprompt === CipherRepromptType.Password && userHasMasterPasswordAndKeyHash) {
if (!this.isDebouncingPasswordRepromptPopout()) { if (!this.isDebouncingPasswordRepromptPopout()) {
await this.openVaultItemPasswordRepromptPopout(tab, { await this.openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id, cipherId: cipher.id,
action: "autofill", action: action ?? "autofill",
}); });
} }

View File

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

View File

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