diff --git a/apps/web/src/app/auth/auth.module.ts b/apps/web/src/app/auth/auth.module.ts new file mode 100644 index 0000000000..49be17aa26 --- /dev/null +++ b/apps/web/src/app/auth/auth.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from "@angular/core"; + +import { CoreAuthModule } from "./core"; +import { SettingsModule } from "./settings/settings.module"; + +@NgModule({ + imports: [CoreAuthModule, SettingsModule], + declarations: [], + providers: [], + exports: [SettingsModule], +}) +export class AuthModule {} diff --git a/apps/web/src/app/auth/core/core.module.ts b/apps/web/src/app/auth/core/core.module.ts new file mode 100644 index 0000000000..e196b1c3d7 --- /dev/null +++ b/apps/web/src/app/auth/core/core.module.ts @@ -0,0 +1,15 @@ +import { NgModule, Optional, SkipSelf } from "@angular/core"; + +import { WebauthnLoginApiService } from "./services/webauthn-login/webauthn-login-api.service"; +import { WebauthnLoginService } from "./services/webauthn-login/webauthn-login.service"; + +@NgModule({ + providers: [WebauthnLoginService, WebauthnLoginApiService], +}) +export class CoreAuthModule { + constructor(@Optional() @SkipSelf() parentModule?: CoreAuthModule) { + if (parentModule) { + throw new Error("CoreAuthModule is already loaded. Import it in AuthModule only"); + } + } +} diff --git a/apps/web/src/app/auth/core/index.ts b/apps/web/src/app/auth/core/index.ts new file mode 100644 index 0000000000..3d2d739adf --- /dev/null +++ b/apps/web/src/app/auth/core/index.ts @@ -0,0 +1,2 @@ +export * from "./services"; +export * from "./core.module"; diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts new file mode 100644 index 0000000000..4ef20f4b97 --- /dev/null +++ b/apps/web/src/app/auth/core/services/index.ts @@ -0,0 +1 @@ +export * from "./webauthn-login"; diff --git a/apps/web/src/app/auth/core/services/webauthn-login/index.ts b/apps/web/src/app/auth/core/services/webauthn-login/index.ts new file mode 100644 index 0000000000..10dea636b8 --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn-login/index.ts @@ -0,0 +1 @@ +export * from "./webauthn-login.service"; diff --git a/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts b/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts new file mode 100644 index 0000000000..ffd0e6cf70 --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts @@ -0,0 +1,18 @@ +import { WebauthnLoginAttestationResponseRequest } from "./webauthn-login-attestation-response.request"; + +/** + * Request sent to the server to save a newly created webauthn login credential. + */ +export class SaveCredentialRequest { + /** The response recieved from the authenticator. This contains the public key */ + deviceResponse: WebauthnLoginAttestationResponseRequest; + + /** Nickname chosen by the user to identify this credential */ + name: string; + + /** + * Token required by the server to complete the creation. + * It contains encrypted information that the server needs to verify the credential. + */ + token: string; +} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts b/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts new file mode 100644 index 0000000000..4b33896290 --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts @@ -0,0 +1,27 @@ +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { WebauthnLoginAuthenticatorResponseRequest } from "./webauthn-login-authenticator-response.request"; + +/** + * The response recieved from an authentiator after a successful attestation. + * This request is used to save newly created webauthn login credentials to the server. + */ +export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthenticatorResponseRequest { + response: { + attestationObject: string; + clientDataJson: string; + }; + + constructor(credential: PublicKeyCredential) { + super(credential); + + if (!(credential.response instanceof AuthenticatorAttestationResponse)) { + throw new Error("Invalid authenticator response"); + } + + this.response = { + attestationObject: Utils.fromBufferToB64(credential.response.attestationObject), + clientDataJson: Utils.fromBufferToB64(credential.response.clientDataJSON), + }; + } +} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-authenticator-response.request.ts b/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-authenticator-response.request.ts new file mode 100644 index 0000000000..9e332ad538 --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-authenticator-response.request.ts @@ -0,0 +1,19 @@ +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +/** + * An abstract class that represents responses recieved from the webauthn authenticator. + * It contains data that is commonly returned during different types of authenticator interactions. + */ +export abstract class WebauthnLoginAuthenticatorResponseRequest { + id: string; + rawId: string; + type: string; + extensions: Record; + + constructor(credential: PublicKeyCredential) { + this.id = credential.id; + this.rawId = Utils.fromBufferToB64(credential.rawId); + this.type = credential.type; + this.extensions = {}; // Extensions are handled client-side + } +} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential-create-options.response.ts b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential-create-options.response.ts new file mode 100644 index 0000000000..ce58820772 --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential-create-options.response.ts @@ -0,0 +1,22 @@ +import { ChallengeResponse } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +/** + * Options provided by the server to be used during attestation (i.e. creation of a new webauthn credential) + */ +export class WebauthnLoginCredentialCreateOptionsResponse extends BaseResponse { + /** Options to be provided to the webauthn authenticator */ + options: ChallengeResponse; + + /** + * Contains an encrypted version of the {@link options}. + * Used by the server to validate the attestation response of newly created credentials. + */ + token: string; + + constructor(response: unknown) { + super(response); + this.options = new ChallengeResponse(this.getResponseProperty("options")); + this.token = this.getResponseProperty("token"); + } +} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts new file mode 100644 index 0000000000..7a7f5199e7 --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts @@ -0,0 +1,17 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +/** + * A webauthn login credential recieved from the server. + */ +export class WebauthnLoginCredentialResponse extends BaseResponse { + id: string; + name: string; + prfSupport: boolean; + + constructor(response: unknown) { + super(response); + this.id = this.getResponseProperty("id"); + this.name = this.getResponseProperty("name"); + this.prfSupport = this.getResponseProperty("prfSupport"); + } +} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts new file mode 100644 index 0000000000..33e1aea369 --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { Verification } from "@bitwarden/common/types/verification"; + +import { SaveCredentialRequest } from "./request/save-credential.request"; +import { WebauthnLoginCredentialCreateOptionsResponse } from "./response/webauthn-login-credential-create-options.response"; +import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response"; + +@Injectable() +export class WebauthnLoginApiService { + constructor( + private apiService: ApiService, + private userVerificationService: UserVerificationService + ) {} + + async getCredentialCreateOptions( + verification: Verification + ): Promise { + const request = await this.userVerificationService.buildRequest(verification); + const response = await this.apiService.send("POST", "/webauthn/options", request, true, true); + return new WebauthnLoginCredentialCreateOptionsResponse(response); + } + + async saveCredential(request: SaveCredentialRequest): Promise { + await this.apiService.send("POST", "/webauthn", request, true, true); + return true; + } + + getCredentials(): Promise> { + return this.apiService.send("GET", "/webauthn", null, true, true); + } + + async deleteCredential(credentialId: string, verification: Verification): Promise { + const request = await this.userVerificationService.buildRequest(verification); + await this.apiService.send("POST", `/webauthn/${credentialId}/delete`, request, true, true); + } +} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.spec.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.spec.ts new file mode 100644 index 0000000000..070513f19e --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.spec.ts @@ -0,0 +1,63 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; + +import { WebauthnLoginApiService } from "./webauthn-login-api.service"; +import { WebauthnLoginService } from "./webauthn-login.service"; + +describe("WebauthnService", () => { + let apiService!: MockProxy; + let credentials: MockProxy; + let webauthnService!: WebauthnLoginService; + + beforeAll(() => { + // Polyfill missing class + window.PublicKeyCredential = class {} as any; + window.AuthenticatorAttestationResponse = class {} as any; + apiService = mock(); + credentials = mock(); + webauthnService = new WebauthnLoginService(apiService, credentials); + }); + + describe("createCredential", () => { + it("should return undefined when navigator.credentials throws", async () => { + credentials.create.mockRejectedValue(new Error("Mocked error")); + const options = createCredentialCreateOptions(); + + const result = await webauthnService.createCredential(options); + + expect(result).toBeUndefined(); + }); + + it("should return credential when navigator.credentials does not throw", async () => { + const credential = createDeviceResponse(); + credentials.create.mockResolvedValue(credential as PublicKeyCredential); + const options = createCredentialCreateOptions(); + + const result = await webauthnService.createCredential(options); + + expect(result).toBe(credential); + }); + }); +}); + +function createCredentialCreateOptions(): CredentialCreateOptionsView { + return new CredentialCreateOptionsView(Symbol() as any, Symbol() as any); +} + +function createDeviceResponse(): PublicKeyCredential { + const credential = { + id: "dGVzdA==", + rawId: new Uint8Array([0x74, 0x65, 0x73, 0x74]), + type: "public-key", + response: { + attestationObject: new Uint8Array([0, 0, 0]), + clientDataJSON: "eyJ0ZXN0IjoidGVzdCJ9", + }, + } as any; + + Object.setPrototypeOf(credential, PublicKeyCredential.prototype); + Object.setPrototypeOf(credential.response, AuthenticatorAttestationResponse.prototype); + + return credential; +} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.ts new file mode 100644 index 0000000000..760214961a --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Optional } from "@angular/core"; +import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Verification } from "@bitwarden/common/types/verification"; + +import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; +import { WebauthnCredentialView } from "../../views/webauth-credential.view"; + +import { SaveCredentialRequest } from "./request/save-credential.request"; +import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request"; +import { WebauthnLoginApiService } from "./webauthn-login-api.service"; + +@Injectable() +export class WebauthnLoginService { + private navigatorCredentials: CredentialsContainer; + private _refresh$ = new BehaviorSubject(undefined); + private _loading$ = new BehaviorSubject(true); + private readonly credentials$ = this._refresh$.pipe( + tap(() => this._loading$.next(true)), + switchMap(() => this.fetchCredentials$()), + tap(() => this._loading$.next(false)), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + readonly loading$ = this._loading$.asObservable(); + + constructor( + private apiService: WebauthnLoginApiService, + @Optional() navigatorCredentials?: CredentialsContainer, + @Optional() private logService?: LogService + ) { + // Default parameters don't work when used with Angular DI + this.navigatorCredentials = navigatorCredentials ?? navigator.credentials; + } + + async getCredentialCreateOptions( + verification: Verification + ): Promise { + const response = await this.apiService.getCredentialCreateOptions(verification); + return new CredentialCreateOptionsView(response.options, response.token); + } + + async createCredential( + credentialOptions: CredentialCreateOptionsView + ): Promise { + const nativeOptions: CredentialCreationOptions = { + publicKey: credentialOptions.options, + }; + + try { + const response = await this.navigatorCredentials.create(nativeOptions); + if (!(response instanceof PublicKeyCredential)) { + return undefined; + } + return response; + } catch (error) { + this.logService?.error(error); + return undefined; + } + } + + async saveCredential( + credentialOptions: CredentialCreateOptionsView, + deviceResponse: PublicKeyCredential, + name: string + ) { + const request = new SaveCredentialRequest(); + request.deviceResponse = new WebauthnLoginAttestationResponseRequest(deviceResponse); + request.token = credentialOptions.token; + request.name = name; + await this.apiService.saveCredential(request); + this.refresh(); + } + + /** + * List of webauthn credentials saved on the server. + * + * **Note:** + * - Subscribing might trigger a network request if the credentials haven't been fetched yet. + * - The observable is shared and will not create unnecessary duplicate requests. + * - The observable will automatically re-fetch if the user adds or removes a credential. + * - The observable is lazy and will only fetch credentials when subscribed to. + * - Don't subscribe to this in the constructor of a long-running service, as it will keep the observable alive. + */ + getCredentials$(): Observable { + return this.credentials$; + } + + getCredential$(credentialId: string): Observable { + return this.credentials$.pipe( + map((credentials) => credentials.find((c) => c.id === credentialId)), + filter((c) => c !== undefined) + ); + } + + async deleteCredential(credentialId: string, verification: Verification): Promise { + await this.apiService.deleteCredential(credentialId, verification); + this.refresh(); + } + + private fetchCredentials$(): Observable { + return from(this.apiService.getCredentials()).pipe(map((response) => response.data)); + } + + private refresh() { + this._refresh$.next(); + } +} diff --git a/apps/web/src/app/auth/core/views/credential-create-options.view.ts b/apps/web/src/app/auth/core/views/credential-create-options.view.ts new file mode 100644 index 0000000000..29efdef5ee --- /dev/null +++ b/apps/web/src/app/auth/core/views/credential-create-options.view.ts @@ -0,0 +1,5 @@ +import { ChallengeResponse } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; + +export class CredentialCreateOptionsView { + constructor(readonly options: ChallengeResponse, readonly token: string) {} +} diff --git a/apps/web/src/app/auth/core/views/webauth-credential.view.ts b/apps/web/src/app/auth/core/views/webauth-credential.view.ts new file mode 100644 index 0000000000..ff5a9c692d --- /dev/null +++ b/apps/web/src/app/auth/core/views/webauth-credential.view.ts @@ -0,0 +1,5 @@ +export class WebauthnCredentialView { + id: string; + name: string; + prfSupport: boolean; +} diff --git a/apps/web/src/app/auth/index.ts b/apps/web/src/app/auth/index.ts new file mode 100644 index 0000000000..fb09223bd9 --- /dev/null +++ b/apps/web/src/app/auth/index.ts @@ -0,0 +1,2 @@ +export * from "./auth.module"; +export * from "./core"; diff --git a/apps/web/src/app/auth/settings/change-password.component.html b/apps/web/src/app/auth/settings/change-password.component.html index b3ad78168d..37a4ad5b59 100644 --- a/apps/web/src/app/auth/settings/change-password.component.html +++ b/apps/web/src/app/auth/settings/change-password.component.html @@ -6,7 +6,14 @@ -
+
@@ -118,3 +125,7 @@ {{ "changeMasterPassword" | i18n }} + + diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index 9ed8227316..958582eb0a 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -1,6 +1,6 @@ import { Component } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Observable } from "rxjs"; import { ChangePasswordComponent as BaseChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -11,12 +11,13 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; -import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { EmergencyAccessStatusType } from "@bitwarden/common/auth/enums/emergency-access-status-type"; import { EmergencyAccessUpdateRequest } from "@bitwarden/common/auth/models/request/emergency-access-update.request"; import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UpdateKeyRequest } from "@bitwarden/common/models/request/update-key.request"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -50,6 +51,8 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { checkForBreaches = true; characterMinimumMessage = ""; + protected showWebauthnLoginSettings$: Observable; + constructor( i18nService: I18nService, cryptoService: CryptoService, @@ -65,13 +68,13 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { private apiService: ApiService, private sendService: SendService, private organizationService: OrganizationService, - private keyConnectorService: KeyConnectorService, private router: Router, private organizationApiService: OrganizationApiServiceAbstraction, private organizationUserService: OrganizationUserService, dialogService: DialogService, private userVerificationService: UserVerificationService, - private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction + private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + private configService: ConfigServiceAbstraction ) { super( i18nService, @@ -86,6 +89,10 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { } async ngOnInit() { + this.showWebauthnLoginSettings$ = this.configService.getFeatureFlag$( + FeatureFlag.PasswordlessLogin + ); + if (!(await this.userVerificationService.hasMasterPassword())) { this.router.navigate(["/settings/security/two-factor"]); } diff --git a/apps/web/src/app/auth/settings/settings.module.ts b/apps/web/src/app/auth/settings/settings.module.ts new file mode 100644 index 0000000000..282524d07e --- /dev/null +++ b/apps/web/src/app/auth/settings/settings.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from "@angular/core"; + +import { PasswordCalloutComponent } from "@bitwarden/auth"; + +import { SharedModule } from "../../shared"; + +import { ChangePasswordComponent } from "./change-password.component"; +import { WebauthnLoginSettingsModule } from "./webauthn-login-settings"; + +@NgModule({ + imports: [SharedModule, WebauthnLoginSettingsModule, PasswordCalloutComponent], + declarations: [ChangePasswordComponent], + providers: [], + exports: [WebauthnLoginSettingsModule, ChangePasswordComponent], +}) +export class SettingsModule {} diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html new file mode 100644 index 0000000000..57a2c545ca --- /dev/null +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html @@ -0,0 +1,70 @@ +
+ + {{ "loginWithPasskey" | i18n }} + {{ "newPasskey" | i18n }} + + + +

+ {{ "passkeyEnterMasterPassword" | i18n }} +

+ + {{ "masterPassword" | i18n }} + + + {{ "confirmIdentity" | i18n }} + +
+ +
+ +

{{ "creatingPasskeyLoading" | i18n }}

+

{{ "creatingPasskeyLoadingInfo" | i18n }}

+
+ +
+ +

{{ "errorCreatingPasskey" | i18n }}

+

{{ "errorCreatingPasskeyInfo" | i18n }}

+
+ +
+

{{ "passkeySuccessfullyCreated" | i18n }}

+

+ {{ "customPasskeyNameInfo" | i18n }} +

+ + {{ "customName" | i18n }} + + {{ + "charactersCurrentAndMaximum" + | i18n : formGroup.value.credentialNaming.name.length : NameMaxCharacters + }} + +
+
+ + + + +
+
diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts new file mode 100644 index 0000000000..5c93d6f25e --- /dev/null +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts @@ -0,0 +1,178 @@ +import { DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { firstValueFrom, map, Observable } from "rxjs"; + +import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; + +import { WebauthnLoginService } from "../../../core"; +import { CredentialCreateOptionsView } from "../../../core/views/credential-create-options.view"; + +import { CreatePasskeyFailedIcon } from "./create-passkey-failed.icon"; +import { CreatePasskeyIcon } from "./create-passkey.icon"; + +export enum CreateCredentialDialogResult { + Success, +} + +type Step = + | "userVerification" + | "credentialCreation" + | "credentialCreationFailed" + | "credentialNaming"; + +@Component({ + templateUrl: "create-credential-dialog.component.html", +}) +export class CreateCredentialDialogComponent implements OnInit { + protected readonly NameMaxCharacters = 50; + protected readonly CreateCredentialDialogResult = CreateCredentialDialogResult; + protected readonly Icons = { CreatePasskeyIcon, CreatePasskeyFailedIcon }; + + protected currentStep: Step = "userVerification"; + protected formGroup = this.formBuilder.group({ + userVerification: this.formBuilder.group({ + masterPassword: ["", [Validators.required]], + }), + credentialNaming: this.formBuilder.group({ + name: ["", Validators.maxLength(50)], + }), + }); + protected credentialOptions?: CredentialCreateOptionsView; + protected deviceResponse?: PublicKeyCredential; + protected hasPasskeys$?: Observable; + + constructor( + private formBuilder: FormBuilder, + private dialogRef: DialogRef, + private webauthnService: WebauthnLoginService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private logService: LogService + ) {} + + ngOnInit(): void { + this.hasPasskeys$ = this.webauthnService + .getCredentials$() + .pipe(map((credentials) => credentials.length > 0)); + } + + protected submit = async () => { + this.dialogRef.disableClose = true; + + try { + switch (this.currentStep) { + case "userVerification": + return await this.submitUserVerification(); + case "credentialCreationFailed": + return await this.submitCredentialCreationFailed(); + case "credentialCreation": + return await this.submitCredentialCreation(); + case "credentialNaming": + return await this.submitCredentialNaming(); + } + } finally { + this.dialogRef.disableClose = false; + } + }; + + protected async submitUserVerification() { + this.formGroup.controls.userVerification.markAllAsTouched(); + if (this.formGroup.controls.userVerification.invalid) { + return; + } + + try { + this.credentialOptions = await this.webauthnService.getCredentialCreateOptions({ + type: VerificationType.MasterPassword, + secret: this.formGroup.value.userVerification.masterPassword, + }); + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 400) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("error"), + this.i18nService.t("invalidMasterPassword") + ); + } else { + this.logService?.error(error); + this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError")); + } + return; + } + + this.currentStep = "credentialCreation"; + await this.submitCredentialCreation(); + } + + protected async submitCredentialCreation() { + this.deviceResponse = await this.webauthnService.createCredential(this.credentialOptions); + if (this.deviceResponse === undefined) { + this.currentStep = "credentialCreationFailed"; + return; + } + + this.currentStep = "credentialNaming"; + } + + protected async submitCredentialCreationFailed() { + this.currentStep = "credentialCreation"; + await this.submitCredentialCreation(); + } + + protected async submitCredentialNaming() { + this.formGroup.controls.credentialNaming.markAllAsTouched(); + if (this.formGroup.controls.credentialNaming.invalid) { + return; + } + + const name = this.formGroup.value.credentialNaming.name; + try { + await this.webauthnService.saveCredential( + this.credentialOptions, + this.deviceResponse, + this.formGroup.value.credentialNaming.name + ); + } catch (error) { + this.logService?.error(error); + this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError")); + return; + } + + if (await firstValueFrom(this.hasPasskeys$)) { + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("passkeySaved", name) + ); + } else { + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("loginWithPasskeyEnabled") + ); + } + + this.dialogRef.close(CreateCredentialDialogResult.Success); + } +} + +/** + * Strongly typed helper to open a CreateCredentialDialog + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + */ +export const openCreateCredentialDialog = ( + dialogService: DialogService, + config: DialogConfig +) => { + return dialogService.open( + CreateCredentialDialogComponent, + config + ); +}; diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-passkey-failed.icon.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-passkey-failed.icon.ts new file mode 100644 index 0000000000..39a2389c5a --- /dev/null +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-passkey-failed.icon.ts @@ -0,0 +1,28 @@ +import { svgIcon } from "@bitwarden/components"; + +export const CreatePasskeyFailedIcon = svgIcon` + + + + + + + + + + + +`; diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-passkey.icon.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-passkey.icon.ts new file mode 100644 index 0000000000..c0e984bbee --- /dev/null +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-passkey.icon.ts @@ -0,0 +1,26 @@ +import { svgIcon } from "@bitwarden/components"; + +export const CreatePasskeyIcon = svgIcon` + + + + + + + + + + +`; diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.html b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.html new file mode 100644 index 0000000000..4cfdbbcf7f --- /dev/null +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.html @@ -0,0 +1,34 @@ +
+ + {{ "removePasskey" | i18n }} + {{ + credential.name + }} + + + + + + + +

{{ "removePasskeyInfo" | i18n }}

+ + + {{ "masterPassword" | i18n }} + + + {{ "confirmIdentity" | i18n }} + +
+
+ + + + +
+
diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts new file mode 100644 index 0000000000..7cb0323839 --- /dev/null +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts @@ -0,0 +1,95 @@ +import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Subject, takeUntil } from "rxjs"; + +import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; + +import { WebauthnLoginService } from "../../../core"; +import { WebauthnCredentialView } from "../../../core/views/webauth-credential.view"; + +export interface DeleteCredentialDialogParams { + credentialId: string; +} + +@Component({ + templateUrl: "delete-credential-dialog.component.html", +}) +export class DeleteCredentialDialogComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + protected formGroup = this.formBuilder.group({ + masterPassword: ["", [Validators.required]], + }); + protected credential?: WebauthnCredentialView; + + constructor( + @Inject(DIALOG_DATA) private params: DeleteCredentialDialogParams, + private formBuilder: FormBuilder, + private dialogRef: DialogRef, + private webauthnService: WebauthnLoginService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private logService: LogService + ) {} + + ngOnInit(): void { + this.webauthnService + .getCredential$(this.params.credentialId) + .pipe(takeUntil(this.destroy$)) + .subscribe((credential) => (this.credential = credential)); + } + + submit = async () => { + if (this.credential === undefined) { + return; + } + + this.dialogRef.disableClose = true; + try { + await this.webauthnService.deleteCredential(this.credential.id, { + type: VerificationType.MasterPassword, + secret: this.formGroup.value.masterPassword, + }); + this.platformUtilsService.showToast("success", null, this.i18nService.t("passkeyRemoved")); + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 400) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("error"), + this.i18nService.t("invalidMasterPassword") + ); + } else { + this.logService.error(error); + this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError")); + } + return false; + } finally { + this.dialogRef.disableClose = false; + } + + this.dialogRef.close(); + }; + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} + +/** + * Strongly typed helper to open a DeleteCredentialDialogComponent + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + */ +export const openDeleteCredentialDialogComponent = ( + dialogService: DialogService, + config: DialogConfig +) => { + return dialogService.open(DeleteCredentialDialogComponent, config); +}; diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/index.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/index.ts new file mode 100644 index 0000000000..8735677095 --- /dev/null +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/index.ts @@ -0,0 +1 @@ +export * from "./webauthn-login-settings.module"; diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html new file mode 100644 index 0000000000..23abe02665 --- /dev/null +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html @@ -0,0 +1,71 @@ +

+ {{ "loginWithPasskey" | i18n }} + + {{ + "on" | i18n + }} + {{ + "off" | i18n + }} + + + + +

+

+ {{ "loginWithPasskeyInfo" | i18n }} + {{ + "learnMoreAboutPasswordless" | i18n + }} +

+ + + + + + + +
{{ credential.name }} + + + {{ "supportsEncryption" | i18n }} + + + {{ "encryptionNotSupported" | i18n }} + + + +
+ +

{{ "passkeyLimitReachedInfo" | i18n }}

+ + + + + + diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts new file mode 100644 index 0000000000..98aa517b98 --- /dev/null +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts @@ -0,0 +1,72 @@ +import { Component, HostBinding, OnDestroy, OnInit } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { WebauthnLoginService } from "../../core"; +import { WebauthnCredentialView } from "../../core/views/webauth-credential.view"; + +import { openCreateCredentialDialog } from "./create-credential-dialog/create-credential-dialog.component"; +import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component"; + +@Component({ + selector: "app-webauthn-login-settings", + templateUrl: "webauthn-login-settings.component.html", + host: { + "aria-live": "polite", + }, +}) +export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + protected readonly MaxCredentialCount = 5; + + protected credentials?: WebauthnCredentialView[]; + protected loading = true; + + constructor( + private webauthnService: WebauthnLoginService, + private dialogService: DialogService + ) {} + + @HostBinding("attr.aria-busy") + get ariaBusy() { + return this.loading ? "true" : "false"; + } + + get hasCredentials() { + return this.credentials && this.credentials.length > 0; + } + + get hasData() { + return this.credentials !== undefined; + } + + get limitReached() { + return this.credentials?.length >= this.MaxCredentialCount; + } + + ngOnInit(): void { + this.webauthnService + .getCredentials$() + .pipe(takeUntil(this.destroy$)) + .subscribe((credentials) => (this.credentials = credentials)); + + this.webauthnService.loading$ + .pipe(takeUntil(this.destroy$)) + .subscribe((loading) => (this.loading = loading)); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + protected createCredential() { + openCreateCredentialDialog(this.dialogService, {}); + } + + protected deleteCredential(credentialId: string) { + openDeleteCredentialDialogComponent(this.dialogService, { data: { credentialId } }); + } +} diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.module.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.module.ts new file mode 100644 index 0000000000..73e21387ec --- /dev/null +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from "@angular/core"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; + +import { SharedModule } from "../../../shared/shared.module"; + +import { CreateCredentialDialogComponent } from "./create-credential-dialog/create-credential-dialog.component"; +import { DeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component"; +import { WebauthnLoginSettingsComponent } from "./webauthn-login-settings.component"; + +@NgModule({ + imports: [SharedModule, FormsModule, ReactiveFormsModule], + declarations: [ + WebauthnLoginSettingsComponent, + CreateCredentialDialogComponent, + DeleteCredentialDialogComponent, + ], + exports: [WebauthnLoginSettingsComponent], +}) +export class WebauthnLoginSettingsModule {} diff --git a/apps/web/src/app/oss.module.ts b/apps/web/src/app/oss.module.ts index e1e57302f4..a68b681dca 100644 --- a/apps/web/src/app/oss.module.ts +++ b/apps/web/src/app/oss.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { OrganizationUserModule } from "./admin-console/organizations/users/organization-user.module"; +import { AuthModule } from "./auth"; import { LoginModule } from "./auth/login/login.module"; import { TrialInitiationModule } from "./auth/trial-initiation/trial-initiation.module"; import { LooseComponentsModule, SharedModule } from "./shared"; @@ -16,6 +17,7 @@ import { VaultFilterModule } from "./vault/individual-vault/vault-filter/vault-f OrganizationBadgeModule, OrganizationUserModule, LoginModule, + AuthModule, ], exports: [ SharedModule, diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 6efaf3653d..f55cf72551 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -25,7 +25,6 @@ import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component" import { RegisterFormModule } from "../auth/register-form/register-form.module"; import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; -import { ChangePasswordComponent } from "../auth/settings/change-password.component"; import { DeauthorizeSessionsComponent } from "../auth/settings/deauthorize-sessions.component"; import { EmergencyAccessAddEditComponent } from "../auth/settings/emergency-access/emergency-access-add-edit.component"; import { EmergencyAccessAttachmentsComponent } from "../auth/settings/emergency-access/emergency-access-attachments.component"; @@ -119,7 +118,6 @@ import { SharedModule } from "./shared.module"; ApiKeyComponent, AttachmentsComponent, ChangeEmailComponent, - ChangePasswordComponent, CollectionsComponent, DeauthorizeSessionsComponent, DeleteAccountComponent, @@ -204,7 +202,6 @@ import { SharedModule } from "./shared.module"; ApiKeyComponent, AttachmentsComponent, ChangeEmailComponent, - ChangePasswordComponent, CollectionsComponent, DeauthorizeSessionsComponent, DeleteAccountComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 651325bad6..b9d613877f 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9ca25ab287..cc0873351b 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -2,6 +2,7 @@ export enum FeatureFlag { DisplayEuEnvironmentFlag = "display-eu-environment", DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning", TrustedDeviceEncryption = "trusted-device-encryption", + PasswordlessLogin = "passwordless-login", AutofillV2 = "autofill-v2", BrowserFilelessImport = "browser-fileless-import", } diff --git a/libs/components/src/dialog/directives/dialog-close.directive.ts b/libs/components/src/dialog/directives/dialog-close.directive.ts index 543c37715d..5e44ced7c2 100644 --- a/libs/components/src/dialog/directives/dialog-close.directive.ts +++ b/libs/components/src/dialog/directives/dialog-close.directive.ts @@ -1,5 +1,5 @@ import { DialogRef } from "@angular/cdk/dialog"; -import { Directive, HostListener, Input, Optional } from "@angular/core"; +import { Directive, HostBinding, HostListener, Input, Optional } from "@angular/core"; @Directive({ selector: "[bitDialogClose]", @@ -9,7 +9,17 @@ export class DialogCloseDirective { constructor(@Optional() public dialogRef: DialogRef) {} - @HostListener("click") close(): void { + @HostBinding("attr.disabled") + get disableClose() { + return this.dialogRef?.disableClose ? true : null; + } + + @HostListener("click") + close(): void { + if (this.disableClose) { + return; + } + this.dialogRef.close(this.dialogResult); } }