From ed77537108b37e7cf0294a7b338f695e308341a5 Mon Sep 17 00:00:00 2001 From: Vitalii Martyniak Date: Sun, 28 Jul 2024 09:53:51 +0200 Subject: [PATCH] Add webauthn auth provider UI components --- src/auth/ha-auth-flow.ts | 87 ++++- src/data/webauthn.ts | 180 ++++++++++ .../profile/ha-profile-section-security.ts | 23 ++ src/panels/profile/ha-setup-passkey-card.ts | 308 ++++++++++++++++++ src/translations/en.json | 36 ++ 5 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 src/data/webauthn.ts create mode 100644 src/panels/profile/ha-setup-passkey-card.ts diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts index db5b233073bf..6ed1167425de 100644 --- a/src/auth/ha-auth-flow.ts +++ b/src/auth/ha-auth-flow.ts @@ -16,6 +16,12 @@ import { redirectWithAuthCode, submitLoginFlow, } from "../data/auth"; +import { + PublicKeyCredentialRequestOptions, + PublicKeyCredentialRequestOptionsJSON, + AuthenticationCredentialJSON, + AuthenticationCredential, +} from "../data/webauthn"; import { DataEntryFlowStep, DataEntryFlowStepForm, @@ -215,7 +221,12 @@ export class HaAuthFlow extends LitElement { `ui.panel.page-authorize.form.providers.${step.handler[0]}.abort.${step.reason}` )} `; - case "form": + case "form": { + if (step.step_id === "challenge") { + const publicKeyOptions = + step.description_placeholders!.webauthn_options; + this._getWebauthnCredentials(publicKeyOptions); + } return html`

${!["select_mfa_module", "mfa"].includes(step.step_id) @@ -261,11 +272,30 @@ export class HaAuthFlow extends LitElement { ` : ""} `; + } default: return nothing; } } + private _base64url = { + encode: function (buffer) { + const base64 = window.btoa( + String.fromCharCode(...new Uint8Array(buffer)) + ); + return base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + }, + decode: function (base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const binStr = window.atob(base64); + const bin = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { + bin[i] = binStr.charCodeAt(i); + } + return bin.buffer; + }, + }; + private _storeTokenChanged(e: CustomEvent) { this._storeToken = (e.currentTarget as HTMLInputElement).checked; } @@ -398,6 +428,61 @@ export class HaAuthFlow extends LitElement { this._submitting = false; } } + + private async _getWebauthnCredentials( + publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptionsJSON + ) { + const publicKeyOptions: PublicKeyCredentialRequestOptions = { + ...publicKeyCredentialRequestOptions, + challenge: this._base64url.decode( + publicKeyCredentialRequestOptions.challenge + ), + allowCredentials: publicKeyCredentialRequestOptions.allowCredentials.map( + (cred) => ({ + ...cred, + id: this._base64url.decode(cred.id), + }) + ), + }; + try { + const result = await navigator.credentials.get({ + publicKey: publicKeyOptions, + }); + const authenticationCredential = result as AuthenticationCredential; + const authenticationCredentialJSON: AuthenticationCredentialJSON = { + id: authenticationCredential.id, + authenticatorAttachment: + authenticationCredential.authenticatorAttachment, + rawId: this._base64url.encode(authenticationCredential.rawId), + response: { + userHandle: this._base64url.encode( + authenticationCredential.response.userHandle + ), + clientDataJSON: this._base64url.encode( + authenticationCredential.response.clientDataJSON + ), + authenticatorData: this._base64url.encode( + authenticationCredential.response.authenticatorData + ), + signature: this._base64url.encode( + authenticationCredential.response.signature + ), + }, + type: authenticationCredential.type, + }; + this._stepData = { + authentication_credential: authenticationCredentialJSON, + client_id: this.clientId, + }; + this._handleSubmit(new Event("submit")); + } catch (err: any) { + // eslint-disable-next-line no-console + this._state = "error"; + this._errorMessage = this._unknownError(); + } finally { + this._submitting = false; + } + } } declare global { diff --git a/src/data/webauthn.ts b/src/data/webauthn.ts new file mode 100644 index 000000000000..88001af2eef5 --- /dev/null +++ b/src/data/webauthn.ts @@ -0,0 +1,180 @@ +declare global { + interface HASSDomEvents { + "hass-refresh-passkeys": undefined; + } +} + +export interface Passkey { + id: string; + credential_id: string; + name: string; + created_at: string; + last_used_at?: string; +} + +export interface PublicKeyCredentialCreationOptionsJSON { + rp: PublicKeyCredentialRpEntity; + user: PublicKeyCredentialUserEntityJSON; + challenge: string; + pubKeyCredParams: PublicKeyCredentialParameters[]; + timeout?: number; + excludeCredentials: PublicKeyCredentialDescriptorJSON[]; + authenticatorSelection?: AuthenticatorSelectionCriteria; + attestation?: AttestationConveyancePreference; + extensions?: AuthenticationExtensionsClientInputs; +} + +export interface PublicKeyCredentialUserEntityJSON { + id: string; + name: string; + displayName: string; +} + +export interface PublicKeyCredentialDescriptorJSON { + type: PublicKeyCredentialType; + id: string; + transports?: AuthenticatorTransport[]; +} + +export interface PublicKeyCredentialCreationOptions { + rp: PublicKeyCredentialRpEntity; + user: PublicKeyCredentialUserEntity; + challenge: BufferSource; + pubKeyCredParams: PublicKeyCredentialParameters[]; + timeout?: number; + excludeCredentials?: PublicKeyCredentialDescriptor[]; + authenticatorSelection?: AuthenticatorSelectionCriteria; + attestation?: AttestationConveyancePreference; + extensions?: AuthenticationExtensionsClientInputs; +} + +export interface PublicKeyCredentialRequestOptionsJSON { + id: string; + challenge: string; + timeout?: number; + rpId?: string; + allowCredentials: PublicKeyCredentialDescriptorJSON[]; + userVerification?: UserVerificationRequirement; + extensions?: AuthenticationExtensionsClientInputs; +} + +export interface PublicKeyCredentialRequestOptions { + id: string; + challenge: BufferSource; + timeout?: number; + rpId?: string; + allowCredentials?: PublicKeyCredentialDescriptor[]; + userVerification?: UserVerificationRequirement; + extensions?: AuthenticationExtensionsClientInputs; +} + +export interface PublicKeyCredentialRpEntity { + id: string; + name: string; +} + +export interface PublicKeyCredentialUserEntity { + id: BufferSource; + name: string; + displayName: string; +} + +export interface PublicKeyCredentialParameters { + type: PublicKeyCredentialType; + alg: COSEAlgorithmIdentifier; +} + +export type PublicKeyCredentialType = "public-key"; + +export type COSEAlgorithmIdentifier = -7 | -257 | -65535 | -257 | -65535; + +export interface PublicKeyCredentialDescriptor { + type: PublicKeyCredentialType; + id: BufferSource; + transports?: AuthenticatorTransport[]; +} + +export type AuthenticatorTransport = "usb" | "nfc" | "ble" | "internal"; + +export type AuthenticatorAttachment = "platform" | "cross-platform"; + +export type UserVerificationRequirement = + | "required" + | "preferred" + | "discouraged"; + +export interface AuthenticatorSelectionCriteria { + authenticatorAttachment?: AuthenticatorAttachment; + requireResidentKey?: boolean; + userVerification?: UserVerificationRequirement; +} + +export type AttestationConveyancePreference = "none" | "indirect" | "direct"; + +export interface AuthenticationExtensionsClientInputs { + [key: string]: any; +} + +export interface PublicKeyCredentialWithClientExtensionOutputs { + clientExtensionResults: AuthenticationExtensionsClientOutputs; +} + +export interface AuthenticationExtensionsClientOutputs { + [key: string]: any; +} + +export interface PublicKeyCredentialAttestationResponse { + clientDataJSON: BufferSource; + attestationObject: BufferSource; +} + +export interface PublicKeyCredentialAssertionResponse { + clientDataJSON: BufferSource; + authenticatorData: BufferSource; + signature: BufferSource; + userHandle: BufferSource; +} + +export interface PublicKeyRegistartionCredentialResponseJSON { + authenticatorAttachment: string; + id: string; + rawId: string; + response: PublicKeyCredentialAttestationResponseJSON; + type: string; +} + +export interface PublicKeyCredentialAttestationResponseJSON { + clientDataJSON: string; + attestationObject: string; +} + +export interface PublicKeyRegistartionCredentialResponse { + authenticatorAttachment: string; + id: string; + rawId: BufferSource; + response: PublicKeyCredentialAttestationResponse; + type: string; +} + +export interface AuthenticationCredentialJSON { + authenticatorAttachment: string; + id: string; + rawId: string; + response: PublicKeyCredentialAssertionResponseJSON; + type: string; +} + +export interface PublicKeyCredentialAssertionResponseJSON { + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle: string; +} + +export interface AuthenticationCredential { + authenticatorAttachment: string; + id: string; + rawId: BufferSource; + response: PublicKeyCredentialAssertionResponse; + type: string; +} diff --git a/src/panels/profile/ha-profile-section-security.ts b/src/panels/profile/ha-profile-section-security.ts index 1aec54bc971a..836ffbb34a69 100644 --- a/src/panels/profile/ha-profile-section-security.ts +++ b/src/panels/profile/ha-profile-section-security.ts @@ -9,6 +9,8 @@ import "./ha-change-password-card"; import "./ha-long-lived-access-tokens-card"; import "./ha-mfa-modules-card"; import "./ha-refresh-tokens-card"; +import "./ha-setup-passkey-card"; +import { Passkey } from "../../data/webauthn"; @customElement("ha-profile-section-security") class HaProfileSectionSecurity extends LitElement { @@ -18,6 +20,8 @@ class HaProfileSectionSecurity extends LitElement { @state() private _refreshTokens?: RefreshToken[]; + @state() private _passkeys?: Passkey[]; + @property({ attribute: false }) public route!: Route; public connectedCallback() { @@ -29,6 +33,9 @@ class HaProfileSectionSecurity extends LitElement { if (!this._refreshTokens) { this._refreshRefreshTokens(); } + if (!this._passkeys) { + this._refreshPasskeys(); + } } protected render(): TemplateResult { @@ -53,6 +60,13 @@ class HaProfileSectionSecurity extends LitElement { > ` : ""} + + + ({ + type: "config/auth_provider/passkey/list", + }); + } + static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/panels/profile/ha-setup-passkey-card.ts b/src/panels/profile/ha-setup-passkey-card.ts new file mode 100644 index 000000000000..37cf1bc23cb0 --- /dev/null +++ b/src/panels/profile/ha-setup-passkey-card.ts @@ -0,0 +1,308 @@ +import "@material/mwc-button"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { mdiKey, mdiDotsVertical, mdiDelete, mdiRename } from "@mdi/js"; +import { customElement, property } from "lit/decorators"; +import { ActionDetail } from "@material/mwc-list"; +import { relativeTime } from "../../common/datetime/relative_time"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-card"; +import "../../components/ha-circular-progress"; +import "../../components/ha-textfield"; +import { + Passkey, + PublicKeyCredentialCreationOptions, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyRegistartionCredentialResponse, + PublicKeyRegistartionCredentialResponseJSON, +} from "../../data/webauthn"; +import { haStyle } from "../../resources/styles"; +import type { HomeAssistant } from "../../types"; +import "../../components/ha-alert"; +import { + showAlertDialog, + showConfirmationDialog, + showPromptDialog, +} from "../../dialogs/generic/show-dialog-box"; + +@customElement("ha-setup-passkey-card") +class HaSetupPasskeyCard extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public passkeys!: Passkey[]; + + protected render(): TemplateResult { + return html` + +
+ ${this.hass.localize("ui.panel.profile.passkeys.description")} + ${!this.passkeys?.length + ? html`

+ ${this.hass.localize("ui.panel.profile.passkeys.empty_state")} +

` + : this.passkeys.map( + (passkey) => html` + + + ${passkey.name} +
+ ${this.hass.localize( + "ui.panel.profile.passkeys.created_at", + { + date: relativeTime( + new Date(passkey.created_at), + this.hass.locale + ), + } + )} +
+
+ ${passkey.last_used_at + ? this.hass.localize( + "ui.panel.profile.passkeys.last_used", + { + date: relativeTime( + new Date(passkey.last_used_at), + this.hass.locale + ), + } + ) + : this.hass.localize( + "ui.panel.profile.passkeys.not_used" + )} +
+
+ + + + + ${this.hass.localize("ui.common.rename")} + + + + ${this.hass.localize("ui.common.delete")} + + +
+
+ ` + )} +
+ +
+ ${this.hass.localize("ui.common.add")} +
+
+ `; + } + + private async _handleAction(ev: CustomEvent) { + const passkey = (ev.currentTarget as any).passkey; + switch (ev.detail.index) { + case 0: + this._renamePasskey(passkey); + break; + case 1: + this._deletePasskey(passkey); + break; + } + } + + private async _renamePasskey(passkey: Passkey): Promise { + const newName = await showPromptDialog(this, { + text: this.hass.localize("ui.panel.profile.passkeys.prompt_name"), + inputLabel: this.hass.localize("ui.panel.profile.passkeys.name"), + }); + if (!newName || newName === passkey.name) { + return; + } + try { + await this.hass.callWS({ + type: "config/auth_provider/passkey/rename", + credential_id: passkey.credential_id, + name: newName, + }); + fireEvent(this, "hass-refresh-passkeys"); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.profile.passkeys.rename_failed"), + text: err.message, + }); + } + } + + private async _deletePasskey(passkey: Passkey): Promise { + if ( + !(await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.profile.passkeys.confirm_delete_title" + ), + text: this.hass.localize( + "ui.panel.profile.passkeys.confirm_delete_text", + { name: passkey.name } + ), + confirmText: this.hass.localize("ui.common.delete"), + destructive: true, + })) + ) { + return; + } + try { + await this.hass.callWS({ + type: "config/auth_provider/passkey/delete", + credential_id: passkey.credential_id, + }); + fireEvent(this, "hass-refresh-passkeys"); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.profile.passkeys.delete_failed"), + text: err.message, + }); + } + } + + private async _registerPasskey() { + try { + const registrationOptions: PublicKeyCredentialCreationOptionsJSON = + await this.hass.callWS({ + type: "config/auth_provider/passkey/register", + }); + const options: PublicKeyCredentialCreationOptions = { + ...registrationOptions, + user: { + ...registrationOptions.user, + id: this._base64url.decode(registrationOptions.user.id), + }, + challenge: this._base64url.decode(registrationOptions.challenge), + excludeCredentials: registrationOptions.excludeCredentials.map( + (cred) => ({ + ...cred, + id: this._base64url.decode(cred.id), + }) + ), + }; + + const result = await navigator.credentials.create({ publicKey: options }); + const publicKeyCredential = + result as PublicKeyRegistartionCredentialResponse; + const credentials: PublicKeyRegistartionCredentialResponseJSON = { + id: publicKeyCredential.id, + authenticatorAttachment: publicKeyCredential.authenticatorAttachment, + type: publicKeyCredential.type, + rawId: this._base64url.encode(publicKeyCredential.rawId), + response: { + clientDataJSON: this._base64url.encode( + publicKeyCredential.response.clientDataJSON + ), + attestationObject: this._base64url.encode( + publicKeyCredential.response.attestationObject + ), + }, + }; + this._verifyRegistrationPasskey(credentials); + } catch (error: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.profile.passkeys.register_failed"), + text: error.message, + }); + } + } + + private async _verifyRegistrationPasskey( + credential: PublicKeyRegistartionCredentialResponseJSON + ) { + try { + this.hass + .callWS({ + type: "config/auth_provider/passkey/register_verify", + credential: credential, + }) + .then(() => { + fireEvent(this, "hass-refresh-passkeys"); + }); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.profile.passkeys.register_failed"), + text: err.message, + }); + } + } + + private _base64url = { + encode: function (buffer) { + const base64 = window.btoa( + String.fromCharCode(...new Uint8Array(buffer)) + ); + return base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + }, + decode: function (base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const binStr = window.atob(base64); + const bin = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { + bin[i] = binStr.charCodeAt(i); + } + return bin.buffer; + }, + }; + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + ha-settings-row { + padding: 0; + --settings-row-prefix-display: contents; + --settings-row-content-display: contents; + } + ha-icon-button { + color: var(--primary-text-color); + } + ha-list-item[disabled], + ha-list-item[disabled] ha-svg-icon { + color: var(--disabled-text-color) !important; + } + ha-settings-row .current-session { + display: inline-flex; + align-items: center; + } + ha-settings-row .dot { + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--success-color); + border-radius: 50%; + margin-right: 6px; + } + ha-settings-row > ha-svg-icon { + margin-right: 12px; + margin-inline-start: initial; + margin-inline-end: 12px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-setup-passkey-card": HaSetupPasskeyCard; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index df4945647e22..9eb011d67788 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6531,6 +6531,25 @@ "empty_state": "You have no long-lived access tokens yet.", "qr_code_image": "QR code for token {name}", "generate_qr_code": "Generate QR Code" + }, + "passkeys": { + "header": "Passkeys", + "description": "Passkeys are webauthn credentials that validate your identity using touch, facial recognition, a device password, or a PIN. They can be used as a password replacement.", + "learn_auth_requests": "Learn how to make authenticated requests.", + "created_at": "Created {date}", + "last_used": "Last used {date}", + "confirm_delete_title": "Delete passkey?", + "confirm_delete_text": "Are you sure you want to delete the passkey ''{name}''?", + "delete_failed": "Failed to delete the passkey.", + "create": "Create Passkey", + "create_failed": "Failed to create the passkey.", + "name": "Name", + "prompt_name": "Give the passkey a name", + "prompt_copy_passkey": "Copy your passkey. It will not be shown again.", + "empty_state": "You have no passkeys yet.", + "not_used": "Has never been used", + "rename_failed": "Failed to rename the passkey.", + "register_failed": "Failed to register the passkey." } }, "todo": { @@ -6650,6 +6669,23 @@ "abort": { "not_allowed": "Your computer is not allowed." } + }, + "webauthn": { + "step": { + "init": { + "data": { + "username": "Username" + }, + "description": "Please enter your username." + } + }, + "error": { + "invalid_user": "Invalid username", + "invalid_auth": "Invalid username" + }, + "abort": { + "not_allowed": "Selected user is not allowed." + } } } }