From da6763e58aedd24d947ca641fba8536dc8b407c6 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 19 Nov 2023 11:37:32 +0000 Subject: [PATCH 1/7] Add bare-minimum to generate and store passkey --- homeassistant/auth/__init__.py | 40 +++- homeassistant/auth/models.py | 29 +++ .../auth/passkey_modules/__init__.py | 174 +++++++++++++++ .../auth/passkey_modules/webauthn.py | 198 ++++++++++++++++++ homeassistant/components/auth/__init__.py | 3 +- homeassistant/components/auth/manifest.json | 4 +- .../components/auth/passkey_setup_flow.py | 116 ++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + 9 files changed, 562 insertions(+), 8 deletions(-) create mode 100644 homeassistant/auth/passkey_modules/__init__.py create mode 100644 homeassistant/auth/passkey_modules/webauthn.py create mode 100644 homeassistant/components/auth/passkey_setup_flow.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 2707f8b6899d..6ca5c7d0d67e 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -5,6 +5,7 @@ from collections import OrderedDict from collections.abc import Mapping from datetime import timedelta +import logging import time from typing import Any, cast @@ -17,13 +18,17 @@ from . import auth_store, jwt_wrapper, models from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config +from .passkey_modules import PasskeyAuthModule, auth_passkey_module_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config EVENT_USER_ADDED = "user_added" EVENT_USER_UPDATED = "user_updated" EVENT_USER_REMOVED = "user_removed" +_LOGGER = logging.getLogger(__name__) + _MfaModuleDict = dict[str, MultiFactorAuthModule] +_PasskeyModuleDict = dict[str, PasskeyAuthModule] _ProviderKey = tuple[str, str | None] _ProviderDict = dict[_ProviderKey, AuthProvider] @@ -63,17 +68,28 @@ async def auth_manager_from_config( provider_hash[key] = provider if module_configs: - modules = await asyncio.gather( + mfa_modules = await asyncio.gather( *(auth_mfa_module_from_config(hass, config) for config in module_configs) ) + passkey_modules = [ + await auth_passkey_module_from_config(hass, {"type": "webauthn"}) + ] + else: - modules = [] + mfa_modules = [] + # So returned auth modules are in same order as config - module_hash: _MfaModuleDict = OrderedDict() - for module in modules: - module_hash[module.id] = module + mfa_module_hash: _MfaModuleDict = OrderedDict() + for module in mfa_modules: + mfa_module_hash[module.id] = module + + passkey_module_hash: _PasskeyModuleDict = OrderedDict() + for module in passkey_modules: + passkey_module_hash[module.id] = module - manager = AuthManager(hass, store, provider_hash, module_hash) + manager = AuthManager( + hass, store, provider_hash, mfa_module_hash, passkey_module_hash + ) return manager @@ -150,12 +166,14 @@ def __init__( store: auth_store.AuthStore, providers: _ProviderDict, mfa_modules: _MfaModuleDict, + passkey_modules: _PasskeyModuleDict, ) -> None: """Initialize the auth manager.""" self.hass = hass self._store = store self._providers = providers self._mfa_modules = mfa_modules + self._passkey_modules = passkey_modules self.login_flow = AuthManagerFlowManager(hass, self) self._revoke_callbacks: dict[str, list[CALLBACK_TYPE]] = {} @@ -169,6 +187,11 @@ def auth_mfa_modules(self) -> list[MultiFactorAuthModule]: """Return a list of available auth modules.""" return list(self._mfa_modules.values()) + @property + def auth_passkey_modules(self) -> list[PasskeyAuthModule]: + """Return a list of available auth modules.""" + return list(self._passkey_modules.values()) + def get_auth_provider( self, provider_type: str, provider_id: str | None ) -> AuthProvider | None: @@ -187,6 +210,11 @@ def get_auth_mfa_module(self, module_id: str) -> MultiFactorAuthModule | None: """Return a multi-factor auth module, None if not found.""" return self._mfa_modules.get(module_id) + def get_auth_passkey_module(self, module_id: str) -> PasskeyAuthModule | None: + """Return a passkey auth module, None if not found.""" + _LOGGER.info(self._passkey_modules) + return self._passkey_modules.get(module_id) + async def async_get_users(self) -> list[models.User]: """Retrieve all users.""" return await self._store.async_get_users() diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index e604bf9d21cd..3474d6700992 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -129,6 +129,35 @@ class Credentials: is_new: bool = attr.ib(default=True) +@attr.s(slots=True) +class Passkey: + """{ + "credentialId": "0wOEfoJpRMBS6_ZP7g_o9sQXtQs", + "credentialPublicKey": "pQECAyYgASFYIGLxzIyvc1E_peYbdYnRiWCZv16QKNwSzK5o2Q9NMnRXIlggMXGUmvQcb37_t6kQGgepYtUZeD76quYJZLO8OpCeB-E", + "signCount": 0, + "aaguid": "00000000-0000-0000-0000-000000000000", + "fmt": "none", + "credentialType": "public-key", + "userVerified": True, + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAAAAAAAAAAAAAAAAAAAAAAAAFNMDhH6CaUTAUuv2T-4P6PbEF7ULpQECAyYgASFYIGLxzIyvc1E_peYbdYnRiWCZv16QKNwSzK5o2Q9NMnRXIlggMXGUmvQcb37_t6kQGgepYtUZeD76quYJZLO8OpCeB-E", + "credentialDeviceType": "multi_device", + "credentialBackedUp": true, + } + """ + + """Passkey for user on an auth provider.""" + credential_id: str = attr.ib() + credential_public_key: str = attr.ib() + sign_count: int = attr.ib() + aaguid: str = attr.ib() + fmt: str = attr.ib() + credential_type: str = attr.ib() + user_verified: bool = attr.ib() + attestation_object: str = attr.ib() + credential_device_type: str = attr.ib() + credential_backed_up: bool = attr.ib() + + class UserMeta(NamedTuple): """User metadata.""" diff --git a/homeassistant/auth/passkey_modules/__init__.py b/homeassistant/auth/passkey_modules/__init__.py new file mode 100644 index 000000000000..f4fe22084589 --- /dev/null +++ b/homeassistant/auth/passkey_modules/__init__.py @@ -0,0 +1,174 @@ +"""Pluggable auth modules for Home Assistant.""" +from __future__ import annotations + +import importlib +import logging +import types +from typing import Any + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant import data_entry_flow, requirements +from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.decorator import Registry + +PASSKEY_AUTH_MODULES: Registry[str, type[PasskeyAuthModule]] = Registry() + +PASSKEY_AUTH_MODULE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TYPE): object, + vol.Optional(CONF_NAME): object, + # Specify ID if you have two mfa auth module for same type. + vol.Optional(CONF_ID): object, + }, + extra=vol.ALLOW_EXTRA, +) + +DATA_REQS = "mfa_auth_module_reqs_processed" + +_LOGGER = logging.getLogger(__name__) + + +class PasskeyAuthModule: + """Multi-factor Auth Module of validation function.""" + + DEFAULT_TITLE = "Unnamed auth module" + MAX_RETRY_TIME = 3 + + def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: + """Initialize an auth module.""" + self.hass = hass + self.config = config + + @property + def id(self) -> str: + """Return id of the auth module. + + Default is same as type + """ + return self.config.get(CONF_ID, self.type) # type: ignore[no-any-return] + + @property + def type(self) -> str: + """Return type of the module.""" + return self.config[CONF_TYPE] # type: ignore[no-any-return] + + @property + def name(self) -> str: + """Return the name of the auth module.""" + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) # type: ignore[no-any-return] + + # Implement by extending class + + @property + def input_schema(self) -> vol.Schema: + """Return a voluptuous schema to define mfa auth module's input.""" + raise NotImplementedError + + async def async_setup_flow(self, user_id: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + raise NotImplementedError + + async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: + """Set up user for mfa auth module.""" + raise NotImplementedError + + async def async_depose_user(self, user_id: str) -> None: + """Remove user from mfa module.""" + raise NotImplementedError + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + raise NotImplementedError + + async def async_validate(self, user_id: str, user_input: dict[str, Any]) -> bool: + """Return True if validation passed.""" + raise NotImplementedError + + +class SetupFlow(data_entry_flow.FlowHandler): + """Handler for the setup flow.""" + + def __init__( + self, auth_module: PasskeyAuthModule, setup_schema: vol.Schema, user_id: str + ) -> None: + """Initialize the setup flow.""" + self._auth_module = auth_module + self._setup_schema = setup_schema + self._user_id = user_id + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the first step of setup flow. + + Return self.async_show_form(step_id='init') if user_input is None. + Return self.async_create_entry(data={'result': result}) if finish. + """ + errors: dict[str, str] = {} + + if user_input: + result = await self._auth_module.async_setup_user(self._user_id, user_input) + return self.async_create_entry(data={"result": result}) + + return self.async_show_form( + step_id="init", data_schema=self._setup_schema, errors=errors + ) + + +async def auth_passkey_module_from_config( + hass: HomeAssistant, config: dict[str, Any] +) -> PasskeyAuthModule: + """Initialize an auth module from a config.""" + module_name: str = config[CONF_TYPE] + module = await _load_passkey_module(hass, module_name) + + try: + config = module.CONFIG_SCHEMA(config) + except vol.Invalid as err: + _LOGGER.error( + "Invalid configuration for multi-factor module %s: %s", + module_name, + humanize_error(config, err), + ) + raise + + return PASSKEY_AUTH_MODULES[module_name](hass, config) + + +async def _load_passkey_module( + hass: HomeAssistant, module_name: str +) -> types.ModuleType: + """Load an mfa auth module.""" + module_path = f"homeassistant.auth.passkey_modules.{module_name}" + + try: + module = importlib.import_module(module_path) + except ImportError as err: + _LOGGER.error("Unable to load passkey module %s: %s", module_name, err) + raise HomeAssistantError( + f"Unable to load passkey module {module_name}: {err}" + ) from err + + if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): + return module + + processed = hass.data.get(DATA_REQS) + if processed and module_name in processed: + return module + + processed = hass.data[DATA_REQS] = set() + + await requirements.async_process_requirements( + hass, module_path, module.REQUIREMENTS + ) + + processed.add(module_name) + return module diff --git a/homeassistant/auth/passkey_modules/webauthn.py b/homeassistant/auth/passkey_modules/webauthn.py new file mode 100644 index 000000000000..75b284a530be --- /dev/null +++ b/homeassistant/auth/passkey_modules/webauthn.py @@ -0,0 +1,198 @@ +"""Time-based One Time Password auth module.""" +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, Optional, cast + +import voluptuous as vol + +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.storage import Store + +from . import ( + PASSKEY_AUTH_MODULE_SCHEMA, + PASSKEY_AUTH_MODULES, + PasskeyAuthModule, + SetupFlow, +) + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ["webauthn==1.11.1"] + +CONFIG_SCHEMA = PASSKEY_AUTH_MODULE_SCHEMA.extend({}, extra=vol.PREVENT_EXTRA) + +STORAGE_VERSION = 1 +STORAGE_KEY = "auth_module.webauthn" +STORAGE_USERS = "users" +STORAGE_USER_ID = "user_id" +STORAGE_OTA_SECRET = "ota_secret" + +INPUT_FIELD_CODE = "code" + + +def _generate_options( + user_id: str, username: str +) -> tuple[dict[str, str], Optional[bytes]]: + """Generate a webauthn options.""" + from webauthn import generate_registration_options, options_to_json + + options = generate_registration_options( + rp_id="localhost", # TODO: Find actual url + rp_name="Home Assistant", + user_id=user_id, + user_name=username, + ) + + # Hacky method to get an object we can send + # Simply calling json.dumps() on the options object fails + json_options = options_to_json(options) + return (json.loads(json_options), options.challenge) + + +def _generate_verification( + credential: dict[str:str], challenge: Optional[bytes] +) -> dict[str, str]: + """Generate a secret, url, and QR code.""" + from webauthn import options_to_json, verify_registration_response + + registration_verification = verify_registration_response( + credential=credential, + expected_challenge=challenge, + expected_origin="http://localhost:8123", # TODO: Find actual origin + expected_rp_id="localhost", # TODO: Find actual URL + require_user_verification=True, + ) + + # Hacky method to get an object we can send + # Simply calling json.dumps() on the options object fails + json_options = options_to_json(registration_verification) + return json.loads(json_options) + + +@PASSKEY_AUTH_MODULES.register("webauthn") +class WebauthnAuthModule(PasskeyAuthModule): + """Auth module validate time-based one time password.""" + + DEFAULT_TITLE = "Time-based One Time Password" + MAX_RETRY_TIME = 5 + + def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: + """Initialize the user data store.""" + super().__init__(hass, config) + self._users: dict[str, str] | None = None + self._user_store = Store[dict[str, dict[str, str]]]( + hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True + ) + self._init_lock = asyncio.Lock() + + @property + def input_schema(self) -> vol.Schema: + """Validate login flow input data.""" + return vol.Schema( + {vol.Required("id"): str}, + extra=vol.ALLOW_EXTRA, + ) + + async def _async_load(self) -> None: + """Load stored data.""" + async with self._init_lock: + if self._users is not None: + return + + if (data := await self._user_store.async_load()) is None: + data = cast(dict[str, dict[str, str]], {STORAGE_USERS: {}}) + + self._users = data.get(STORAGE_USERS, {}) + + async def _async_save(self) -> None: + """Save data.""" + await self._user_store.async_save({STORAGE_USERS: self._users or {}}) + + async def async_setup_flow(self, user: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + user = await self.hass.auth.async_get_user(user) + assert user is not None + return WebauthnSetupFlow(self, self.input_schema, user) + + async def async_setup_user(self, user_id: str, setup_data: Any) -> str: + """Set up auth module for user.""" + if self._users is None: + await self._async_load() + + self._users[user_id] = setup_data.get("passkey") + + await self._async_save() + return self._users[user_id] + + async def async_depose_user(self, user_id: str) -> None: + """Depose auth module for user.""" + if self._users is None: + await self._async_load() + + if self._users.pop(user_id, None): # type: ignore[union-attr] + await self._async_save() + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + if self._users is None: + await self._async_load() + + return user_id in self._users # type: ignore[operator] + + +class WebauthnSetupFlow(SetupFlow): + """Handler for the setup flow.""" + + def __init__( + self, auth_module: WebauthnAuthModule, setup_schema: vol.Schema, user: User + ) -> None: + """Initialize the setup flow.""" + super().__init__(auth_module, setup_schema, user.id) + # to fix typing complaint + self._auth_module: WebauthnAuthModule = auth_module + self._user = user + self._challenge: Optional[bytes] + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + errors: dict[str, str] = {} + options: dict[str, str] = {} + + if user_input: + hass = self._auth_module.hass + passkey = await hass.async_add_executor_job( + _generate_verification, + user_input, + self._challenge, + ) + self._challenge = None + result = await self._auth_module.async_setup_user( + self._user_id, {"passkey": passkey} + ) + _LOGGER.info("Completed") + + else: + hass = self._auth_module.hass + (options, self._challenge) = await hass.async_add_executor_job( + _generate_options, + str(self._user.id), + str(self._user.name), + ) + + return self.async_show_form( + step_id="init", + data_schema=self._setup_schema, + description_placeholders={ + "options": options, + }, + errors=errors, + ) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 78a1383012d6..382f89363902 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -158,7 +158,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util -from . import indieauth, login_flow, mfa_setup_flow +from . import indieauth, login_flow, mfa_setup_flow, passkey_setup_flow DOMAIN = "auth" @@ -196,6 +196,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await login_flow.async_setup(hass, store_result) await mfa_setup_flow.async_setup(hass) + await passkey_setup_flow.async_setup(hass) return True diff --git a/homeassistant/components/auth/manifest.json b/homeassistant/components/auth/manifest.json index 58e925de9e98..84728c60ff55 100644 --- a/homeassistant/components/auth/manifest.json +++ b/homeassistant/components/auth/manifest.json @@ -2,7 +2,9 @@ "domain": "auth", "name": "Auth", "codeowners": ["@home-assistant/core"], - "dependencies": ["http"], + "dependencies": [ + "http" + ], "documentation": "https://www.home-assistant.io/integrations/auth", "integration_type": "system", "quality_scale": "internal" diff --git a/homeassistant/components/auth/passkey_setup_flow.py b/homeassistant/components/auth/passkey_setup_flow.py new file mode 100644 index 000000000000..7876a45a0d49 --- /dev/null +++ b/homeassistant/components/auth/passkey_setup_flow.py @@ -0,0 +1,116 @@ +"""Helpers to setup multi-factor auth module.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv + +WS_TYPE_PASSKEY_REGISTER = "auth/passkey_register" +SCHEMA_WS_PASSKEY_REGISTER = vol.All( + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_PASSKEY_REGISTER, + vol.Exclusive("passkey_module_id", "module_or_flow_id"): str, + vol.Exclusive("flow_id", "module_or_flow_id"): str, + vol.Optional("credential", "module_or_flow_id"): object, + } + ), + cv.has_at_least_one_key("passkey_module_id", "flow_id"), +) + +DATA_SETUP_FLOW_MGR = "auth_passkey_setup_flow_manager" + +_LOGGER = logging.getLogger(__name__) + + +class PasskeyFlowManager(data_entry_flow.FlowManager): + """Manage multi factor authentication flows.""" + + async def async_create_flow( # type: ignore[override] + self, + handler_key: str, + *, + context: dict[str, Any], + data: dict[str, Any], + ) -> data_entry_flow.FlowHandler: + """Create a setup flow. handler is a passkey module.""" + passkey_module = self.hass.auth.get_auth_passkey_module(handler_key) + if passkey_module is None: + raise ValueError(f"Passkey module {handler_key} is not found") + + user_id = data.pop("user_id") + return await passkey_module.async_setup_flow(user_id) + + async def async_finish_flow( + self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult + ) -> data_entry_flow.FlowResult: + """Complete an mfs setup flow.""" + _LOGGER.debug("flow_result: %s", result) + return result + + +async def async_setup(hass: HomeAssistant) -> None: + """Init passkey setup flow manager.""" + hass.data[DATA_SETUP_FLOW_MGR] = PasskeyFlowManager(hass) + + websocket_api.async_register_command( + hass, + WS_TYPE_PASSKEY_REGISTER, + websocket_passkey_register_request, + SCHEMA_WS_PASSKEY_REGISTER, + ) + + +@callback +@websocket_api.ws_require_user(allow_system_user=False) +def websocket_passkey_register_request( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a setup flow for passkey auth module.""" + + async def async_setup_flow(msg: dict[str, Any]) -> None: + """Return a setup flow for mfa auth module.""" + flow_manager: PasskeyFlowManager = hass.data[DATA_SETUP_FLOW_MGR] + + if (flow_id := msg.get("flow_id")) is not None: + _LOGGER.warning(msg.get("credential")) + result = await flow_manager.async_configure(flow_id, msg.get("credential")) + _LOGGER.warning("Done!") + # connection.send_message( + # websocket_api.result_message(msg["id"], _prepare_result_json(result)) + # ) + return + + passkey_module_id = msg["passkey_module_id"] + if hass.auth.get_auth_passkey_module(passkey_module_id) is None: + connection.send_message( + websocket_api.error_message( + msg["id"], + "no_module", + f"Passkey module {passkey_module_id} is not found", + ) + ) + return + + result = await flow_manager.async_init( + passkey_module_id, data={"user_id": connection.user.id} + ) + + _LOGGER.info(result) + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "flow_id": result["flow_id"], + "options": result["description_placeholders"]["options"], + }, + ) + ) + + hass.async_create_task(async_setup_flow(msg)) diff --git a/requirements_all.txt b/requirements_all.txt index a8ccb26797f8..abdc501e5898 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2720,6 +2720,9 @@ watchdog==2.3.1 # homeassistant.components.waterfurnace waterfurnace==1.1.0 +# homeassistant.auth.passkey_modules.webauthn +webauthn==1.11.1 + # homeassistant.components.cisco_webex_teams webexteamssdk==1.1.1;python_version<'3.12' diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89a5db104aec..7ed5fb62ea08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2024,6 +2024,9 @@ wallbox==0.4.14 # homeassistant.components.folder_watcher watchdog==2.3.1 +# homeassistant.auth.passkey_modules.webauthn +webauthn==1.11.1 + # homeassistant.components.assist_pipeline webrtc-noise-gain==1.2.3 From 1e8bd32b3f74e820dfa7529180b0a53fa5a1ff72 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 19 Nov 2023 11:56:28 +0000 Subject: [PATCH 2/7] Fix override parameter --- homeassistant/auth/passkey_modules/webauthn.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/auth/passkey_modules/webauthn.py b/homeassistant/auth/passkey_modules/webauthn.py index 75b284a530be..33132e3c0c8b 100644 --- a/homeassistant/auth/passkey_modules/webauthn.py +++ b/homeassistant/auth/passkey_modules/webauthn.py @@ -113,12 +113,12 @@ async def _async_save(self) -> None: """Save data.""" await self._user_store.async_save({STORAGE_USERS: self._users or {}}) - async def async_setup_flow(self, user: str) -> SetupFlow: + async def async_setup_flow(self, user_id: str) -> SetupFlow: """Return a data entry flow handler for setup module. Mfa module should extend SetupFlow """ - user = await self.hass.auth.async_get_user(user) + user = await self.hass.auth.async_get_user(user_id) assert user is not None return WebauthnSetupFlow(self, self.input_schema, user) From 92a6c88471cc17ff2563ecd64a90dbbb470ea74e Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 19 Nov 2023 12:28:23 +0000 Subject: [PATCH 3/7] Allow to store multiple passkeys per user --- .../auth/passkey_modules/webauthn.py | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/homeassistant/auth/passkey_modules/webauthn.py b/homeassistant/auth/passkey_modules/webauthn.py index 33132e3c0c8b..9d7378fcf3db 100644 --- a/homeassistant/auth/passkey_modules/webauthn.py +++ b/homeassistant/auth/passkey_modules/webauthn.py @@ -28,11 +28,9 @@ STORAGE_VERSION = 1 STORAGE_KEY = "auth_module.webauthn" -STORAGE_USERS = "users" +STORAGE_PASSKEYS = "passkeys" STORAGE_USER_ID = "user_id" -STORAGE_OTA_SECRET = "ota_secret" - -INPUT_FIELD_CODE = "code" +STORAGE_CREDENTIAL_PUBLIC_KEY = "credential_public_key" def _generate_options( @@ -84,7 +82,7 @@ class WebauthnAuthModule(PasskeyAuthModule): def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: """Initialize the user data store.""" super().__init__(hass, config) - self._users: dict[str, str] | None = None + self._passkeys: dict[str, str] | None = None self._user_store = Store[dict[str, dict[str, str]]]( hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) @@ -101,17 +99,17 @@ def input_schema(self) -> vol.Schema: async def _async_load(self) -> None: """Load stored data.""" async with self._init_lock: - if self._users is not None: + if self._passkeys is not None: return if (data := await self._user_store.async_load()) is None: - data = cast(dict[str, dict[str, str]], {STORAGE_USERS: {}}) + data = cast(dict[str, dict[str, str]], {STORAGE_PASSKEYS: {}}) - self._users = data.get(STORAGE_USERS, {}) + self._passkeys = data.get(STORAGE_PASSKEYS, {}) async def _async_save(self) -> None: """Save data.""" - await self._user_store.async_save({STORAGE_USERS: self._users or {}}) + await self._user_store.async_save({STORAGE_PASSKEYS: self._passkeys or {}}) async def async_setup_flow(self, user_id: str) -> SetupFlow: """Return a data entry flow handler for setup module. @@ -124,28 +122,34 @@ async def async_setup_flow(self, user_id: str) -> SetupFlow: async def async_setup_user(self, user_id: str, setup_data: Any) -> str: """Set up auth module for user.""" - if self._users is None: + if self._passkeys is None: await self._async_load() - self._users[user_id] = setup_data.get("passkey") + # If the validation process succeeded, the server would then store the publicKeyBytes and credentialId in a database, associated with the user. + passkey = setup_data.get("passkey") + + self._passkeys[passkey["credentialId"]] = { + STORAGE_USER_ID: user_id, + STORAGE_CREDENTIAL_PUBLIC_KEY: passkey["credentialPublicKey"], + } await self._async_save() - return self._users[user_id] + return self._passkeys[passkey["credentialId"]] async def async_depose_user(self, user_id: str) -> None: """Depose auth module for user.""" - if self._users is None: + if self._passkeys is None: await self._async_load() - if self._users.pop(user_id, None): # type: ignore[union-attr] + if self._passkeys.pop(user_id, None): # type: ignore[union-attr] await self._async_save() async def async_is_user_setup(self, user_id: str) -> bool: """Return whether user is setup.""" - if self._users is None: + if self._passkeys is None: await self._async_load() - return user_id in self._users # type: ignore[operator] + return user_id in self._passkeys # type: ignore[operator] class WebauthnSetupFlow(SetupFlow): From 00af443b8f2aec43a055835b6b51899aed36c5c4 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 19 Nov 2023 13:04:35 +0000 Subject: [PATCH 4/7] Use mfa_modules for webauthn --- homeassistant/auth/__init__.py | 25 +-- .../webauthn.py | 12 +- .../auth/passkey_modules/__init__.py | 174 ------------------ .../components/auth/passkey_setup_flow.py | 20 +- homeassistant/config.py | 5 +- 5 files changed, 21 insertions(+), 215 deletions(-) rename homeassistant/auth/{passkey_modules => mfa_modules}/webauthn.py (95%) delete mode 100644 homeassistant/auth/passkey_modules/__init__.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 6ca5c7d0d67e..ca3b9dfee94d 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -18,7 +18,6 @@ from . import auth_store, jwt_wrapper, models from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config -from .passkey_modules import PasskeyAuthModule, auth_passkey_module_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config EVENT_USER_ADDED = "user_added" @@ -28,7 +27,6 @@ _LOGGER = logging.getLogger(__name__) _MfaModuleDict = dict[str, MultiFactorAuthModule] -_PasskeyModuleDict = dict[str, PasskeyAuthModule] _ProviderKey = tuple[str, str | None] _ProviderDict = dict[_ProviderKey, AuthProvider] @@ -71,9 +69,6 @@ async def auth_manager_from_config( mfa_modules = await asyncio.gather( *(auth_mfa_module_from_config(hass, config) for config in module_configs) ) - passkey_modules = [ - await auth_passkey_module_from_config(hass, {"type": "webauthn"}) - ] else: mfa_modules = [] @@ -83,13 +78,7 @@ async def auth_manager_from_config( for module in mfa_modules: mfa_module_hash[module.id] = module - passkey_module_hash: _PasskeyModuleDict = OrderedDict() - for module in passkey_modules: - passkey_module_hash[module.id] = module - - manager = AuthManager( - hass, store, provider_hash, mfa_module_hash, passkey_module_hash - ) + manager = AuthManager(hass, store, provider_hash, mfa_module_hash) return manager @@ -166,14 +155,12 @@ def __init__( store: auth_store.AuthStore, providers: _ProviderDict, mfa_modules: _MfaModuleDict, - passkey_modules: _PasskeyModuleDict, ) -> None: """Initialize the auth manager.""" self.hass = hass self._store = store self._providers = providers self._mfa_modules = mfa_modules - self._passkey_modules = passkey_modules self.login_flow = AuthManagerFlowManager(hass, self) self._revoke_callbacks: dict[str, list[CALLBACK_TYPE]] = {} @@ -187,11 +174,6 @@ def auth_mfa_modules(self) -> list[MultiFactorAuthModule]: """Return a list of available auth modules.""" return list(self._mfa_modules.values()) - @property - def auth_passkey_modules(self) -> list[PasskeyAuthModule]: - """Return a list of available auth modules.""" - return list(self._passkey_modules.values()) - def get_auth_provider( self, provider_type: str, provider_id: str | None ) -> AuthProvider | None: @@ -210,11 +192,6 @@ def get_auth_mfa_module(self, module_id: str) -> MultiFactorAuthModule | None: """Return a multi-factor auth module, None if not found.""" return self._mfa_modules.get(module_id) - def get_auth_passkey_module(self, module_id: str) -> PasskeyAuthModule | None: - """Return a passkey auth module, None if not found.""" - _LOGGER.info(self._passkey_modules) - return self._passkey_modules.get(module_id) - async def async_get_users(self) -> list[models.User]: """Retrieve all users.""" return await self._store.async_get_users() diff --git a/homeassistant/auth/passkey_modules/webauthn.py b/homeassistant/auth/mfa_modules/webauthn.py similarity index 95% rename from homeassistant/auth/passkey_modules/webauthn.py rename to homeassistant/auth/mfa_modules/webauthn.py index 9d7378fcf3db..f68fedcc0718 100644 --- a/homeassistant/auth/passkey_modules/webauthn.py +++ b/homeassistant/auth/mfa_modules/webauthn.py @@ -14,9 +14,9 @@ from homeassistant.helpers.storage import Store from . import ( - PASSKEY_AUTH_MODULE_SCHEMA, - PASSKEY_AUTH_MODULES, - PasskeyAuthModule, + MULTI_FACTOR_AUTH_MODULE_SCHEMA, + MULTI_FACTOR_AUTH_MODULES, + MultiFactorAuthModule, SetupFlow, ) @@ -24,7 +24,7 @@ REQUIREMENTS = ["webauthn==1.11.1"] -CONFIG_SCHEMA = PASSKEY_AUTH_MODULE_SCHEMA.extend({}, extra=vol.PREVENT_EXTRA) +CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({}, extra=vol.PREVENT_EXTRA) STORAGE_VERSION = 1 STORAGE_KEY = "auth_module.webauthn" @@ -72,8 +72,8 @@ def _generate_verification( return json.loads(json_options) -@PASSKEY_AUTH_MODULES.register("webauthn") -class WebauthnAuthModule(PasskeyAuthModule): +@MULTI_FACTOR_AUTH_MODULES.register("webauthn") +class WebauthnAuthModule(MultiFactorAuthModule): """Auth module validate time-based one time password.""" DEFAULT_TITLE = "Time-based One Time Password" diff --git a/homeassistant/auth/passkey_modules/__init__.py b/homeassistant/auth/passkey_modules/__init__.py deleted file mode 100644 index f4fe22084589..000000000000 --- a/homeassistant/auth/passkey_modules/__init__.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Pluggable auth modules for Home Assistant.""" -from __future__ import annotations - -import importlib -import logging -import types -from typing import Any - -import voluptuous as vol -from voluptuous.humanize import humanize_error - -from homeassistant import data_entry_flow, requirements -from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.decorator import Registry - -PASSKEY_AUTH_MODULES: Registry[str, type[PasskeyAuthModule]] = Registry() - -PASSKEY_AUTH_MODULE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_TYPE): object, - vol.Optional(CONF_NAME): object, - # Specify ID if you have two mfa auth module for same type. - vol.Optional(CONF_ID): object, - }, - extra=vol.ALLOW_EXTRA, -) - -DATA_REQS = "mfa_auth_module_reqs_processed" - -_LOGGER = logging.getLogger(__name__) - - -class PasskeyAuthModule: - """Multi-factor Auth Module of validation function.""" - - DEFAULT_TITLE = "Unnamed auth module" - MAX_RETRY_TIME = 3 - - def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: - """Initialize an auth module.""" - self.hass = hass - self.config = config - - @property - def id(self) -> str: - """Return id of the auth module. - - Default is same as type - """ - return self.config.get(CONF_ID, self.type) # type: ignore[no-any-return] - - @property - def type(self) -> str: - """Return type of the module.""" - return self.config[CONF_TYPE] # type: ignore[no-any-return] - - @property - def name(self) -> str: - """Return the name of the auth module.""" - return self.config.get(CONF_NAME, self.DEFAULT_TITLE) # type: ignore[no-any-return] - - # Implement by extending class - - @property - def input_schema(self) -> vol.Schema: - """Return a voluptuous schema to define mfa auth module's input.""" - raise NotImplementedError - - async def async_setup_flow(self, user_id: str) -> SetupFlow: - """Return a data entry flow handler for setup module. - - Mfa module should extend SetupFlow - """ - raise NotImplementedError - - async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: - """Set up user for mfa auth module.""" - raise NotImplementedError - - async def async_depose_user(self, user_id: str) -> None: - """Remove user from mfa module.""" - raise NotImplementedError - - async def async_is_user_setup(self, user_id: str) -> bool: - """Return whether user is setup.""" - raise NotImplementedError - - async def async_validate(self, user_id: str, user_input: dict[str, Any]) -> bool: - """Return True if validation passed.""" - raise NotImplementedError - - -class SetupFlow(data_entry_flow.FlowHandler): - """Handler for the setup flow.""" - - def __init__( - self, auth_module: PasskeyAuthModule, setup_schema: vol.Schema, user_id: str - ) -> None: - """Initialize the setup flow.""" - self._auth_module = auth_module - self._setup_schema = setup_schema - self._user_id = user_id - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> FlowResult: - """Handle the first step of setup flow. - - Return self.async_show_form(step_id='init') if user_input is None. - Return self.async_create_entry(data={'result': result}) if finish. - """ - errors: dict[str, str] = {} - - if user_input: - result = await self._auth_module.async_setup_user(self._user_id, user_input) - return self.async_create_entry(data={"result": result}) - - return self.async_show_form( - step_id="init", data_schema=self._setup_schema, errors=errors - ) - - -async def auth_passkey_module_from_config( - hass: HomeAssistant, config: dict[str, Any] -) -> PasskeyAuthModule: - """Initialize an auth module from a config.""" - module_name: str = config[CONF_TYPE] - module = await _load_passkey_module(hass, module_name) - - try: - config = module.CONFIG_SCHEMA(config) - except vol.Invalid as err: - _LOGGER.error( - "Invalid configuration for multi-factor module %s: %s", - module_name, - humanize_error(config, err), - ) - raise - - return PASSKEY_AUTH_MODULES[module_name](hass, config) - - -async def _load_passkey_module( - hass: HomeAssistant, module_name: str -) -> types.ModuleType: - """Load an mfa auth module.""" - module_path = f"homeassistant.auth.passkey_modules.{module_name}" - - try: - module = importlib.import_module(module_path) - except ImportError as err: - _LOGGER.error("Unable to load passkey module %s: %s", module_name, err) - raise HomeAssistantError( - f"Unable to load passkey module {module_name}: {err}" - ) from err - - if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): - return module - - processed = hass.data.get(DATA_REQS) - if processed and module_name in processed: - return module - - processed = hass.data[DATA_REQS] = set() - - await requirements.async_process_requirements( - hass, module_path, module.REQUIREMENTS - ) - - processed.add(module_name) - return module diff --git a/homeassistant/components/auth/passkey_setup_flow.py b/homeassistant/components/auth/passkey_setup_flow.py index 7876a45a0d49..96f26804067f 100644 --- a/homeassistant/components/auth/passkey_setup_flow.py +++ b/homeassistant/components/auth/passkey_setup_flow.py @@ -16,12 +16,12 @@ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( { vol.Required("type"): WS_TYPE_PASSKEY_REGISTER, - vol.Exclusive("passkey_module_id", "module_or_flow_id"): str, + vol.Exclusive("mfa_module_id", "module_or_flow_id"): str, vol.Exclusive("flow_id", "module_or_flow_id"): str, vol.Optional("credential", "module_or_flow_id"): object, } ), - cv.has_at_least_one_key("passkey_module_id", "flow_id"), + cv.has_at_least_one_key("mfa_module_id", "flow_id"), ) DATA_SETUP_FLOW_MGR = "auth_passkey_setup_flow_manager" @@ -40,12 +40,12 @@ async def async_create_flow( # type: ignore[override] data: dict[str, Any], ) -> data_entry_flow.FlowHandler: """Create a setup flow. handler is a passkey module.""" - passkey_module = self.hass.auth.get_auth_passkey_module(handler_key) - if passkey_module is None: - raise ValueError(f"Passkey module {handler_key} is not found") + mfa_module = self.hass.auth.get_auth_mfa_module(handler_key) + if mfa_module is None: + raise ValueError(f"MFA module {handler_key} is not found") user_id = data.pop("user_id") - return await passkey_module.async_setup_flow(user_id) + return await mfa_module.async_setup_flow(user_id) async def async_finish_flow( self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult @@ -87,19 +87,19 @@ async def async_setup_flow(msg: dict[str, Any]) -> None: # ) return - passkey_module_id = msg["passkey_module_id"] - if hass.auth.get_auth_passkey_module(passkey_module_id) is None: + mfa_module_id = msg["mfa_module_id"] + if hass.auth.get_auth_mfa_module(mfa_module_id) is None: connection.send_message( websocket_api.error_message( msg["id"], "no_module", - f"Passkey module {passkey_module_id} is not found", + f"Passkey module {mfa_module_id} is not found", ) ) return result = await flow_manager.async_init( - passkey_module_id, data={"user_id": connection.user.id} + mfa_module_id, data={"user_id": connection.user.id} ) _LOGGER.info(result) diff --git a/homeassistant/config.py b/homeassistant/config.py index 6a840b017144..7629cc529e74 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -735,7 +735,10 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non mfa_conf = config.get( CONF_AUTH_MFA_MODULES, - [{"type": "totp", "id": "totp", "name": "Authenticator app"}], + [ + {"type": "totp", "id": "totp", "name": "Authenticator app"}, + {"type": "webauthn", "id": "webauthn", "name": "Passkey"}, + ], ) setattr( From da8585c4c39a1c6036b914d170c80469a36d99e2 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 19 Nov 2023 15:27:54 +0000 Subject: [PATCH 5/7] Add list of passkeys in frontend/profile --- homeassistant/auth/__init__.py | 16 ++++++++++++++++ homeassistant/auth/mfa_modules/webauthn.py | 7 +++++++ homeassistant/components/auth/__init__.py | 2 ++ 3 files changed, 25 insertions(+) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index ca3b9dfee94d..08e8d9f24bc9 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -174,6 +174,22 @@ def auth_mfa_modules(self) -> list[MultiFactorAuthModule]: """Return a list of available auth modules.""" return list(self._mfa_modules.values()) + async def async_get_passkeys(self, user: models.User) -> dict[str, str]: + """Return a list of passkeys.""" + + passkeys = [] + + for module in self._mfa_modules.values(): + if module.id == "webauthn": + rv = await module.get_passkeys() + + ## append passkey to list when user id matches + for key in rv: + if rv[key]["user_id"] == user.id: + passkeys.append({"id": key, "name": "fakename"}) + + return passkeys + def get_auth_provider( self, provider_type: str, provider_id: str | None ) -> AuthProvider | None: diff --git a/homeassistant/auth/mfa_modules/webauthn.py b/homeassistant/auth/mfa_modules/webauthn.py index f68fedcc0718..94184f7174bd 100644 --- a/homeassistant/auth/mfa_modules/webauthn.py +++ b/homeassistant/auth/mfa_modules/webauthn.py @@ -107,6 +107,13 @@ async def _async_load(self) -> None: self._passkeys = data.get(STORAGE_PASSKEYS, {}) + async def get_passkeys(self) -> dict[str, str]: + """Return passkeys.""" + if self._passkeys is None: + await self._async_load() + + return self._passkeys + async def _async_save(self) -> None: """Save data.""" await self._user_store.async_save({STORAGE_PASSKEYS: self._passkeys or {}}) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 382f89363902..7d675ee8973d 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -486,6 +486,7 @@ async def websocket_current_user( """Return the current user.""" user = connection.user enabled_modules = await hass.auth.async_get_enabled_mfa(user) + passkeys = await hass.auth.async_get_passkeys(user) connection.send_message( websocket_api.result_message( @@ -510,6 +511,7 @@ async def websocket_current_user( } for module in hass.auth.auth_mfa_modules ], + "passkeys": passkeys, }, ) ) From 645a5b06b791c5b9b3bceeae03bea4b56624e041 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 19 Nov 2023 15:34:24 +0000 Subject: [PATCH 6/7] Undo some unneccary changes --- homeassistant/auth/__init__.py | 12 ++++++------ homeassistant/auth/models.py | 23 +---------------------- 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 08e8d9f24bc9..73ed3f67508b 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -66,19 +66,19 @@ async def auth_manager_from_config( provider_hash[key] = provider if module_configs: - mfa_modules = await asyncio.gather( + modules = await asyncio.gather( *(auth_mfa_module_from_config(hass, config) for config in module_configs) ) else: - mfa_modules = [] + modules = [] # So returned auth modules are in same order as config - mfa_module_hash: _MfaModuleDict = OrderedDict() - for module in mfa_modules: - mfa_module_hash[module.id] = module + module_hash: _MfaModuleDict = OrderedDict() + for module in modules: + module_hash[module.id] = module - manager = AuthManager(hass, store, provider_hash, mfa_module_hash) + manager = AuthManager(hass, store, provider_hash, module_hash) return manager diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 3474d6700992..ae7012508f30 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -131,31 +131,10 @@ class Credentials: @attr.s(slots=True) class Passkey: - """{ - "credentialId": "0wOEfoJpRMBS6_ZP7g_o9sQXtQs", - "credentialPublicKey": "pQECAyYgASFYIGLxzIyvc1E_peYbdYnRiWCZv16QKNwSzK5o2Q9NMnRXIlggMXGUmvQcb37_t6kQGgepYtUZeD76quYJZLO8OpCeB-E", - "signCount": 0, - "aaguid": "00000000-0000-0000-0000-000000000000", - "fmt": "none", - "credentialType": "public-key", - "userVerified": True, - "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAAAAAAAAAAAAAAAAAAAAAAAAFNMDhH6CaUTAUuv2T-4P6PbEF7ULpQECAyYgASFYIGLxzIyvc1E_peYbdYnRiWCZv16QKNwSzK5o2Q9NMnRXIlggMXGUmvQcb37_t6kQGgepYtUZeD76quYJZLO8OpCeB-E", - "credentialDeviceType": "multi_device", - "credentialBackedUp": true, - } - """ - """Passkey for user on an auth provider.""" + credential_id: str = attr.ib() credential_public_key: str = attr.ib() - sign_count: int = attr.ib() - aaguid: str = attr.ib() - fmt: str = attr.ib() - credential_type: str = attr.ib() - user_verified: bool = attr.ib() - attestation_object: str = attr.ib() - credential_device_type: str = attr.ib() - credential_backed_up: bool = attr.ib() class UserMeta(NamedTuple): From 2418101cfe05da6d2ef6eaa34819091230b373af Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 26 Nov 2023 11:09:57 +0000 Subject: [PATCH 7/7] Move away from MFA module and use authprovider --- homeassistant/auth/mfa_modules/webauthn.py | 209 ----------- homeassistant/auth/providers/webauthn.py | 352 ++++++++++++++++++ homeassistant/components/auth/__init__.py | 6 +- .../components/auth/passkey_setup_flow.py | 116 ------ homeassistant/components/config/__init__.py | 1 + .../config/auth_provider_webauthn.py | 78 ++++ homeassistant/config.py | 1 - passkey-frontend.patch | 272 ++++++++++++++ 8 files changed, 705 insertions(+), 330 deletions(-) delete mode 100644 homeassistant/auth/mfa_modules/webauthn.py create mode 100644 homeassistant/auth/providers/webauthn.py delete mode 100644 homeassistant/components/auth/passkey_setup_flow.py create mode 100644 homeassistant/components/config/auth_provider_webauthn.py create mode 100644 passkey-frontend.patch diff --git a/homeassistant/auth/mfa_modules/webauthn.py b/homeassistant/auth/mfa_modules/webauthn.py deleted file mode 100644 index 94184f7174bd..000000000000 --- a/homeassistant/auth/mfa_modules/webauthn.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Time-based One Time Password auth module.""" -from __future__ import annotations - -import asyncio -import json -import logging -from typing import Any, Optional, cast - -import voluptuous as vol - -from homeassistant.auth.models import User -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.storage import Store - -from . import ( - MULTI_FACTOR_AUTH_MODULE_SCHEMA, - MULTI_FACTOR_AUTH_MODULES, - MultiFactorAuthModule, - SetupFlow, -) - -_LOGGER = logging.getLogger(__name__) - -REQUIREMENTS = ["webauthn==1.11.1"] - -CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({}, extra=vol.PREVENT_EXTRA) - -STORAGE_VERSION = 1 -STORAGE_KEY = "auth_module.webauthn" -STORAGE_PASSKEYS = "passkeys" -STORAGE_USER_ID = "user_id" -STORAGE_CREDENTIAL_PUBLIC_KEY = "credential_public_key" - - -def _generate_options( - user_id: str, username: str -) -> tuple[dict[str, str], Optional[bytes]]: - """Generate a webauthn options.""" - from webauthn import generate_registration_options, options_to_json - - options = generate_registration_options( - rp_id="localhost", # TODO: Find actual url - rp_name="Home Assistant", - user_id=user_id, - user_name=username, - ) - - # Hacky method to get an object we can send - # Simply calling json.dumps() on the options object fails - json_options = options_to_json(options) - return (json.loads(json_options), options.challenge) - - -def _generate_verification( - credential: dict[str:str], challenge: Optional[bytes] -) -> dict[str, str]: - """Generate a secret, url, and QR code.""" - from webauthn import options_to_json, verify_registration_response - - registration_verification = verify_registration_response( - credential=credential, - expected_challenge=challenge, - expected_origin="http://localhost:8123", # TODO: Find actual origin - expected_rp_id="localhost", # TODO: Find actual URL - require_user_verification=True, - ) - - # Hacky method to get an object we can send - # Simply calling json.dumps() on the options object fails - json_options = options_to_json(registration_verification) - return json.loads(json_options) - - -@MULTI_FACTOR_AUTH_MODULES.register("webauthn") -class WebauthnAuthModule(MultiFactorAuthModule): - """Auth module validate time-based one time password.""" - - DEFAULT_TITLE = "Time-based One Time Password" - MAX_RETRY_TIME = 5 - - def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: - """Initialize the user data store.""" - super().__init__(hass, config) - self._passkeys: dict[str, str] | None = None - self._user_store = Store[dict[str, dict[str, str]]]( - hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True - ) - self._init_lock = asyncio.Lock() - - @property - def input_schema(self) -> vol.Schema: - """Validate login flow input data.""" - return vol.Schema( - {vol.Required("id"): str}, - extra=vol.ALLOW_EXTRA, - ) - - async def _async_load(self) -> None: - """Load stored data.""" - async with self._init_lock: - if self._passkeys is not None: - return - - if (data := await self._user_store.async_load()) is None: - data = cast(dict[str, dict[str, str]], {STORAGE_PASSKEYS: {}}) - - self._passkeys = data.get(STORAGE_PASSKEYS, {}) - - async def get_passkeys(self) -> dict[str, str]: - """Return passkeys.""" - if self._passkeys is None: - await self._async_load() - - return self._passkeys - - async def _async_save(self) -> None: - """Save data.""" - await self._user_store.async_save({STORAGE_PASSKEYS: self._passkeys or {}}) - - async def async_setup_flow(self, user_id: str) -> SetupFlow: - """Return a data entry flow handler for setup module. - - Mfa module should extend SetupFlow - """ - user = await self.hass.auth.async_get_user(user_id) - assert user is not None - return WebauthnSetupFlow(self, self.input_schema, user) - - async def async_setup_user(self, user_id: str, setup_data: Any) -> str: - """Set up auth module for user.""" - if self._passkeys is None: - await self._async_load() - - # If the validation process succeeded, the server would then store the publicKeyBytes and credentialId in a database, associated with the user. - passkey = setup_data.get("passkey") - - self._passkeys[passkey["credentialId"]] = { - STORAGE_USER_ID: user_id, - STORAGE_CREDENTIAL_PUBLIC_KEY: passkey["credentialPublicKey"], - } - - await self._async_save() - return self._passkeys[passkey["credentialId"]] - - async def async_depose_user(self, user_id: str) -> None: - """Depose auth module for user.""" - if self._passkeys is None: - await self._async_load() - - if self._passkeys.pop(user_id, None): # type: ignore[union-attr] - await self._async_save() - - async def async_is_user_setup(self, user_id: str) -> bool: - """Return whether user is setup.""" - if self._passkeys is None: - await self._async_load() - - return user_id in self._passkeys # type: ignore[operator] - - -class WebauthnSetupFlow(SetupFlow): - """Handler for the setup flow.""" - - def __init__( - self, auth_module: WebauthnAuthModule, setup_schema: vol.Schema, user: User - ) -> None: - """Initialize the setup flow.""" - super().__init__(auth_module, setup_schema, user.id) - # to fix typing complaint - self._auth_module: WebauthnAuthModule = auth_module - self._user = user - self._challenge: Optional[bytes] - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> FlowResult: - errors: dict[str, str] = {} - options: dict[str, str] = {} - - if user_input: - hass = self._auth_module.hass - passkey = await hass.async_add_executor_job( - _generate_verification, - user_input, - self._challenge, - ) - self._challenge = None - result = await self._auth_module.async_setup_user( - self._user_id, {"passkey": passkey} - ) - _LOGGER.info("Completed") - - else: - hass = self._auth_module.hass - (options, self._challenge) = await hass.async_add_executor_job( - _generate_options, - str(self._user.id), - str(self._user.name), - ) - - return self.async_show_form( - step_id="init", - data_schema=self._setup_schema, - description_placeholders={ - "options": options, - }, - errors=errors, - ) diff --git a/homeassistant/auth/providers/webauthn.py b/homeassistant/auth/providers/webauthn.py new file mode 100644 index 000000000000..684ce1548e65 --- /dev/null +++ b/homeassistant/auth/providers/webauthn.py @@ -0,0 +1,352 @@ +"""Home Assistant auth provider.""" +from __future__ import annotations + +import asyncio +import base64 +from collections.abc import Mapping +import logging +from typing import Any, cast + +import bcrypt +import voluptuous as vol + +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.storage import Store +from homeassistant.auth.models import User + +from homeassistant.data_entry_flow import FlowResultType + +from ..models import Credentials, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow + +import asyncio +import json +import logging +from typing import Any, Optional, cast + +import voluptuous as vol + +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.storage import Store + +from webauthn.authentication.verify_authentication_response import ( + VerifiedAuthentication, +) + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ["webauthn==1.11.1"] + +STORAGE_VERSION = 1 +STORAGE_KEY = "auth_provider.webauthn" + + +def _disallow_id(conf: dict[str, Any]) -> dict[str, Any]: + """Disallow ID in config.""" + if CONF_ID in conf: + raise vol.Invalid("ID is not allowed for the homeassistant auth provider.") + + return conf + + +CONFIG_SCHEMA = vol.All(AUTH_PROVIDER_SCHEMA, _disallow_id) + + +@callback +def async_get_provider(hass: HomeAssistant) -> WebauthnAuthProvider: + """Get the provider.""" + for prv in hass.auth.auth_providers: + if prv.type == "webauthn": + return cast(WebauthnAuthProvider, prv) + + raise RuntimeError("Provider not found") + + +class InvalidAuth(HomeAssistantError): + """Raised when we encounter invalid authentication.""" + + +class InvalidUser(HomeAssistantError): + """Raised when invalid user is specified. + + Will not be raised when validating authentication. + """ + + +class Data: + """Hold the user data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the user data store.""" + self.hass = hass + self._store = Store[dict[str, list[dict[str, str]]]]( + hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True + ) + self._data: dict[str, list[dict[str, str]]] | None = None + self._challenge: bytes | None = None + + async def async_load(self) -> None: + """Load stored data.""" + if (data := await self._store.async_load()) is None: + data = cast(dict[str, list[dict[str, str]]], {"users": []}) + + self._data = data + + @property + def users(self) -> list[dict[str, str]]: + """Return users.""" + assert self._data is not None + return self._data["users"] + + # def validate_login(self, username: str, password: str) -> None: + # """Validate a username and password. + + # Raises InvalidAuth if auth invalid. + # """ + # username = self.normalize_username(username) + # dummy = b"$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO" + # found = None + + # # Compare all users to avoid timing attacks. + # for user in self.users: + # if self.normalize_username(user["username"]) == username: + # found = user + + # if found is None: + # # check a hash to make timing the same as if user was found + # bcrypt.checkpw(b"foo", dummy) + # raise InvalidAuth + + # user_hash = base64.b64decode(found["password"]) + + # # bcrypt.checkpw is timing-safe + # if not bcrypt.checkpw(password.encode(), user_hash): + # raise InvalidAuth + + # def hash_password(self, password: str, for_storage: bool = False) -> bytes: + # """Encode a password.""" + # hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) + + # if for_storage: + # hashed = base64.b64encode(hashed) + # return hashed + + def add_auth(self, username: str, password: str) -> None: + """Add a new authenticated user/pass.""" + username = self.normalize_username(username) + + if any( + self.normalize_username(user["username"]) == username for user in self.users + ): + raise InvalidUser + + self.users.append( + { + "username": username, + "password": self.hash_password(password, True).decode(), + } + ) + + async def async_generate_register_options( + self, user_id: str, username: str + ) -> tuple[dict[str, str], Optional[bytes]]: + from webauthn import generate_registration_options, options_to_json + + options = generate_registration_options( + rp_id="localhost", # TODO: Find actual url + rp_name="Home Assistant", + user_id=user_id, + user_name=username, + ) + + self._challenge = options.challenge + + # Hacky method to get an object we can send + # Simply calling json.dumps() on the options object fails + json_options = options_to_json(options) + return json.loads(json_options) + + async def async_generate_verification( + self, user_id: str, credential: dict[str:str] + ) -> dict[str, str]: + """Generate a secret, url, and QR code.""" + from webauthn import options_to_json, verify_registration_response + + challenge = self._challenge + self._challenge = None + + registration_verification = verify_registration_response( + credential=credential, + expected_challenge=challenge, + expected_origin="http://localhost:8123", # TODO: Find actual origin + expected_rp_id="localhost", # TODO: Find actual URL + require_user_verification=True, + ) + + # Hacky method to get an object we can send + # Simply calling json.dumps() on the options object fails + json_options = options_to_json(registration_verification) + passkey = json.loads(json_options) + + self.users.append( + { + "user_id": user_id, + "credential_id": passkey["credentialId"], + "credential_public_key": passkey["credentialPublicKey"], + } + ) + await self.async_save() + + return passkey + + async def async_generate_auth_options( + self, + ) -> tuple[dict[str, str], Optional[bytes]]: + from webauthn import generate_authentication_options, options_to_json + + options = generate_authentication_options( + rp_id="localhost", # TODO: Find actual url + ) + + self._challenge = options.challenge + + # Hacky method to get an object we can send + # Simply calling json.dumps() on the options object fails + json_options = options_to_json(options) + return json.loads(json_options) + + async def async_validate_login( + self, credential: dict[str:str] + ) -> VerifiedAuthentication: + from webauthn import verify_authentication_response, options_to_json + + challenge = self._challenge + self._challenge = None + + # Find credential by ID + found = None + for user in self.users: + if user["credential_id"] == credential["id"]: + found = user + + if found is None: + raise InvalidAuth + + return verify_authentication_response( + credential=credential, + expected_challenge=challenge, + expected_origin="http://localhost:8123", # TODO: Find actual origin + expected_rp_id="localhost", # TODO: Find actual URL + credential_public_key=found["credential_public_key"], + credential_current_sign_count=1, + require_user_verification=True, + ) + + async def async_save(self) -> None: + """Save data.""" + if self._data is not None: + await self._store.async_save(self._data) + + +@AUTH_PROVIDERS.register("webauthn") +class WebauthnAuthProvider(AuthProvider): + """Auth provider based on a local storage of users in Home Assistant config dir.""" + + DEFAULT_TITLE = "Home Assistant Local" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize an Home Assistant auth provider.""" + super().__init__(*args, **kwargs) + self.data: Data | None = None + self._init_lock = asyncio.Lock() + + async def async_initialize(self) -> None: + """Initialize the auth provider.""" + async with self._init_lock: + if self.data is not None: + return + + data = Data(self.hass) + await data.async_load() + self.data = data + + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: + """Return a flow to login.""" + return HassLoginFlow(self) + + async def async_generate_webauthn_options( + self, user: User + ) -> tuple[dict[str, str], Optional[bytes]]: + """Generate webauthn options.""" + if self.data is None: + await self.async_initialize() + assert self.data is not None + + return await self.data.async_generate_register_options(user.id, user.name) + + async def async_add_auth(self, user: User, credential: dict[str:str]) -> bool: + """Validate webauthn registration.""" + if self.data is None: + await self.async_initialize() + assert self.data is not None + + ## TODO: Catch exceptions + await self.data.async_generate_verification(user.id, credential) + + return True + + async def async_generate_authentication_options(self) -> None: + """Validate a username and password.""" + if self.data is None: + await self.async_initialize() + assert self.data is not None + + return await self.data.async_generate_auth_options() + + async def async_login(self, credentials: dict[str:str]) -> None: + """Validate a username and password.""" + if self.data is None: + await self.async_initialize() + assert self.data is not None + + return await self.data.async_validate_login(credentials) + + +class HassLoginFlow(LoginFlow): + """Handler for the login flow.""" + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the step of the form.""" + errors = {} + + if user_input is not None: + try: + await cast(WebauthnAuthProvider, self._auth_provider).async_login() + except InvalidAuth: + errors["base"] = "invalid_auth" + + if not errors: + return await self.async_finish(user_input) + + options = await cast( + WebauthnAuthProvider, self._auth_provider + ).async_generate_authentication_options() + + # return self.async_show_form( + # step_id="init", + # data_schema=vol.Schema( + # { + # vol.Required("username"): str, + # } + # ), + # description_placeholders=options, + # errors=errors, + # ) + return self.async_external_step(step_id="init", url="test.nl", description_placeholders=options) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 7d675ee8973d..185075304178 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -158,7 +158,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util -from . import indieauth, login_flow, mfa_setup_flow, passkey_setup_flow +from . import indieauth, login_flow, mfa_setup_flow DOMAIN = "auth" @@ -196,7 +196,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await login_flow.async_setup(hass, store_result) await mfa_setup_flow.async_setup(hass) - await passkey_setup_flow.async_setup(hass) return True @@ -486,7 +485,6 @@ async def websocket_current_user( """Return the current user.""" user = connection.user enabled_modules = await hass.auth.async_get_enabled_mfa(user) - passkeys = await hass.auth.async_get_passkeys(user) connection.send_message( websocket_api.result_message( @@ -511,7 +509,7 @@ async def websocket_current_user( } for module in hass.auth.auth_mfa_modules ], - "passkeys": passkeys, + "passkeys": [], }, ) ) diff --git a/homeassistant/components/auth/passkey_setup_flow.py b/homeassistant/components/auth/passkey_setup_flow.py deleted file mode 100644 index 96f26804067f..000000000000 --- a/homeassistant/components/auth/passkey_setup_flow.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Helpers to setup multi-factor auth module.""" -from __future__ import annotations - -import logging -from typing import Any - -import voluptuous as vol - -from homeassistant import data_entry_flow -from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv - -WS_TYPE_PASSKEY_REGISTER = "auth/passkey_register" -SCHEMA_WS_PASSKEY_REGISTER = vol.All( - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): WS_TYPE_PASSKEY_REGISTER, - vol.Exclusive("mfa_module_id", "module_or_flow_id"): str, - vol.Exclusive("flow_id", "module_or_flow_id"): str, - vol.Optional("credential", "module_or_flow_id"): object, - } - ), - cv.has_at_least_one_key("mfa_module_id", "flow_id"), -) - -DATA_SETUP_FLOW_MGR = "auth_passkey_setup_flow_manager" - -_LOGGER = logging.getLogger(__name__) - - -class PasskeyFlowManager(data_entry_flow.FlowManager): - """Manage multi factor authentication flows.""" - - async def async_create_flow( # type: ignore[override] - self, - handler_key: str, - *, - context: dict[str, Any], - data: dict[str, Any], - ) -> data_entry_flow.FlowHandler: - """Create a setup flow. handler is a passkey module.""" - mfa_module = self.hass.auth.get_auth_mfa_module(handler_key) - if mfa_module is None: - raise ValueError(f"MFA module {handler_key} is not found") - - user_id = data.pop("user_id") - return await mfa_module.async_setup_flow(user_id) - - async def async_finish_flow( - self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult - ) -> data_entry_flow.FlowResult: - """Complete an mfs setup flow.""" - _LOGGER.debug("flow_result: %s", result) - return result - - -async def async_setup(hass: HomeAssistant) -> None: - """Init passkey setup flow manager.""" - hass.data[DATA_SETUP_FLOW_MGR] = PasskeyFlowManager(hass) - - websocket_api.async_register_command( - hass, - WS_TYPE_PASSKEY_REGISTER, - websocket_passkey_register_request, - SCHEMA_WS_PASSKEY_REGISTER, - ) - - -@callback -@websocket_api.ws_require_user(allow_system_user=False) -def websocket_passkey_register_request( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a setup flow for passkey auth module.""" - - async def async_setup_flow(msg: dict[str, Any]) -> None: - """Return a setup flow for mfa auth module.""" - flow_manager: PasskeyFlowManager = hass.data[DATA_SETUP_FLOW_MGR] - - if (flow_id := msg.get("flow_id")) is not None: - _LOGGER.warning(msg.get("credential")) - result = await flow_manager.async_configure(flow_id, msg.get("credential")) - _LOGGER.warning("Done!") - # connection.send_message( - # websocket_api.result_message(msg["id"], _prepare_result_json(result)) - # ) - return - - mfa_module_id = msg["mfa_module_id"] - if hass.auth.get_auth_mfa_module(mfa_module_id) is None: - connection.send_message( - websocket_api.error_message( - msg["id"], - "no_module", - f"Passkey module {mfa_module_id} is not found", - ) - ) - return - - result = await flow_manager.async_init( - mfa_module_id, data={"user_id": connection.user.id} - ) - - _LOGGER.info(result) - connection.send_message( - websocket_api.result_message( - msg["id"], - { - "flow_id": result["flow_id"], - "options": result["description_placeholders"]["options"], - }, - ) - ) - - hass.async_create_task(async_setup_flow(msg)) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 84a1c2eaa17a..8731c3a34731 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -22,6 +22,7 @@ "area_registry", "auth", "auth_provider_homeassistant", + "auth_provider_webauthn", "automation", "config_entries", "core", diff --git a/homeassistant/components/config/auth_provider_webauthn.py b/homeassistant/components/config/auth_provider_webauthn.py new file mode 100644 index 000000000000..12e2bf6da687 --- /dev/null +++ b/homeassistant/components/config/auth_provider_webauthn.py @@ -0,0 +1,78 @@ +"""Offer API to configure the Home Assistant auth provider.""" +from typing import Any + +import voluptuous as vol + +from homeassistant.auth.providers import webauthn as auth_webauthn +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import Unauthorized +import homeassistant.helpers.config_validation as cv + +import logging + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass): + """Enable the Home Assistant views.""" + websocket_api.async_register_command(hass, websocket_register) + websocket_api.async_register_command(hass, websocket_register_validate) + return True + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/auth_provider/passkey/register", + }, +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_register( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Create credentials and attach to a user.""" + _LOGGER.warning(msg) + _LOGGER.warning(connection) + + provider = auth_webauthn.async_get_provider(hass) + + options = await provider.async_generate_webauthn_options(connection.user) + + connection.send_result( + msg["id"], + { + "options": options, + }, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/auth_provider/passkey/register_validate", + vol.Required("credential"): object, + }, +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_register_validate( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Create credentials and attach to a user.""" + _LOGGER.warning(msg) + _LOGGER.warning(connection) + + provider = auth_webauthn.async_get_provider(hass) + + result = await provider.async_add_auth(connection.user, msg["credential"]) + + connection.send_result( + msg["id"], + { + "result": result, + }, + ) diff --git a/homeassistant/config.py b/homeassistant/config.py index 7629cc529e74..25578cfee0d5 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -737,7 +737,6 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non CONF_AUTH_MFA_MODULES, [ {"type": "totp", "id": "totp", "name": "Authenticator app"}, - {"type": "webauthn", "id": "webauthn", "name": "Passkey"}, ], ) diff --git a/passkey-frontend.patch b/passkey-frontend.patch new file mode 100644 index 000000000000..87f382653928 --- /dev/null +++ b/passkey-frontend.patch @@ -0,0 +1,272 @@ +diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts +index 9be7202c7..be57f645b 100644 +--- a/src/auth/ha-auth-flow.ts ++++ b/src/auth/ha-auth-flow.ts +@@ -212,11 +212,80 @@ export class HaAuthFlow extends LitElement { + ` + : ""} + `; ++ case "external": ++ return html` Log in using Passkey`; + 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 async _generatePasskey(options) { ++ console.log(options); ++ ++ // Base64URL decode the challenge ++ options.challenge = this._base64url.decode(options.challenge); ++ ++ // `allowCredentials` empty array invokes an account selector by discoverable credentials. ++ options.allowCredentials = []; ++ ++ // Invoke WebAuthn get ++ const cred = await navigator.credentials.get({ ++ publicKey: options, ++ // Request a conditional UI ++ // mediation: conditional ? 'conditional' : 'optional' ++ }); ++ ++ console.log(cred) ++ ++ const credential = {}; ++ credential.id = cred.id; ++ credential.type = cred.type; ++ // Base64URL encode `rawId` ++ credential.rawId = this._base64url.encode(cred.rawId); ++ ++ // Base64URL encode some values ++ const clientDataJSON = this._base64url.encode(cred.response.clientDataJSON); ++ const authenticatorData = this._base64url.encode(cred.response.authenticatorData); ++ const signature = this._base64url.encode(cred.response.signature); ++ const userHandle = this._base64url.encode(cred.response.userHandle); ++ ++ credential.response = { ++ clientDataJSON, ++ authenticatorData, ++ signature, ++ userHandle, ++ }; ++ ++ const response = await fetch("", { ++ method: "POST", ++ credentials: "same-origin", ++ body: JSON.stringify({ ++ credenital: credential.response, ++ }), ++ }); ++ ++ } ++ + private _storeTokenChanged(e: CustomEvent) { + this._storeToken = (e.currentTarget as HTMLInputElement).checked; + } +diff --git a/src/panels/profile/ha-panel-profile.ts b/src/panels/profile/ha-panel-profile.ts +index a38fd171c..4b89210db 100644 +--- a/src/panels/profile/ha-panel-profile.ts ++++ b/src/panels/profile/ha-panel-profile.ts +@@ -12,6 +12,7 @@ import { + getOptimisticFrontendUserDataCollection, + } from "../../data/frontend"; + import { RefreshToken } from "../../data/refresh_token"; ++import { Passkey } from "../../data/passkey"; + import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; + import { haStyle } from "../../resources/styles"; + import { HomeAssistant } from "../../types"; +@@ -42,6 +43,7 @@ class HaPanelProfile extends LitElement { + @property({ type: Boolean }) public narrow!: boolean; + + @state() private _refreshTokens?: RefreshToken[]; ++ @state() private _passkeys?: Passkey[]; + + @state() private _coreUserData?: CoreFrontendUserData | null; + +@@ -91,8 +93,13 @@ class HaPanelProfile extends LitElement { + : "" + } + ++ ++ ${this.hass.localize("ui.panel.profile.logout")} ++ ++ + + + +
+- ${ +- this._errorMsg +- ? html`${this._errorMsg}` +- : "" +- } +- ${ +- this._statusMsg +- ? html`${this._statusMsg}` +- : "" +- } +- ++ ${this.passkeys!.map( ++ (passkey) => ++ html` ++ ${passkey.name} ++ ${passkey.id} ++ ` ++ )} + + ${this.hass.localize("ui.panel.profile.setup_passkey.add")} + +@@ -67,28 +50,6 @@ class HaSetupPasskeyCard extends LitElement { + `; + } + +- private _currentPasswordChanged(ev) { +- this._currentPassword = ev.target.value; +- } +- +- private _newPasswordChanged(ev) { +- this._password = ev.target.value; +- } +- +- private _newPasswordConfirmChanged(ev) { +- this._passwordConfirm = ev.target.value; +- } +- +- protected firstUpdated(changedProps: PropertyValues) { +- super.firstUpdated(changedProps); +- this.addEventListener("keypress", (ev) => { +- this._statusMsg = undefined; +- if (ev.key === "Enter") { +- this._changePassword(); +- } +- }); +- } +- + private _base64url = { + encode: function (buffer) { + const base64 = window.btoa( +@@ -107,7 +68,7 @@ class HaSetupPasskeyCard extends LitElement { + }, + }; + +- private async _createPasskey(flow_id: string, options: any) { ++ private async _createPasskey(options: any) { + console.log(options); + + // Base64URL decode some values +@@ -160,8 +121,7 @@ class HaSetupPasskeyCard extends LitElement { + + this.hass + .callWS({ +- type: "auth/passkey_register", +- flow_id: flow_id, ++ type: "config/auth_provider/passkey/register_validate", + credential: credential, + }) + .then(() => { +@@ -169,15 +129,14 @@ class HaSetupPasskeyCard extends LitElement { + }); + } + +- private async _changePassword() { ++ private async _generatePasskey() { + this.hass + .callWS({ +- type: "auth/passkey_register", +- passkey_module_id: "webauthn", ++ type: "config/auth_provider/passkey/register", + }) + .then((data) => { + console.log("passkey setup! ", data); +- this._createPasskey(data["flow_id"], data["options"]); ++ this._createPasskey(data["options"]); + }); + } + +diff --git a/src/types.ts b/src/types.ts +index 8d0d37ee8..bf4b6a8c9 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -12,6 +12,7 @@ import { LocalizeFunc } from "./common/translations/localize"; + import { AreaRegistryEntry } from "./data/area_registry"; + import { DeviceRegistryEntry } from "./data/device_registry"; + import { EntityRegistryDisplayEntry } from "./data/entity_registry"; ++import { Passkey } from "./data/passkey"; + import { CoreFrontendUserData } from "./data/frontend"; + import { FrontendLocaleData, getHassTranslations } from "./data/translation"; + import { Themes } from "./data/ws-themes"; +@@ -99,6 +100,7 @@ export interface CurrentUser { + name: string; + credentials: Credential[]; + mfa_modules: MFAModule[]; ++ passkeys: Passkey[]; + } + + // Currently selected theme and its settings. These are the values stored in local storage.