From 8443da0b9fa31a7bc5d0351e550a0d765865abc6 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 12 Oct 2018 12:21:48 +0200 Subject: [PATCH] Add-on SSO support with Home Assistant auth system (#752) * Create auth.py * Finish auth cache * Add documentation * Add valid schema * Update auth.py * Update auth.py * Update security.py * Create auth.py * Update coresys.py * Update bootstrap.py * Update const.py * Update validate.py * Update const.py * Update addon.py * Update auth.py * Update __init__.py * Update auth.py * Update auth.py * Update auth.py * Update const.py * Update auth.py * Update auth.py * Update auth.py * Update validate.py * Update coresys.py * Update auth.py * Update auth.py * more security * Update API.md * Update auth.py * Update auth.py * Update auth.py * Update auth.py * Update auth.py * Update homeassistant.py * Update homeassistant.py --- API.md | 12 ++++++ hassio/addons/addon.py | 7 ++- hassio/addons/validate.py | 8 ++-- hassio/api/__init__.py | 11 +++++ hassio/api/auth.py | 58 +++++++++++++++++++++++++ hassio/api/security.py | 1 + hassio/auth.py | 91 +++++++++++++++++++++++++++++++++++++++ hassio/bootstrap.py | 2 + hassio/const.py | 3 ++ hassio/coresys.py | 13 ++++++ hassio/exceptions.py | 7 +++ hassio/homeassistant.py | 4 +- hassio/validate.py | 8 +++- 13 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 hassio/api/auth.py create mode 100644 hassio/auth.py diff --git a/API.md b/API.md index ad8fc4129..f5c596f42 100644 --- a/API.md +++ b/API.md @@ -675,3 +675,15 @@ return: "channel": "stable|beta|dev" } ``` + +### Auth / SSO API + +You can use the user system on homeassistant. We handle this auth system on +supervisor. + +You can call post `/auth` + +We support: +- Json `{ "user|name": "...", "password": "..." }` +- application/x-www-form-urlencoded `user|name=...&password=...` +- BasicAuth diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 2efbad2c9..5d9b9f1c9 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -28,7 +28,7 @@ from ..const import ( ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_DISCOVERY, ATTR_SERVICES, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_FULL_ACCESS, ATTR_PROTECTED, ATTR_ACCESS_TOKEN, ATTR_HOST_PID, ATTR_HASSIO_ROLE, - ATTR_MACHINE, + ATTR_MACHINE, ATTR_LOGIN_BACKEND, SECURITY_PROFILE, SECURITY_DISABLE, SECURITY_DEFAULT) from ..coresys import CoreSysAttributes from ..docker.addon import DockerAddon @@ -411,6 +411,11 @@ class Addon(CoreSysAttributes): """Return True if the add-on read access to devicetree.""" return self._mesh[ATTR_DEVICETREE] + @property + def with_login_backend(self): + """Return True if the add-on access to login/auth backend.""" + return self._mesh[ATTR_LOGIN_BACKEND] + @property def with_audio(self): """Return True if the add-on access to audio.""" diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index a62a1666d..dc5afbbc6 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -20,12 +20,13 @@ from ..const import ( ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_SERVICES, ATTR_DISCOVERY, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_PROTECTED, ATTR_FULL_ACCESS, ATTR_ACCESS_TOKEN, ATTR_HOST_PID, ATTR_HASSIO_ROLE, - ATTR_MACHINE, + ATTR_MACHINE, ATTR_LOGIN_BACKEND, PRIVILEGED_NET_ADMIN, PRIVILEGED_SYS_ADMIN, PRIVILEGED_SYS_RAWIO, PRIVILEGED_IPC_LOCK, PRIVILEGED_SYS_TIME, PRIVILEGED_SYS_NICE, PRIVILEGED_SYS_RESOURCE, PRIVILEGED_SYS_PTRACE, PRIVILEGED_DAC_READ_SEARCH, ROLE_DEFAULT, ROLE_HOMEASSISTANT, ROLE_MANAGER, ROLE_ADMIN) -from ..validate import NETWORK_PORT, DOCKER_PORTS, ALSA_DEVICE, UUID_MATCH +from ..validate import ( + NETWORK_PORT, DOCKER_PORTS, ALSA_DEVICE, UUID_MATCH, SHA256) from ..services.validate import DISCOVERY_SERVICES _LOGGER = logging.getLogger(__name__) @@ -143,6 +144,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema({ vol.Optional(ATTR_STDIN, default=False): vol.Boolean(), vol.Optional(ATTR_LEGACY, default=False): vol.Boolean(), vol.Optional(ATTR_DOCKER_API, default=False): vol.Boolean(), + vol.Optional(ATTR_LOGIN_BACKEND, default=False): vol.Boolean(), vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)], vol.Optional(ATTR_DISCOVERY): [vol.In(DISCOVERY_SERVICES)], vol.Required(ATTR_OPTIONS): dict, @@ -187,7 +189,7 @@ SCHEMA_BUILD_CONFIG = vol.Schema({ SCHEMA_ADDON_USER = vol.Schema({ vol.Required(ATTR_VERSION): vol.Coerce(str), vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): UUID_MATCH, - vol.Optional(ATTR_ACCESS_TOKEN): vol.Match(r"^[0-9a-f]{64}$"), + vol.Optional(ATTR_ACCESS_TOKEN): SHA256, vol.Optional(ATTR_OPTIONS, default=dict): dict, vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(), vol.Optional(ATTR_BOOT): diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index cccc1b203..9e9c09cee 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -5,6 +5,7 @@ from pathlib import Path from aiohttp import web from .addons import APIAddons +from .auth import APIAuth from .discovery import APIDiscovery from .homeassistant import APIHomeAssistant from .hardware import APIHardware @@ -49,6 +50,7 @@ class RestAPI(CoreSysAttributes): self._register_discovery() self._register_services() self._register_info() + self._register_auth() def _register_host(self): """Register hostcontrol functions.""" @@ -101,6 +103,15 @@ class RestAPI(CoreSysAttributes): web.get('/info', api_info.info), ]) + def _register_auth(self): + """Register auth functions.""" + api_auth = APIAuth() + api_auth.coresys = self.coresys + + self.webapp.add_routes([ + web.post('/auth', api_auth.auth), + ]) + def _register_supervisor(self): """Register Supervisor functions.""" api_supervisor = APISupervisor() diff --git a/hassio/api/auth.py b/hassio/api/auth.py new file mode 100644 index 000000000..8c9254fb8 --- /dev/null +++ b/hassio/api/auth.py @@ -0,0 +1,58 @@ +"""Init file for Hass.io auth/SSO RESTful API.""" +import logging + +from aiohttp import BasicAuth +from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION + +from .utils import api_process +from ..const import REQUEST_FROM, CONTENT_TYPE_JSON, CONTENT_TYPE_URL +from ..coresys import CoreSysAttributes +from ..exceptions import APIError, APIForbidden + +_LOGGER = logging.getLogger(__name__) + + +class APIAuth(CoreSysAttributes): + """Handle RESTful API for auth functions.""" + + def _process_basic(self, request, addon): + """Process login request with basic auth. + + Return a coroutine. + """ + auth = BasicAuth.decode(request.headers[AUTHORIZATION]) + return self.sys_auth.check_login(addon, auth.login, auth.password) + + def _process_dict(self, request, addon, data): + """Process login with dict data. + + Return a coroutine. + """ + username = data.get('username') or data.get('user') + password = data.get('password') + + return self.sys_auth.check_login(addon, username, password) + + @api_process + async def auth(self, request): + """Process login request.""" + addon = request[REQUEST_FROM] + + if not addon.with_login_backend: + raise APIForbidden("Can't use Home Assistant auth!") + + # BasicAuth + if AUTHORIZATION in request.headers: + return await self._process_basic(request, addon) + + # Json + if request.headers.get(CONTENT_TYPE) == CONTENT_TYPE_JSON: + data = await request.json() + return await self._process_dict(request, addon, data) + + # URL encoded + if request.headers.get(CONTENT_TYPE) == CONTENT_TYPE_URL: + data = await request.post() + return await self._process_dict(request, addon, data) + + raise APIError("Auth method not detected!") diff --git a/hassio/api/security.py b/hassio/api/security.py index d87ee5cb8..aeac3f5bd 100644 --- a/hassio/api/security.py +++ b/hassio/api/security.py @@ -36,6 +36,7 @@ ADDONS_API_BYPASS = re.compile( r"|/info" r"|/services.*" r"|/discovery.*" + r"|/auth" r")$" ) diff --git a/hassio/auth.py b/hassio/auth.py new file mode 100644 index 000000000..dc91d2c29 --- /dev/null +++ b/hassio/auth.py @@ -0,0 +1,91 @@ +"""Manage SSO for Add-ons with Home Assistant user.""" +import logging +import hashlib + +from .const import ( + FILE_HASSIO_AUTH, ATTR_PASSWORD, ATTR_USERNAME, ATTR_ADDON) +from .coresys import CoreSysAttributes +from .utils.json import JsonConfig +from .validate import SCHEMA_AUTH_CONFIG +from .exceptions import AuthError, HomeAssistantAPIError + +_LOGGER = logging.getLogger(__name__) + + +class Auth(JsonConfig, CoreSysAttributes): + """Manage SSO for Add-ons with Home Assistant user.""" + + def __init__(self, coresys): + """Initialize updater.""" + super().__init__(FILE_HASSIO_AUTH, SCHEMA_AUTH_CONFIG) + self.coresys = coresys + + def _check_cache(self, username, password): + """Check password in cache.""" + username_h = _rehash(username) + password_h = _rehash(password, username) + + if self._data.get(username_h) == password_h: + _LOGGER.info("Cache hit for %s", username) + return True + + _LOGGER.warning("No cache hit for %s", username) + return False + + def _update_cache(self, username, password): + """Cache a username, password.""" + username_h = _rehash(username) + password_h = _rehash(password, username) + + if self._data.get(username_h) == password_h: + return + + self._data[username_h] = password_h + self.save_data() + + def _dismatch_cache(self, username): + """Remove user from cache.""" + username_h = _rehash(username) + + self._data.pop(username_h, None) + self.save_data() + + async def check_login(self, addon, username, password): + """Check username login.""" + if password is None: + _LOGGER.error("None as password is not supported!") + raise AuthError() + _LOGGER.info("Auth request from %s for %s", addon.slug, username) + + # Check API state + if not await self.sys_homeassistant.check_api_state(): + _LOGGER.info("Home Assistant not running, check cache") + return self._check_cache(username, password) + + try: + async with self.sys_homeassistant.make_request( + 'post', 'api/hassio_auth', json={ + ATTR_USERNAME: username, + ATTR_PASSWORD: password, + ATTR_ADDON: addon.slug, + }) as req: + + if req.status == 200: + _LOGGER.info("Success login from %s", username) + self._update_cache(username, password) + return True + + _LOGGER.warning("Wrong login from %s", username) + self._dismatch_cache(username) + return False + except HomeAssistantAPIError: + _LOGGER.error("Can't request auth on Home Assistant!") + + raise AuthError() + + +def _rehash(value, salt2=""): + """Rehash a value.""" + for idx in range(1, 20): + value = hashlib.sha256(f"{value}{idx}{salt2}".encode()).hexdigest() + return value diff --git a/hassio/bootstrap.py b/hassio/bootstrap.py index 2aaa67b17..8b236643f 100644 --- a/hassio/bootstrap.py +++ b/hassio/bootstrap.py @@ -8,6 +8,7 @@ from pathlib import Path from colorlog import ColoredFormatter from .core import HassIO +from .auth import Auth from .addons import AddonManager from .api import RestAPI from .const import SOCKET_DOCKER @@ -38,6 +39,7 @@ def initialize_coresys(loop): # Initialize core objects coresys.core = HassIO(coresys) + coresys.auth = Auth(coresys) coresys.updater = Updater(coresys) coresys.api = RestAPI(coresys) coresys.supervisor = Supervisor(coresys) diff --git a/hassio/const.py b/hassio/const.py index 38990218f..1653497e4 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -16,6 +16,7 @@ URL_HASSOS_OTA = ( HASSIO_DATA = Path("/data") +FILE_HASSIO_AUTH = Path(HASSIO_DATA, "auth.json") FILE_HASSIO_ADDONS = Path(HASSIO_DATA, "addons.json") FILE_HASSIO_CONFIG = Path(HASSIO_DATA, "config.json") FILE_HASSIO_HOMEASSISTANT = Path(HASSIO_DATA, "homeassistant.json") @@ -50,6 +51,7 @@ CONTENT_TYPE_PNG = 'image/png' CONTENT_TYPE_JSON = 'application/json' CONTENT_TYPE_TEXT = 'text/plain' CONTENT_TYPE_TAR = 'application/tar' +CONTENT_TYPE_URL = 'application/x-www-form-urlencoded' HEADER_HA_ACCESS = 'x-ha-access' HEADER_TOKEN = 'x-hassio-key' @@ -184,6 +186,7 @@ ATTR_PROTECTED = 'protected' ATTR_RATING = 'rating' ATTR_HASSIO_ROLE = 'hassio_role' ATTR_SUPERVISOR = 'supervisor' +ATTR_LOGIN_BACKEND = 'login_backend' SERVICE_MQTT = 'mqtt' PROVIDE_SERVICE = 'provide' diff --git a/hassio/coresys.py b/hassio/coresys.py index b0bc7739a..0c74420bf 100644 --- a/hassio/coresys.py +++ b/hassio/coresys.py @@ -33,6 +33,7 @@ class CoreSys: # Internal objects pointers self._core = None + self._auth = None self._homeassistant = None self._supervisor = None self._addons = None @@ -122,6 +123,18 @@ class CoreSys: raise RuntimeError("Hass.io already set!") self._core = value + @property + def auth(self): + """Return Auth object.""" + return self._auth + + @auth.setter + def auth(self, value): + """Set a Auth object.""" + if self._auth: + raise RuntimeError("Auth already set!") + self._auth = value + @property def homeassistant(self): """Return Home Assistant object.""" diff --git a/hassio/exceptions.py b/hassio/exceptions.py index 288d5559f..66f9d48bb 100644 --- a/hassio/exceptions.py +++ b/hassio/exceptions.py @@ -57,6 +57,13 @@ class HassioUpdaterError(HassioError): pass +# Auth + +class AuthError(HassioError): + """Auth errors.""" + pass + + # Host class HostError(HassioError): diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index f162f4947..b6afc8d99 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -442,9 +442,9 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): async with self.make_request('get', 'api/') as resp: if resp.status in (200, 201): return True - err = resp.status + status = resp.status + _LOGGER.warning("Home Assistant API config mismatch: %s", status) - _LOGGER.warning("Home Assistant API config mismatch: %d", err) return False async def _block_till_run(self): diff --git a/hassio/validate.py b/hassio/validate.py index 98858ad33..f5a9fdd50 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -23,6 +23,7 @@ DOCKER_IMAGE = vol.Match(r"^[\w{}]+/[\-\w{}]+$") ALSA_DEVICE = vol.Maybe(vol.Match(r"\d+,\d+")) CHANNELS = vol.In([CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV]) UUID_MATCH = vol.Match(r"^[0-9a-f]{32}$") +SHA256 = vol.Match(r"^[0-9a-f]{64}$") SERVICE_ALL = vol.In([SERVICE_MQTT]) @@ -74,7 +75,7 @@ DOCKER_PORTS = vol.Schema({ # pylint: disable=no-value-for-parameter SCHEMA_HASS_CONFIG = vol.Schema({ vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): UUID_MATCH, - vol.Optional(ATTR_ACCESS_TOKEN): vol.Match(r"^[0-9a-f]{64}$"), + vol.Optional(ATTR_ACCESS_TOKEN): SHA256, vol.Optional(ATTR_BOOT, default=True): vol.Boolean(), vol.Inclusive(ATTR_IMAGE, 'custom_hass'): DOCKER_IMAGE, vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str), @@ -120,3 +121,8 @@ SCHEMA_DISCOVERY = vol.Schema([ SCHEMA_DISCOVERY_CONFIG = vol.Schema({ vol.Optional(ATTR_DISCOVERY, default=list): schema_or(SCHEMA_DISCOVERY), }, extra=vol.REMOVE_EXTRA) + + +SCHEMA_AUTH_CONFIG = vol.Schema({ + SHA256: SHA256 +})