mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +00:00
[PM-23157] Autofill - Master Reprompt (#15431)
* delay setting `this.cipher` until reprompt is verified * handle failures of reprompt * do not show cipher after copy password or copy totp fails
This commit is contained in:
@@ -20,7 +20,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
|||||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||||
import { DialogService, ToastService } from "@bitwarden/components";
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
@@ -241,6 +241,93 @@ describe("ViewV2Component", () => {
|
|||||||
expect(copy).toHaveBeenCalledTimes(1);
|
expect(copy).toHaveBeenCalledTimes(1);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it("does not set the cipher until reprompt is complete", fakeAsync(() => {
|
||||||
|
let promptPromise: (val?: unknown) => void;
|
||||||
|
mockCipherService.decrypt.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockCipher,
|
||||||
|
reprompt: CipherRepromptType.Password,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
doAutofill.mockImplementationOnce(() => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// store the promise resolver to manually trigger the promise resolve
|
||||||
|
promptPromise = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
params$.next({ action: AUTOFILL_ID });
|
||||||
|
|
||||||
|
flush(); // Flush all pending actions
|
||||||
|
|
||||||
|
expect(component.cipher).toBeUndefined();
|
||||||
|
expect(doAutofill).toHaveBeenCalled();
|
||||||
|
|
||||||
|
promptPromise!(true); // resolve the password prompt
|
||||||
|
|
||||||
|
flush();
|
||||||
|
expect(component.cipher).toEqual({ ...mockCipher, reprompt: CipherRepromptType.Password });
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("does not set the cipher at all if doAutofill fails and reprompt is active", fakeAsync(() => {
|
||||||
|
let promptPromise: (val?: unknown) => void;
|
||||||
|
mockCipherService.decrypt.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockCipher,
|
||||||
|
reprompt: CipherRepromptType.Password,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
doAutofill.mockImplementationOnce(() => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// store the promise resolver to manually trigger the promise resolve
|
||||||
|
promptPromise = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
params$.next({ action: AUTOFILL_ID });
|
||||||
|
|
||||||
|
flush(); // Flush all pending actions
|
||||||
|
|
||||||
|
expect(component.cipher).toBeUndefined();
|
||||||
|
expect(doAutofill).toHaveBeenCalled();
|
||||||
|
|
||||||
|
promptPromise!(false); // resolve the password prompt
|
||||||
|
|
||||||
|
flush();
|
||||||
|
expect(component.cipher).toBeUndefined();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it.each([COPY_PASSWORD_ID, COPY_VERIFICATION_CODE_ID])(
|
||||||
|
"does not set cipher when copy fails for %s",
|
||||||
|
fakeAsync((action: string) => {
|
||||||
|
let promptPromise: (val?: unknown) => void;
|
||||||
|
mockCipherService.decrypt.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockCipher,
|
||||||
|
reprompt: CipherRepromptType.Password,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
copy.mockImplementationOnce(() => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// store the promise resolver to manually trigger the promise resolve
|
||||||
|
promptPromise = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
params$.next({ action });
|
||||||
|
|
||||||
|
flush(); // Flush all pending actions
|
||||||
|
|
||||||
|
expect(component.cipher).toBeUndefined();
|
||||||
|
expect(copy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
promptPromise!(false); // resolve the password prompt
|
||||||
|
|
||||||
|
flush();
|
||||||
|
expect(component.cipher).toBeUndefined();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
it("closes the popout after a load action", fakeAsync(() => {
|
it("closes the popout after a load action", fakeAsync(() => {
|
||||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValueOnce(true);
|
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValueOnce(true);
|
||||||
jest.spyOn(BrowserPopupUtils, "inSingleActionPopout").mockReturnValueOnce(true);
|
jest.spyOn(BrowserPopupUtils, "inSingleActionPopout").mockReturnValueOnce(true);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions
|
|||||||
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||||
|
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||||
import {
|
import {
|
||||||
AsyncActionsModule,
|
AsyncActionsModule,
|
||||||
ButtonModule,
|
ButtonModule,
|
||||||
@@ -139,20 +140,38 @@ export class ViewV2Component {
|
|||||||
this.loadAction = params.action;
|
this.loadAction = params.action;
|
||||||
this.senderTabId = params.senderTabId ? parseInt(params.senderTabId, 10) : undefined;
|
this.senderTabId = params.senderTabId ? parseInt(params.senderTabId, 10) : undefined;
|
||||||
|
|
||||||
const activeUserId = await firstValueFrom(
|
this.activeUserId = await firstValueFrom(
|
||||||
this.accountService.activeAccount$.pipe(getUserId),
|
this.accountService.activeAccount$.pipe(getUserId),
|
||||||
);
|
);
|
||||||
const cipher = await this.getCipherData(params.cipherId, activeUserId);
|
|
||||||
return { activeUserId, cipher };
|
|
||||||
}),
|
|
||||||
switchMap(async ({ activeUserId, cipher }) => {
|
|
||||||
this.cipher = cipher;
|
|
||||||
this.headerText = this.setHeader(cipher.type);
|
|
||||||
this.activeUserId = activeUserId;
|
|
||||||
|
|
||||||
|
const cipher = await this.getCipherData(params.cipherId, this.activeUserId);
|
||||||
|
this.headerText = this.setHeader(cipher.type);
|
||||||
|
|
||||||
|
// Handling the load action needs to take place before setting `this.cipher`,
|
||||||
|
// This is important for scenarios where the action requires a password re-prompt.
|
||||||
|
// For those instances, no cipher details should be shown behind the re-prompt dialog until the password has been verified.
|
||||||
if (this.loadAction) {
|
if (this.loadAction) {
|
||||||
await this._handleLoadAction(this.loadAction, this.senderTabId);
|
const success = await this._handleLoadAction(this.loadAction, cipher, this.senderTabId);
|
||||||
|
|
||||||
|
// When the action is not successful and the cipher has a reprompt enabled,
|
||||||
|
// The cipher details can flash on the screen before the popout closes,
|
||||||
|
// pass `null` to prevent this.
|
||||||
|
if (
|
||||||
|
[AUTOFILL_ID, COPY_PASSWORD_ID, COPY_VERIFICATION_CODE_ID].includes(
|
||||||
|
this.loadAction,
|
||||||
|
) &&
|
||||||
|
success === false &&
|
||||||
|
cipher.reprompt !== CipherRepromptType.None
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cipher;
|
||||||
|
}),
|
||||||
|
filterOutNullish(),
|
||||||
|
switchMap(async (cipher) => {
|
||||||
|
this.cipher = cipher;
|
||||||
|
|
||||||
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(cipher);
|
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(cipher);
|
||||||
|
|
||||||
@@ -262,50 +281,46 @@ export class ViewV2Component {
|
|||||||
* via the extension context menu. It is necessary to render the view for items that have password
|
* via the extension context menu. It is necessary to render the view for items that have password
|
||||||
* reprompt enabled.
|
* reprompt enabled.
|
||||||
* @param loadAction
|
* @param loadAction
|
||||||
|
* @param cipher - The cipher being viewed, passed as a param because `this.cipher` may not be set yet.
|
||||||
* @param senderTabId
|
* @param senderTabId
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private async _handleLoadAction(
|
private async _handleLoadAction(
|
||||||
loadAction: LoadAction,
|
loadAction: LoadAction,
|
||||||
|
cipher: CipherView,
|
||||||
senderTabId?: number,
|
senderTabId?: number,
|
||||||
): Promise<void | boolean> {
|
): Promise<void | boolean> {
|
||||||
let actionSuccess = false;
|
let actionSuccess = false;
|
||||||
|
|
||||||
// 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 &&
|
cipher.reprompt !== CipherRepromptType.None &&
|
||||||
!(await this.passwordRepromptService.showPasswordPrompt())
|
!(await this.passwordRepromptService.showPasswordPrompt())
|
||||||
) {
|
) {
|
||||||
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
|
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${cipher.id}`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case AUTOFILL_ID:
|
case AUTOFILL_ID:
|
||||||
actionSuccess = await this.vaultPopupAutofillService.doAutofill(this.cipher, false);
|
actionSuccess = await this.vaultPopupAutofillService.doAutofill(cipher, false);
|
||||||
break;
|
break;
|
||||||
case COPY_USERNAME_ID:
|
case COPY_USERNAME_ID:
|
||||||
actionSuccess = await this.copyCipherFieldService.copy(
|
actionSuccess = await this.copyCipherFieldService.copy(
|
||||||
this.cipher.login.username,
|
cipher.login.username,
|
||||||
"username",
|
"username",
|
||||||
this.cipher,
|
cipher,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case COPY_PASSWORD_ID:
|
case COPY_PASSWORD_ID:
|
||||||
actionSuccess = await this.copyCipherFieldService.copy(
|
actionSuccess = await this.copyCipherFieldService.copy(
|
||||||
this.cipher.login.password,
|
cipher.login.password,
|
||||||
"password",
|
"password",
|
||||||
this.cipher,
|
cipher,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case COPY_VERIFICATION_CODE_ID:
|
case COPY_VERIFICATION_CODE_ID:
|
||||||
actionSuccess = await this.copyCipherFieldService.copy(
|
actionSuccess = await this.copyCipherFieldService.copy(cipher.login.totp, "totp", cipher);
|
||||||
this.cipher.login.totp,
|
|
||||||
"totp",
|
|
||||||
this.cipher,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case UPDATE_PASSWORD: {
|
case UPDATE_PASSWORD: {
|
||||||
const repromptSuccess = await this.passwordRepromptService.showPasswordPrompt();
|
const repromptSuccess = await this.passwordRepromptService.showPasswordPrompt();
|
||||||
@@ -315,7 +330,7 @@ export class ViewV2Component {
|
|||||||
success: repromptSuccess,
|
success: repromptSuccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
|
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${cipher.id}`);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -329,7 +344,7 @@ export class ViewV2Component {
|
|||||||
senderTabId
|
senderTabId
|
||||||
) {
|
) {
|
||||||
await BrowserApi.focusTab(senderTabId);
|
await BrowserApi.focusTab(senderTabId);
|
||||||
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
|
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${cipher.id}`);
|
||||||
} else {
|
} else {
|
||||||
await this.popupRouterCacheService.back();
|
await this.popupRouterCacheService.back();
|
||||||
}
|
}
|
||||||
@@ -337,5 +352,7 @@ export class ViewV2Component {
|
|||||||
actionSuccess ? 1000 : 0,
|
actionSuccess ? 1000 : 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return actionSuccess;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user