1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-11 10:10:25 +01:00

[PM-9959] [PM-9962] Browser Refresh - Passkey Fixes (#10299)

* [PM-9959] Expose Fido2SessionData interface

* [PM-9959] Ensure cipherType is passed during passkey creation

* [PM-9959] Add beforeSubmit hook to cipherForm

* [PM-9959] Add support for Fido2 credential creation in add-edit-v2

* [PM-9959] Ensure cipherType defaults to CipherType.Login if none is available

* [PM-9959] Add support for name and username to be passed in as query params for initial form values

* [PM-9962] Hide remove passkey button when cipher has "except passwords" permissions
This commit is contained in:
Shane Melton 2024-07-29 08:13:56 -07:00 committed by GitHub
parent 00f6920a86
commit ad01a529e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 166 additions and 25 deletions

View File

@ -18,7 +18,7 @@ import { DomainSettingsService } from "@bitwarden/common/autofill/services/domai
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SecureNoteType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@ -311,6 +311,7 @@ export class Fido2Component implements OnInit, OnDestroy {
queryParams: { queryParams: {
name: data.credentialName || data.rpId, name: data.credentialName || data.rpId,
uri: this.url, uri: this.url,
type: CipherType.Login.toString(),
uilocation: "popout", uilocation: "popout",
username: data.userName, username: data.userName,
senderTabId: this.senderTabId, senderTabId: this.senderTabId,

View File

@ -10,7 +10,8 @@
*ngIf="!loading" *ngIf="!loading"
formId="cipherForm" formId="cipherForm"
[config]="config" [config]="config"
(cipherSaved)="onCipherSaved()" (cipherSaved)="onCipherSaved($event)"
[beforeSubmit]="checkFido2UserVerification"
[submitBtn]="submitBtn" [submitBtn]="submitBtn"
> >
<app-open-attachments <app-open-attachments

View File

@ -1,14 +1,15 @@
import { CommonModule, Location } from "@angular/common"; import { CommonModule, Location } from "@angular/common";
import { Component } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms"; import { FormsModule } from "@angular/forms";
import { ActivatedRoute, Params, Router } from "@angular/router"; import { ActivatedRoute, Params, Router } from "@angular/router";
import { map, switchMap } from "rxjs"; import { firstValueFrom, map, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AsyncActionsModule, ButtonModule, SearchModule } from "@bitwarden/components"; import { AsyncActionsModule, ButtonModule, SearchModule } from "@bitwarden/components";
import { import {
CipherFormConfig, CipherFormConfig,
@ -19,10 +20,18 @@ import {
TotpCaptureService, TotpCaptureService,
} from "@bitwarden/vault"; } from "@bitwarden/vault";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
import { PopupCloseWarningService } from "../../../../../popup/services/popup-close-warning.service";
import { BrowserFido2UserInterfaceSession } from "../../../../fido2/browser-fido2-user-interface.service";
import { BrowserTotpCaptureService } from "../../../services/browser-totp-capture.service"; import { BrowserTotpCaptureService } from "../../../services/browser-totp-capture.service";
import {
fido2PopoutSessionData$,
Fido2SessionData,
} from "../../../utils/fido2-popout-session-data";
import { VaultPopoutType } from "../../../utils/vault-popout-window";
import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component"; import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component";
/** /**
@ -31,12 +40,14 @@ import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-a
class QueryParams { class QueryParams {
constructor(params: Params) { constructor(params: Params) {
this.cipherId = params.cipherId; this.cipherId = params.cipherId;
this.type = parseInt(params.type, null); this.type = params.type != undefined ? parseInt(params.type, null) : undefined;
this.clone = params.clone === "true"; this.clone = params.clone === "true";
this.folderId = params.folderId; this.folderId = params.folderId;
this.organizationId = params.organizationId; this.organizationId = params.organizationId;
this.collectionId = params.collectionId; this.collectionId = params.collectionId;
this.uri = params.uri; this.uri = params.uri;
this.username = params.username;
this.name = params.name;
} }
/** /**
@ -47,7 +58,7 @@ class QueryParams {
/** /**
* The type of cipher to create. * The type of cipher to create.
*/ */
type: CipherType; type?: CipherType;
/** /**
* Whether to clone the cipher. * Whether to clone the cipher.
@ -73,6 +84,16 @@ class QueryParams {
* Optional URI to pre-fill for login ciphers. * Optional URI to pre-fill for login ciphers.
*/ */
uri?: string; uri?: string;
/**
* Optional username to pre-fill for login/identity ciphers.
*/
username?: string;
/**
* Optional name to pre-fill for the cipher.
*/
name?: string;
} }
export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>; export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
@ -99,7 +120,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
AsyncActionsModule, AsyncActionsModule,
], ],
}) })
export class AddEditV2Component { export class AddEditV2Component implements OnInit {
headerText: string; headerText: string;
config: CipherFormConfig; config: CipherFormConfig;
@ -111,16 +132,50 @@ export class AddEditV2Component {
return this.config?.originalCipher?.id as CipherId; return this.config?.originalCipher?.id as CipherId;
} }
private fido2PopoutSessionData$ = fido2PopoutSessionData$();
private fido2PopoutSessionData: Fido2SessionData;
private get inFido2PopoutWindow() {
return BrowserPopupUtils.inPopout(window) && this.fido2PopoutSessionData.isFido2Session;
}
private get inSingleActionPopout() {
return BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.addEditVaultItem);
}
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private location: Location, private location: Location,
private i18nService: I18nService, private i18nService: I18nService,
private addEditFormConfigService: CipherFormConfigService, private addEditFormConfigService: CipherFormConfigService,
private router: Router, private router: Router,
private popupCloseWarningService: PopupCloseWarningService,
) { ) {
this.subscribeToParams(); this.subscribeToParams();
} }
async ngOnInit() {
this.fido2PopoutSessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (BrowserPopupUtils.inPopout(window)) {
this.popupCloseWarningService.enable();
}
}
/**
* Called before the form is submitted, allowing us to handle Fido2 user verification.
*/
protected checkFido2UserVerification: () => Promise<boolean> = async () => {
if (!this.inFido2PopoutWindow) {
// Not in a Fido2 popout window, no need to handle user verification.
return true;
}
// TODO use fido2 user verification service once user verification for passkeys is approved for production.
// We are bypassing user verification pending approval for production.
return true;
};
/** /**
* Navigates to previous view or view-cipher path * Navigates to previous view or view-cipher path
* depending on the history length. * depending on the history length.
@ -129,6 +184,17 @@ export class AddEditV2Component {
* forced into a popout window. * forced into a popout window.
*/ */
async handleBackButton() { async handleBackButton() {
if (this.inFido2PopoutWindow) {
this.popupCloseWarningService.disable();
BrowserFido2UserInterfaceSession.abortPopout(this.fido2PopoutSessionData.sessionId);
return;
}
if (this.inSingleActionPopout) {
await BrowserPopupUtils.closeSingleActionPopout(VaultPopoutType.addEditVaultItem);
return;
}
if (history.length === 1) { if (history.length === 1) {
await this.router.navigate(["/view-cipher"], { await this.router.navigate(["/view-cipher"], {
queryParams: { cipherId: this.originalCipherId }, queryParams: { cipherId: this.originalCipherId },
@ -138,7 +204,25 @@ export class AddEditV2Component {
} }
} }
onCipherSaved() { async onCipherSaved(cipher: CipherView) {
if (BrowserPopupUtils.inPopout(window)) {
this.popupCloseWarningService.disable();
}
if (this.inFido2PopoutWindow) {
BrowserFido2UserInterfaceSession.confirmNewCredentialResponse(
this.fido2PopoutSessionData.sessionId,
cipher.id,
this.fido2PopoutSessionData.userVerification,
);
return;
}
if (this.inSingleActionPopout) {
await BrowserPopupUtils.closeSingleActionPopout(VaultPopoutType.addEditVaultItem, 1000);
return;
}
this.location.back(); this.location.back();
} }
@ -189,6 +273,12 @@ export class AddEditV2Component {
if (params.uri) { if (params.uri) {
config.initialValues.loginUri = params.uri; config.initialValues.loginUri = params.uri;
} }
if (params.username) {
config.initialValues.username = params.username;
}
if (params.name) {
config.initialValues.name = params.name;
}
} }
setHeader(mode: CipherFormMode, type: CipherType) { setHeader(mode: CipherFormMode, type: CipherType) {

View File

@ -397,6 +397,7 @@ export class AddEditComponent extends BaseAddEditComponent {
} }
// TODO: Remove and use fido2 user verification service once user verification for passkeys is approved for production. // TODO: Remove and use fido2 user verification service once user verification for passkeys is approved for production.
// Be sure to make the same changes to add-edit-v2.component.ts if applicable
private async handleFido2UserVerification( private async handleFido2UserVerification(
sessionId: string, sessionId: string,
userVerification: boolean, userVerification: boolean,

View File

@ -2,6 +2,18 @@ import { inject } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { map } from "rxjs"; import { map } from "rxjs";
/**
* Interface describing the data that can be passed as query params for a FIDO2 session.
*/
export interface Fido2SessionData {
isFido2Session: boolean;
sessionId: string;
fallbackSupported: boolean;
userVerification: boolean;
senderUrl: string;
fromLock: boolean;
}
/** /**
* Function to retrieve FIDO2 session data from query parameters. * Function to retrieve FIDO2 session data from query parameters.
* Expected to be used within components tied to routes with these query parameters. * Expected to be used within components tied to routes with these query parameters.
@ -10,13 +22,16 @@ export function fido2PopoutSessionData$() {
const route = inject(ActivatedRoute); const route = inject(ActivatedRoute);
return route.queryParams.pipe( return route.queryParams.pipe(
map((queryParams) => ({ map(
(queryParams) =>
<Fido2SessionData>{
isFido2Session: queryParams.sessionId != null, isFido2Session: queryParams.sessionId != null,
sessionId: queryParams.sessionId as string, sessionId: queryParams.sessionId as string,
fallbackSupported: queryParams.fallbackSupported === "true", fallbackSupported: queryParams.fallbackSupported === "true",
userVerification: queryParams.userVerification === "true", userVerification: queryParams.userVerification === "true",
senderUrl: queryParams.senderUrl as string, senderUrl: queryParams.senderUrl as string,
fromLock: queryParams.fromLock === "true", fromLock: queryParams.fromLock === "true",
})), },
),
); );
} }

View File

@ -22,6 +22,8 @@ export type OptionalInitialValues = {
organizationId?: OrganizationId; organizationId?: OrganizationId;
collectionIds?: CollectionId[]; collectionIds?: CollectionId[];
loginUri?: string; loginUri?: string;
username?: string;
name?: string;
}; };
/** /**

View File

@ -90,6 +90,12 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
@Input() @Input()
submitBtn?: ButtonComponent; submitBtn?: ButtonComponent;
/**
* Optional function to call before submitting the form. If the function returns false, the form will not be submitted.
*/
@Input()
beforeSubmit: () => Promise<boolean>;
/** /**
* Event emitted when the cipher is saved successfully. * Event emitted when the cipher is saved successfully.
*/ */
@ -213,7 +219,17 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
return; return;
} }
await this.addEditFormService.saveCipher(this.updatedCipherView, this.config); if (this.beforeSubmit) {
const shouldSubmit = await this.beforeSubmit();
if (!shouldSubmit) {
return;
}
}
const savedCipher = await this.addEditFormService.saveCipher(
this.updatedCipherView,
this.config,
);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
@ -225,6 +241,6 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
), ),
}); });
this.cipherSaved.emit(this.updatedCipherView); this.cipherSaved.emit(savedCipher);
}; };
} }

View File

@ -113,6 +113,10 @@ export class IdentitySectionComponent implements OnInit {
if (this.originalCipherView && this.originalCipherView.id) { if (this.originalCipherView && this.originalCipherView.id) {
this.populateFormData(); this.populateFormData();
} else {
this.identityForm.patchValue({
username: this.cipherFormContainer.config.initialValues?.username || "",
});
} }
} }

View File

@ -165,7 +165,7 @@ export class ItemDetailsSectionComponent implements OnInit {
await this.initFromExistingCipher(); await this.initFromExistingCipher();
} else { } else {
this.itemDetailsForm.setValue({ this.itemDetailsForm.setValue({
name: "", name: this.initialValues?.name || "",
organizationId: this.initialValues?.organizationId || this.defaultOwner, organizationId: this.initialValues?.organizationId || this.defaultOwner,
folderId: this.initialValues?.folderId || null, folderId: this.initialValues?.folderId || null,
collectionIds: [], collectionIds: [],

View File

@ -67,7 +67,7 @@
bitIconButton="bwi-minus-circle" bitIconButton="bwi-minus-circle"
buttonType="danger" buttonType="danger"
bitSuffix bitSuffix
*ngIf="loginDetailsForm.enabled" *ngIf="loginDetailsForm.enabled && viewHiddenFields"
[bitAction]="removePasskey" [bitAction]="removePasskey"
data-testid="remove-passkey-button" data-testid="remove-passkey-button"
[appA11yTitle]="'removePasskey' | i18n" [appA11yTitle]="'removePasskey' | i18n"

View File

@ -452,6 +452,8 @@ describe("LoginDetailsSectionComponent", () => {
fixture = TestBed.createComponent(LoginDetailsSectionComponent); fixture = TestBed.createComponent(LoginDetailsSectionComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(true);
}); });
it("renders the passkey field when available", () => { it("renders the passkey field when available", () => {
@ -469,7 +471,7 @@ describe("LoginDetailsSectionComponent", () => {
it("renders the passkey remove button when editable", () => { it("renders the passkey remove button when editable", () => {
fixture.detectChanges(); fixture.detectChanges();
expect(getRemovePasskeyBtn).not.toBeNull(); expect(getRemovePasskeyBtn()).not.toBeNull();
}); });
it("does not render the passkey remove button when not editable", () => { it("does not render the passkey remove button when not editable", () => {
@ -480,6 +482,14 @@ describe("LoginDetailsSectionComponent", () => {
expect(getRemovePasskeyBtn()).toBeNull(); expect(getRemovePasskeyBtn()).toBeNull();
}); });
it("does not render the passkey remove button when viewHiddenFields is false", () => {
jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(false);
fixture.detectChanges();
expect(getRemovePasskeyBtn()).toBeNull();
});
it("hides the passkey field when missing a passkey", () => { it("hides the passkey field when missing a passkey", () => {
(cipherFormContainer.originalCipherView as CipherView).login.fido2Credentials = []; (cipherFormContainer.originalCipherView as CipherView).login.fido2Credentials = [];

View File

@ -144,9 +144,10 @@ export class LoginDetailsSectionComponent implements OnInit {
} }
private async initNewCipher() { private async initNewCipher() {
this.loginDetailsForm.controls.password.patchValue( this.loginDetailsForm.patchValue({
await this.generationService.generateInitialPassword(), username: this.cipherFormContainer.config.initialValues?.username || "",
); password: await this.generationService.generateInitialPassword(),
});
} }
captureTotp = async () => { captureTotp = async () => {

View File

@ -48,7 +48,7 @@ export class DefaultCipherFormConfigService implements CipherFormConfigService {
return { return {
mode, mode,
cipherType, cipherType: cipher?.type ?? cipherType ?? CipherType.Login,
admin: false, admin: false,
allowPersonalOwnership, allowPersonalOwnership,
originalCipher: cipher, originalCipher: cipher,