From f75c1ab02dc9594f1c5a76707f71e08493d28a37 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 18 Jul 2024 09:38:55 -0700 Subject: [PATCH] [PM-8524] Cipher Form - Edit Login Details Section (#10081) * [PM-8524] Introduce login details section component * [PM-8524] Add ability to remove passkey * [PM-8524] Introduce TotpCaptureService and the Browser implementation * [PM-8524] Tweak storybook * [PM-8524] Add note regarding existing login view references * [PM-8524] Fix clone mode so that a new cipher is created * [PM-8524] Add support for generating usernames/passwords and auditing passwords * [PM-8524] Migrate password/username generation to CipherFormGenerationService * [PM-8524] Add optional passwordInput to BitPasswordInputToggle to support conditionally rendered password toggle buttons * [PM-8524] Add LoginDetailsSection tests * [PM-8524] Add BrowserTotpCaptureService tests * Revert "[PM-8524] Add optional passwordInput to BitPasswordInputToggle to support conditionally rendered password toggle buttons" This reverts commit e76a0ccfe896c68a85a75a3b1fd4c4ac628f6cee. * [PM-8524] Add null check to password input toggle --- apps/browser/src/_locales/en/messages.json | 15 + .../add-edit/add-edit-v2.component.ts | 7 +- .../browser-totp-capture.service.spec.ts | 69 +++ .../services/browser-totp-capture.service.ts | 23 + apps/web/src/locales/en/messages.json | 15 + .../password-input-toggle.directive.ts | 4 +- .../cipher-form-generation.service.ts | 20 + .../abstractions/totp-capture.service.ts | 9 + .../src/cipher-form/cipher-form-container.ts | 2 + .../src/cipher-form/cipher-form.module.ts | 6 + .../src/cipher-form/cipher-form.stories.ts | 40 +- .../components/cipher-form.component.html | 4 + .../components/cipher-form.component.ts | 6 + .../login-details-section.component.html | 110 ++++ .../login-details-section.component.spec.ts | 503 ++++++++++++++++++ .../login-details-section.component.ts | 228 ++++++++ libs/vault/src/cipher-form/index.ts | 2 + .../default-cipher-form-generation.service.ts | 30 ++ 18 files changed, 1090 insertions(+), 3 deletions(-) create mode 100644 apps/browser/src/vault/popup/services/browser-totp-capture.service.spec.ts create mode 100644 apps/browser/src/vault/popup/services/browser-totp-capture.service.ts create mode 100644 libs/vault/src/cipher-form/abstractions/cipher-form-generation.service.ts create mode 100644 libs/vault/src/cipher-form/abstractions/totp-capture.service.ts create mode 100644 libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html create mode 100644 libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts create mode 100644 libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts create mode 100644 libs/vault/src/cipher-form/services/default-cipher-form-generation.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index b6968f1ff87..462b6eedbef 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -639,6 +639,15 @@ "totpCapture": { "message": "Scan authenticator QR code from current webpage" }, + "totpHelperTitle": { + "message": "Make 2-step verification seamless" + }, + "totpHelper": { + "message": "Bitwarden can store and fill 2-step verification codes. Copy and paste the key into this field." + }, + "totpHelperWithCapture": { + "message": "Bitwarden can store and fill 2-step verification codes. Select the camera icon to take a screenshot of this website's authenticator QR code, or copy and paste the key into this field." + }, "copyTOTP": { "message": "Copy Authenticator key (TOTP)" }, @@ -3619,6 +3628,12 @@ } } }, + "loginCredentials": { + "message": "Login credentials" + }, + "authenticatorKey": { + "message": "Authenticator key" + }, "cardDetails": { "message": "Card details" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index d7af9272eaa..80e4a7961f0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -16,11 +16,13 @@ import { CipherFormMode, CipherFormModule, DefaultCipherFormConfigService, + TotpCaptureService, } from "@bitwarden/vault"; import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; +import { BrowserTotpCaptureService } from "../../../services/browser-totp-capture.service"; import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component"; /** @@ -79,7 +81,10 @@ export type AddEditQueryParams = Partial>; selector: "app-add-edit-v2", templateUrl: "add-edit-v2.component.html", standalone: true, - providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }], + providers: [ + { provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }, + { provide: TotpCaptureService, useClass: BrowserTotpCaptureService }, + ], imports: [ CommonModule, SearchModule, diff --git a/apps/browser/src/vault/popup/services/browser-totp-capture.service.spec.ts b/apps/browser/src/vault/popup/services/browser-totp-capture.service.spec.ts new file mode 100644 index 00000000000..2c9afacffd7 --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-totp-capture.service.spec.ts @@ -0,0 +1,69 @@ +import { TestBed } from "@angular/core/testing"; +import qrcodeParser from "qrcode-parser"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; + +import { BrowserTotpCaptureService } from "./browser-totp-capture.service"; + +jest.mock("qrcode-parser", () => jest.fn()); + +const mockQrcodeParser = qrcodeParser as jest.Mock; + +describe("BrowserTotpCaptureService", () => { + let testBed: TestBed; + let service: BrowserTotpCaptureService; + let mockCaptureVisibleTab: jest.SpyInstance; + + const validTotpUrl = "otpauth://totp/label?secret=123"; + + beforeEach(() => { + mockCaptureVisibleTab = jest.spyOn(BrowserApi, "captureVisibleTab"); + mockCaptureVisibleTab.mockResolvedValue("screenshot"); + + testBed = TestBed.configureTestingModule({ + providers: [BrowserTotpCaptureService], + }); + service = testBed.inject(BrowserTotpCaptureService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should call captureVisibleTab and qrcodeParser when captureTotpSecret is called", async () => { + mockQrcodeParser.mockResolvedValue({ toString: () => validTotpUrl }); + + await service.captureTotpSecret(); + + expect(mockCaptureVisibleTab).toHaveBeenCalled(); + expect(mockQrcodeParser).toHaveBeenCalledWith("screenshot"); + }); + + it("should return the totpUrl when captureTotpSecret is called", async () => { + mockQrcodeParser.mockResolvedValue({ toString: () => validTotpUrl }); + + const result = await service.captureTotpSecret(); + + expect(result).toEqual(validTotpUrl); + }); + + it("should return null when the URL is not the otpauth: protocol", async () => { + mockQrcodeParser.mockResolvedValue({ toString: () => "https://example.com" }); + + const result = await service.captureTotpSecret(); + + expect(result).toBeNull(); + }); + + it("should return null when the URL is missing the secret parameter", async () => { + mockQrcodeParser.mockResolvedValue({ toString: () => "otpauth://totp/label" }); + + const result = await service.captureTotpSecret(); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/browser/src/vault/popup/services/browser-totp-capture.service.ts b/apps/browser/src/vault/popup/services/browser-totp-capture.service.ts new file mode 100644 index 00000000000..3f8ba61ed36 --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-totp-capture.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from "@angular/core"; +import qrcodeParser from "qrcode-parser"; + +import { TotpCaptureService } from "@bitwarden/vault"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; + +/** + * Implementation of TotpCaptureService for the browser which captures the + * TOTP secret from the currently visible tab. + */ +@Injectable() +export class BrowserTotpCaptureService implements TotpCaptureService { + async captureTotpSecret() { + const screenshot = await BrowserApi.captureVisibleTab(); + const data = await qrcodeParser(screenshot); + const url = new URL(data.toString()); + if (url.protocol === "otpauth:" && url.searchParams.has("secret")) { + return data.toString(); + } + return null; + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 73396c39c16..ba4e6b7174a 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -42,6 +42,12 @@ "cardholderName": { "message": "Cardholder name" }, + "loginCredentials": { + "message": "Login credentials" + }, + "authenticatorKey": { + "message": "Authenticator key" + }, "number": { "message": "Number" }, @@ -138,6 +144,15 @@ "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, + "totpHelperTitle": { + "message": "Make 2-step verification seamless" + }, + "totpHelper": { + "message": "Bitwarden can store and fill 2-step verification codes. Copy and paste the key into this field." + }, + "totpHelperWithCapture": { + "message": "Bitwarden can store and fill 2-step verification codes. Select the camera icon to take a screenshot of this website's authenticator QR code, or copy and paste the key into this field." + }, "folder": { "message": "Folder" }, diff --git a/libs/components/src/form-field/password-input-toggle.directive.ts b/libs/components/src/form-field/password-input-toggle.directive.ts index 9c5047351fd..5e98db45393 100644 --- a/libs/components/src/form-field/password-input-toggle.directive.ts +++ b/libs/components/src/form-field/password-input-toggle.directive.ts @@ -56,7 +56,9 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan } ngAfterContentInit(): void { - this.toggled = this.formField.input.type !== "password"; + if (this.formField.input?.type) { + this.toggled = this.formField.input.type !== "password"; + } this.button.icon = this.icon; } diff --git a/libs/vault/src/cipher-form/abstractions/cipher-form-generation.service.ts b/libs/vault/src/cipher-form/abstractions/cipher-form-generation.service.ts new file mode 100644 index 00000000000..6ddd4473445 --- /dev/null +++ b/libs/vault/src/cipher-form/abstractions/cipher-form-generation.service.ts @@ -0,0 +1,20 @@ +/** + * Service responsible for generating random passwords and usernames. + */ +export abstract class CipherFormGenerationService { + /** + * Generates a random password. Called when the user clicks the "Generate Password" button in the UI. + */ + abstract generatePassword(): Promise; + + /** + * Generates a random username. Called when the user clicks the "Generate Username" button in the UI. + */ + abstract generateUsername(): Promise; + + /** + * Generates an initial password for a new cipher. This should not involve any user interaction as it will + * be used to pre-fill the password field in the UI for new Login ciphers. + */ + abstract generateInitialPassword(): Promise; +} diff --git a/libs/vault/src/cipher-form/abstractions/totp-capture.service.ts b/libs/vault/src/cipher-form/abstractions/totp-capture.service.ts new file mode 100644 index 00000000000..d6d95565869 --- /dev/null +++ b/libs/vault/src/cipher-form/abstractions/totp-capture.service.ts @@ -0,0 +1,9 @@ +/** + * Service to capture TOTP secret from a client application. + */ +export abstract class TotpCaptureService { + /** + * Captures a TOTP secret and returns it as a string. Returns null if no TOTP secret was found. + */ + abstract captureTotpSecret(): Promise; +} diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts index 9655b70bbbd..eb21f3561da 100644 --- a/libs/vault/src/cipher-form/cipher-form-container.ts +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -6,6 +6,7 @@ import { CardDetailsSectionComponent } from "./components/card-details-section/c import { CustomFieldsComponent } from "./components/custom-fields/custom-fields.component"; import { IdentitySectionComponent } from "./components/identity/identity.component"; import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component"; +import { LoginDetailsSectionComponent } from "./components/login-details-section/login-details-section.component"; /** * The complete form for a cipher. Includes all the sub-forms from their respective section components. @@ -14,6 +15,7 @@ import { ItemDetailsSectionComponent } from "./components/item-details/item-deta export type CipherForm = { itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"]; additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"]; + loginDetails?: LoginDetailsSectionComponent["loginDetailsForm"]; cardDetails?: CardDetailsSectionComponent["cardDetailsForm"]; identityDetails?: IdentitySectionComponent["identityForm"]; customFields?: CustomFieldsComponent["customFieldsForm"]; diff --git a/libs/vault/src/cipher-form/cipher-form.module.ts b/libs/vault/src/cipher-form/cipher-form.module.ts index 3552e050c0e..b2a82d6766f 100644 --- a/libs/vault/src/cipher-form/cipher-form.module.ts +++ b/libs/vault/src/cipher-form/cipher-form.module.ts @@ -1,7 +1,9 @@ import { NgModule } from "@angular/core"; +import { CipherFormGenerationService } from "./abstractions/cipher-form-generation.service"; import { CipherFormService } from "./abstractions/cipher-form.service"; import { CipherFormComponent } from "./components/cipher-form.component"; +import { DefaultCipherFormGenerationService } from "./services/default-cipher-form-generation.service"; import { DefaultCipherFormService } from "./services/default-cipher-form.service"; @NgModule({ @@ -11,6 +13,10 @@ import { DefaultCipherFormService } from "./services/default-cipher-form.service provide: CipherFormService, useClass: DefaultCipherFormService, }, + { + provide: CipherFormGenerationService, + useClass: DefaultCipherFormGenerationService, + }, ], exports: [CipherFormComponent], }) diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index 67011b5a478..e90c46fa0ad 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -9,17 +9,24 @@ import { } from "@storybook/angular"; import { BehaviorSubject } from "rxjs"; +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components"; -import { CipherFormConfig, PasswordRepromptService } from "@bitwarden/vault"; +import { + CipherFormConfig, + CipherFormGenerationService, + PasswordRepromptService, +} from "@bitwarden/vault"; import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests"; import { CipherFormService } from "./abstractions/cipher-form.service"; +import { TotpCaptureService } from "./abstractions/totp-capture.service"; import { CipherFormModule } from "./cipher-form.module"; import { CipherFormComponent } from "./components/cipher-form.component"; @@ -73,6 +80,17 @@ const defaultConfig: CipherFormConfig = { collectionIds: ["col1"], favorite: false, notes: "Example notes", + viewPassword: true, + login: Object.assign(new LoginView(), { + username: "testuser", + password: "testpassword", + fido2Credentials: [ + { + creationDate: new Date(), + }, + ], + totp: "123456", + }) as LoginView, } as unknown as Cipher, }; @@ -113,6 +131,26 @@ export default { enabled$: new BehaviorSubject(true), }, }, + { + provide: CipherFormGenerationService, + useValue: { + generateInitialPassword: () => Promise.resolve("initial-password"), + generatePassword: () => Promise.resolve("random-password"), + generateUsername: () => Promise.resolve("random-username"), + }, + }, + { + provide: TotpCaptureService, + useValue: { + captureTotpSecret: () => Promise.resolve("some-value"), + }, + }, + { + provide: AuditService, + useValue: { + passwordLeaked: () => Promise.resolve(0), + }, + }, ], }), componentWrapperDecorator( diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.html b/libs/vault/src/cipher-form/components/cipher-form.component.html index 669f3c8b963..60dbd91fc36 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.html +++ b/libs/vault/src/cipher-form/components/cipher-form.component.html @@ -6,6 +6,10 @@ [originalCipherView]="originalCipherView" > + + + +

+ {{ "loginCredentials" | i18n }} +

+
+ + + + {{ "username" | i18n }} + + + + + + {{ "password" | i18n }} + + + + + + + + {{ "typePasskey" | i18n }} + + + + + + + {{ "authenticatorKey" | i18n }} + + +

{{ (canCaptureTotp ? "totpHelperWithCapture" : "totpHelper") | i18n }}

+
+
+ + + +
+
+ diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts new file mode 100644 index 00000000000..610c451c95e --- /dev/null +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts @@ -0,0 +1,503 @@ +import { DatePipe } from "@angular/common"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; +import { ToastService } from "@bitwarden/components"; + +import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service"; +import { TotpCaptureService } from "../../abstractions/totp-capture.service"; +import { CipherFormContainer } from "../../cipher-form-container"; + +import { LoginDetailsSectionComponent } from "./login-details-section.component"; + +describe("LoginDetailsSectionComponent", () => { + let component: LoginDetailsSectionComponent; + let fixture: ComponentFixture; + + let cipherFormContainer: MockProxy; + let generationService: MockProxy; + let auditService: MockProxy; + let toastService: MockProxy; + let totpCaptureService: MockProxy; + let i18nService: MockProxy; + + beforeEach(async () => { + cipherFormContainer = mock(); + + generationService = mock(); + auditService = mock(); + toastService = mock(); + totpCaptureService = mock(); + i18nService = mock(); + + await TestBed.configureTestingModule({ + imports: [LoginDetailsSectionComponent], + providers: [ + { provide: CipherFormContainer, useValue: cipherFormContainer }, + { provide: CipherFormGenerationService, useValue: generationService }, + { provide: AuditService, useValue: auditService }, + { provide: ToastService, useValue: toastService }, + { provide: TotpCaptureService, useValue: totpCaptureService }, + { provide: I18nService, useValue: i18nService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(LoginDetailsSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("registers 'loginDetailsForm' form with CipherFormContainer", () => { + expect(cipherFormContainer.registerChildForm).toHaveBeenCalledWith( + "loginDetails", + component.loginDetailsForm, + ); + }); + + it("patches 'loginDetailsForm' changes to CipherFormContainer", () => { + component.loginDetailsForm.patchValue({ + username: "new-username", + password: "secret-password", + totp: "123456", + }); + + expect(cipherFormContainer.patchCipher).toHaveBeenLastCalledWith({ + login: expect.objectContaining({ + username: "new-username", + password: "secret-password", + totp: "123456", + }), + }); + }); + + it("disables 'loginDetailsForm' when in partial-edit mode", async () => { + cipherFormContainer.config.mode = "partial-edit"; + + await component.ngOnInit(); + + expect(component.loginDetailsForm.disabled).toBe(true); + }); + + it("initializes 'loginDetailsForm' with original cipher view values", async () => { + (cipherFormContainer.originalCipherView as CipherView) = { + viewPassword: true, + login: { + password: "original-password", + username: "original-username", + totp: "original-totp", + } as LoginView, + } as CipherView; + + await component.ngOnInit(); + + expect(component.loginDetailsForm.value).toEqual({ + username: "original-username", + password: "original-password", + totp: "original-totp", + }); + }); + + it("initializes 'loginDetailsForm' with generated password when creating a new cipher", async () => { + generationService.generateInitialPassword.mockResolvedValue("generated-password"); + + await component.ngOnInit(); + + expect(component.loginDetailsForm.controls.password.value).toBe("generated-password"); + }); + + describe("viewHiddenFields", () => { + beforeEach(() => { + (cipherFormContainer.originalCipherView as CipherView) = { + viewPassword: false, + login: { + password: "original-password", + } as LoginView, + } as CipherView; + }); + + it("returns value of originalCipher.viewPassword", () => { + (cipherFormContainer.originalCipherView as CipherView).viewPassword = true; + + expect(component.viewHiddenFields).toBe(true); + + (cipherFormContainer.originalCipherView as CipherView).viewPassword = false; + + expect(component.viewHiddenFields).toBe(false); + }); + + it("returns true when creating a new cipher", () => { + (cipherFormContainer.originalCipherView as CipherView) = null; + + expect(component.viewHiddenFields).toBe(true); + }); + + it("disables the password and totp fields when passwords are hidden for the original cipher", async () => { + await component.ngOnInit(); + + expect(component.loginDetailsForm.controls.password.disabled).toBe(true); + expect(component.loginDetailsForm.controls.totp.disabled).toBe(true); + }); + + it("still provides original values for hidden fields when passwords are hidden", async () => { + await component.ngOnInit(); + + component.loginDetailsForm.patchValue({ + username: "new-username", + }); + + expect(cipherFormContainer.patchCipher).toHaveBeenLastCalledWith({ + login: expect.objectContaining({ + username: "new-username", + password: "original-password", + }), + }); + }); + }); + + describe("username", () => { + const getGenerateUsernameBtn = () => + fixture.nativeElement.querySelector("button[data-testid='generate-username-button']"); + + it("should show generate username button when editable", () => { + expect(getGenerateUsernameBtn()).not.toBeNull(); + }); + + it("should hide generate username button when not editable", fakeAsync(() => { + component.loginDetailsForm.controls.username.disable(); + fixture.detectChanges(); + expect(getGenerateUsernameBtn()).toBeNull(); + })); + + it("should generate a username when the generate username button is clicked", fakeAsync(() => { + generationService.generateUsername.mockResolvedValue("generated-username"); + + getGenerateUsernameBtn().click(); + + tick(); + + expect(component.loginDetailsForm.controls.username.value).toEqual("generated-username"); + })); + + it("should not replace an existing username if generation returns null", fakeAsync(() => { + generationService.generateUsername.mockResolvedValue(null); + + getGenerateUsernameBtn().click(); + + tick(); + + const usernameSpy = jest.spyOn(component.loginDetailsForm.controls.username, "patchValue"); + + expect(usernameSpy).not.toHaveBeenCalled(); + })); + }); + + describe("password", () => { + const getGeneratePasswordBtn = () => + fixture.nativeElement.querySelector("button[data-testid='generate-password-button']"); + + const getCheckPasswordBtn = () => + fixture.nativeElement.querySelector("button[data-testid='check-password-button']"); + + const getTogglePasswordVisibilityBtn = () => + fixture.nativeElement.querySelector("button[data-testid='toggle-password-visibility']"); + + it("should show the password visibility toggle button based on viewHiddenFields", () => { + jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(true); + fixture.detectChanges(); + expect(getTogglePasswordVisibilityBtn()).not.toBeNull(); + + jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(false); + fixture.detectChanges(); + expect(getTogglePasswordVisibilityBtn()).toBeNull(); + }); + + describe("password generation", () => { + it("should show generate password button when editable", () => { + expect(getGeneratePasswordBtn()).not.toBeNull(); + }); + + it("should hide generate password button when not editable", fakeAsync(() => { + component.loginDetailsForm.controls.password.disable(); + fixture.detectChanges(); + + expect(getGeneratePasswordBtn()).toBeNull(); + })); + + it("should generate a password when the generate password button is clicked", fakeAsync(() => { + generationService.generatePassword.mockResolvedValue("generated-password"); + + getGeneratePasswordBtn().click(); + + tick(); + + expect(component.loginDetailsForm.controls.password.value).toEqual("generated-password"); + })); + + it("should not replace an existing password if generation returns null", fakeAsync(() => { + generationService.generatePassword.mockResolvedValue(null); + + getGeneratePasswordBtn().click(); + + tick(); + + const passwordSpy = jest.spyOn(component.loginDetailsForm.controls.password, "patchValue"); + + expect(passwordSpy).not.toHaveBeenCalled(); + })); + }); + + describe("password checking", () => { + it("should show the password check button when a password is present and editable", () => { + component.loginDetailsForm.controls.password.setValue("password"); + fixture.detectChanges(); + expect(getCheckPasswordBtn()).not.toBeNull(); + }); + + it("should hide the password check button when the password is missing", () => { + component.loginDetailsForm.controls.password.setValue(null); + fixture.detectChanges(); + expect(getCheckPasswordBtn()).toBeNull(); + }); + + it("should hide the password check button when the password is not editable", () => { + component.loginDetailsForm.controls.password.disable(); + fixture.detectChanges(); + expect(getCheckPasswordBtn()).toBeNull(); + }); + + it("should call checkPassword when the password check button is clicked", fakeAsync(() => { + component.checkPassword = jest.fn(); + component.loginDetailsForm.controls.password.setValue("password"); + + fixture.detectChanges(); + + getCheckPasswordBtn().click(); + + tick(); + + expect(component.checkPassword).toHaveBeenCalled(); + })); + + describe("checkPassword", () => { + it("should not call the audit service when the password is empty", async () => { + component.loginDetailsForm.controls.password.setValue(null); + + await component.checkPassword(); + + expect(auditService.passwordLeaked).not.toHaveBeenCalled(); + expect(toastService.showToast).not.toHaveBeenCalled(); + }); + + it("should show a warning toast when the password has been exposed in a data breach", async () => { + component.loginDetailsForm.controls.password.setValue("password"); + auditService.passwordLeaked.mockResolvedValue(1); + i18nService.t.mockReturnValue("passwordExposedMsg"); + + await component.checkPassword(); + + expect(auditService.passwordLeaked).toHaveBeenCalledWith("password"); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "warning", + title: null, + message: "passwordExposedMsg", + }); + expect(i18nService.t).toHaveBeenCalledWith("passwordExposed", "1"); + }); + + it("should show a success toast when the password has not been exposed in a data breach", async () => { + component.loginDetailsForm.controls.password.setValue("password"); + auditService.passwordLeaked.mockResolvedValue(0); + i18nService.t.mockReturnValue("passwordSafeMsg"); + + await component.checkPassword(); + + expect(auditService.passwordLeaked).toHaveBeenCalledWith("password"); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: "passwordSafeMsg", + }); + expect(i18nService.t).toHaveBeenCalledWith("passwordSafe"); + }); + }); + }); + }); + + describe("totp", () => { + const getToggleTotpVisibilityBtn = () => + fixture.nativeElement.querySelector("button[data-testid='toggle-totp-visibility']"); + + const getCaptureTotpBtn = () => + fixture.nativeElement.querySelector("button[data-testid='capture-totp-button']"); + + it("should show the totp visibility toggle button based on viewHiddenFields", () => { + jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(true); + fixture.detectChanges(); + expect(getToggleTotpVisibilityBtn()).not.toBeNull(); + + jest.spyOn(component, "viewHiddenFields", "get").mockReturnValue(false); + fixture.detectChanges(); + expect(getToggleTotpVisibilityBtn()).toBeNull(); + }); + + it("should show the totp capture button based on canCaptureTotp", () => { + jest.spyOn(component, "canCaptureTotp", "get").mockReturnValue(true); + fixture.detectChanges(); + expect(getCaptureTotpBtn()).not.toBeNull(); + + jest.spyOn(component, "canCaptureTotp", "get").mockReturnValue(false); + fixture.detectChanges(); + expect(getCaptureTotpBtn()).toBeNull(); + }); + + it("should call captureTotp when the capture totp button is clicked", fakeAsync(() => { + component.captureTotp = jest.fn(); + fixture.detectChanges(); + + getCaptureTotpBtn().click(); + + tick(); + + expect(component.captureTotp).toHaveBeenCalled(); + })); + + describe("canCaptureTotp", () => { + it("should return true when totpCaptureService is present and totp is editable", () => { + component.loginDetailsForm.controls.totp.enable(); + expect(component.canCaptureTotp).toBe(true); + }); + + it("should return false when totpCaptureService is missing", () => { + (component as any).totpCaptureService = null; + expect(component.canCaptureTotp).toBe(false); + }); + + it("should return false when totp is disabled", () => { + component.loginDetailsForm.controls.totp.disable(); + expect(component.canCaptureTotp).toBe(false); + }); + }); + + describe("captureTotp", () => { + it("should not call totpCaptureService.captureTotpSecret when canCaptureTotp is false", async () => { + jest.spyOn(component, "canCaptureTotp", "get").mockReturnValue(false); + await component.captureTotp(); + expect(totpCaptureService.captureTotpSecret).not.toHaveBeenCalled(); + }); + + it("should patch the totp value when totpCaptureService.captureTotpSecret returns a value", async () => { + jest.spyOn(component, "canCaptureTotp", "get").mockReturnValue(true); + totpCaptureService.captureTotpSecret.mockResolvedValue("some-totp-secret"); + i18nService.t.mockReturnValue("totpCaptureSuccessMsg"); + + await component.captureTotp(); + + expect(component.loginDetailsForm.controls.totp.value).toBe("some-totp-secret"); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: "totpCaptureSuccessMsg", + }); + }); + + it("should show an error toast when totpCaptureService.captureTotpSecret throws", async () => { + jest.spyOn(component, "canCaptureTotp", "get").mockReturnValue(true); + totpCaptureService.captureTotpSecret.mockRejectedValue(new Error()); + i18nService.t.mockReturnValueOnce("errorOccurredMsg"); + i18nService.t.mockReturnValueOnce("totpCaptureErrorMsg"); + + const totpSpy = jest.spyOn(component.loginDetailsForm.controls.totp, "patchValue"); + + await component.captureTotp(); + + expect(totpSpy).not.toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "errorOccurredMsg", + message: "totpCaptureErrorMsg", + }); + }); + }); + }); + + describe("passkeys", () => { + const passkeyDate = new Date(); + const dateSpy = jest + .spyOn(DatePipe.prototype, "transform") + .mockReturnValue(passkeyDate.toString()); + + const getRemovePasskeyBtn = () => + fixture.nativeElement.querySelector("button[data-testid='remove-passkey-button']"); + + const getPasskeyField = () => + fixture.nativeElement.querySelector("input[data-testid='passkey-field']"); + + beforeEach(() => { + (cipherFormContainer.originalCipherView as CipherView) = { + login: Object.assign(new LoginView(), { + fido2Credentials: [{ creationDate: passkeyDate } as Fido2CredentialView], + }), + } as CipherView; + + fixture = TestBed.createComponent(LoginDetailsSectionComponent); + component = fixture.componentInstance; + }); + + it("renders the passkey field when available", () => { + i18nService.t.mockReturnValue("Created"); + + fixture.detectChanges(); + + const passkeyField = getPasskeyField(); + + expect(passkeyField).not.toBeNull(); + expect(dateSpy).toHaveBeenLastCalledWith(passkeyDate, "short"); + expect(passkeyField.value).toBe("Created " + passkeyDate.toString()); + }); + + it("renders the passkey remove button when editable", () => { + fixture.detectChanges(); + + expect(getRemovePasskeyBtn).not.toBeNull(); + }); + + it("does not render the passkey remove button when not editable", () => { + cipherFormContainer.config.mode = "partial-edit"; + + fixture.detectChanges(); + + expect(getRemovePasskeyBtn()).toBeNull(); + }); + + it("hides the passkey field when missing a passkey", () => { + (cipherFormContainer.originalCipherView as CipherView).login.fido2Credentials = []; + + fixture.detectChanges(); + + expect(getPasskeyField()).toBeNull(); + }); + + it("should remove the passkey when the remove button is clicked", fakeAsync(() => { + fixture.detectChanges(); + + getRemovePasskeyBtn().click(); + + tick(); + + expect(cipherFormContainer.patchCipher).toHaveBeenLastCalledWith({ + login: expect.objectContaining({ + fido2Credentials: null, + }), + }); + })); + }); +}); diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts new file mode 100644 index 00000000000..e88e1c0a5fc --- /dev/null +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts @@ -0,0 +1,228 @@ +import { DatePipe, NgIf } from "@angular/common"; +import { Component, inject, OnInit, Optional } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; +import { + AsyncActionsModule, + CardComponent, + FormFieldModule, + IconButtonModule, + PopoverModule, + SectionComponent, + SectionHeaderComponent, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service"; +import { TotpCaptureService } from "../../abstractions/totp-capture.service"; +import { CipherFormContainer } from "../../cipher-form-container"; + +@Component({ + selector: "vault-login-details-section", + templateUrl: "./login-details-section.component.html", + standalone: true, + imports: [ + SectionComponent, + ReactiveFormsModule, + SectionHeaderComponent, + TypographyModule, + JslibModule, + CardComponent, + FormFieldModule, + IconButtonModule, + AsyncActionsModule, + NgIf, + PopoverModule, + ], +}) +export class LoginDetailsSectionComponent implements OnInit { + loginDetailsForm = this.formBuilder.group({ + username: [""], + password: [""], + totp: [""], + }); + + /** + * Whether the TOTP field can be captured from the current tab. Only available in the browser extension. + */ + get canCaptureTotp() { + return this.totpCaptureService != null && this.loginDetailsForm.controls.totp.enabled; + } + + private datePipe = inject(DatePipe); + + private loginView: LoginView; + + get hasPasskey(): boolean { + return this.loginView?.hasFido2Credentials; + } + + get fido2CredentialCreationDateValue(): string { + const dateCreated = this.i18nService.t("dateCreated"); + const creationDate = this.datePipe.transform( + this.loginView?.fido2Credentials?.[0]?.creationDate, + "short", + ); + return `${dateCreated} ${creationDate}`; + } + + get viewHiddenFields() { + if (this.cipherFormContainer.originalCipherView) { + return this.cipherFormContainer.originalCipherView.viewPassword; + } + return true; + } + + constructor( + private cipherFormContainer: CipherFormContainer, + private formBuilder: FormBuilder, + private i18nService: I18nService, + private generationService: CipherFormGenerationService, + private auditService: AuditService, + private toastService: ToastService, + @Optional() private totpCaptureService?: TotpCaptureService, + ) { + this.cipherFormContainer.registerChildForm("loginDetails", this.loginDetailsForm); + + this.loginDetailsForm.valueChanges + .pipe( + takeUntilDestroyed(), + // getRawValue() is used as fields can be disabled when passwords are hidden + map(() => this.loginDetailsForm.getRawValue()), + ) + .subscribe((value) => { + Object.assign(this.loginView, { + username: value.username, + password: value.password, + totp: value.totp, + } as LoginView); + + this.cipherFormContainer.patchCipher({ + login: this.loginView, + }); + }); + } + + async ngOnInit() { + this.loginView = new LoginView(); + if (this.cipherFormContainer.originalCipherView?.login) { + this.initFromExistingCipher(this.cipherFormContainer.originalCipherView.login); + } else { + await this.initNewCipher(); + } + + if (this.cipherFormContainer.config.mode === "partial-edit") { + this.loginDetailsForm.disable(); + } + } + + private initFromExistingCipher(existingLogin: LoginView) { + // Note: this.loginView will still contain references to the existing login's Uri and Fido2Credential arrays. + // We may need to deep clone these in the future. + Object.assign(this.loginView, existingLogin); + this.loginDetailsForm.patchValue({ + username: this.loginView.username, + password: this.loginView.password, + totp: this.loginView.totp, + }); + + if (!this.viewHiddenFields) { + this.loginDetailsForm.controls.password.disable(); + this.loginDetailsForm.controls.totp.disable(); + } + } + + private async initNewCipher() { + this.loginDetailsForm.controls.password.patchValue( + await this.generationService.generateInitialPassword(), + ); + } + + captureTotp = async () => { + if (!this.canCaptureTotp) { + return; + } + try { + const totp = await this.totpCaptureService.captureTotpSecret(); + if (totp) { + this.loginDetailsForm.controls.totp.patchValue(totp); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("totpCaptureSuccess"), + }); + } + } catch { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("totpCaptureError"), + }); + } + }; + + removePasskey = async () => { + // Fido2Credentials do not have a form control, so update directly + this.loginView.fido2Credentials = null; + this.cipherFormContainer.patchCipher({ + login: this.loginView, + }); + }; + + /** + * Generate a new password and update the form. + * TODO: Browser extension needs a means to cache the current form so values are not lost upon navigating to the generator. + */ + generatePassword = async () => { + const newPassword = await this.generationService.generatePassword(); + + if (newPassword) { + this.loginDetailsForm.controls.password.patchValue(newPassword); + } + }; + + /** + * Generate a new username and update the form. + * TODO: Browser extension needs a means to cache the current form so values are not lost upon navigating to the generator. + */ + generateUsername = async () => { + const newUsername = await this.generationService.generateUsername(); + if (newUsername) { + this.loginDetailsForm.controls.username.patchValue(newUsername); + } + }; + + /** + * Checks if the password has been exposed in a data breach using the AuditService. + */ + checkPassword = async () => { + const password = this.loginDetailsForm.controls.password.value; + + if (password == null || password === "") { + return; + } + + const matches = await this.auditService.passwordLeaked(password); + + if (matches > 0) { + this.toastService.showToast({ + variant: "warning", + title: null, + message: this.i18nService.t("passwordExposed", matches.toString()), + }); + } else { + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("passwordSafe"), + }); + } + }; +} diff --git a/libs/vault/src/cipher-form/index.ts b/libs/vault/src/cipher-form/index.ts index 4cc762ffb64..1d275029df1 100644 --- a/libs/vault/src/cipher-form/index.ts +++ b/libs/vault/src/cipher-form/index.ts @@ -5,4 +5,6 @@ export { CipherFormMode, OptionalInitialValues, } from "./abstractions/cipher-form-config.service"; +export { TotpCaptureService } from "./abstractions/totp-capture.service"; +export { CipherFormGenerationService } from "./abstractions/cipher-form-generation.service"; export { DefaultCipherFormConfigService } from "./services/default-cipher-form-config.service"; diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-generation.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form-generation.service.ts new file mode 100644 index 00000000000..181590e8418 --- /dev/null +++ b/libs/vault/src/cipher-form/services/default-cipher-form-generation.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@angular/core"; + +import { + PasswordGenerationServiceAbstraction, + UsernameGenerationServiceAbstraction, +} from "@bitwarden/generator-legacy"; + +import { CipherFormGenerationService } from "../abstractions/cipher-form-generation.service"; + +@Injectable() +export class DefaultCipherFormGenerationService implements CipherFormGenerationService { + constructor( + private passwordGenerationService: PasswordGenerationServiceAbstraction, + private usernameGenerationService: UsernameGenerationServiceAbstraction, + ) {} + + async generatePassword(): Promise { + const [options] = await this.passwordGenerationService.getOptions(); + return await this.passwordGenerationService.generatePassword(options); + } + + async generateUsername(): Promise { + const options = await this.usernameGenerationService.getOptions(); + return await this.usernameGenerationService.generateUsername(options); + } + + async generateInitialPassword(): Promise { + return await this.generatePassword(); + } +}