diff --git a/API.md b/API.md index d6a6ba4d2..f5c596f42 100644 --- a/API.md +++ b/API.md @@ -663,14 +663,27 @@ return: ### Misc -- GET `/version` +- GET `/info` ```json { "supervisor": "version", "homeassistant": "version", "hassos": "null|version", + "hostname": "name", "machine": "type", "arch": "arch", "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..f9335f7c3 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 + ROLE_DEFAULT, ROLE_HOMEASSISTANT, ROLE_MANAGER, ROLE_ADMIN, ROLE_BACKUP) +from ..validate import ( + NETWORK_PORT, DOCKER_PORTS, ALSA_DEVICE, UUID_MATCH, SHA256) from ..services.validate import DISCOVERY_SERVICES _LOGGER = logging.getLogger(__name__) @@ -84,6 +85,7 @@ PRIVILEGED_ALL = [ ROLE_ALL = [ ROLE_DEFAULT, ROLE_HOMEASSISTANT, + ROLE_BACKUP, ROLE_MANAGER, ROLE_ADMIN, ] @@ -143,6 +145,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 +190,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 4f86a58ac..9e9c09cee 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -5,16 +5,17 @@ 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 from .host import APIHost from .hassos import APIHassOS +from .info import APIInfo from .proxy import APIProxy from .supervisor import APISupervisor from .snapshots import APISnapshots from .services import APIServices -from .version import APIVersion from .security import SecurityMiddleware from ..coresys import CoreSysAttributes @@ -48,7 +49,8 @@ class RestAPI(CoreSysAttributes): self._register_snapshots() self._register_discovery() self._register_services() - self._register_version() + self._register_info() + self._register_auth() def _register_host(self): """Register hostcontrol functions.""" @@ -92,13 +94,22 @@ class RestAPI(CoreSysAttributes): web.get('/hardware/audio', api_hardware.audio), ]) - def _register_version(self): - """Register version functions.""" - api_version = APIVersion() - api_version.coresys = self.coresys + def _register_info(self): + """Register info functions.""" + api_info = APIInfo() + api_info.coresys = self.coresys self.webapp.add_routes([ - web.get('/version', api_version.info), + 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): 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/version.py b/hassio/api/info.py similarity index 80% rename from hassio/api/version.py rename to hassio/api/info.py index c18bc6627..ce29efb04 100644 --- a/hassio/api/version.py +++ b/hassio/api/info.py @@ -1,4 +1,4 @@ -"""Init file for Hass.io version RESTful API.""" +"""Init file for Hass.io info RESTful API.""" import logging from .utils import api_process @@ -10,12 +10,12 @@ from ..coresys import CoreSysAttributes _LOGGER = logging.getLogger(__name__) -class APIVersion(CoreSysAttributes): - """Handle RESTful API for version functions.""" +class APIInfo(CoreSysAttributes): + """Handle RESTful API for info functions.""" @api_process async def info(self, request): - """Show version info.""" + """Show system info.""" return { ATTR_SUPERVISOR: self.sys_supervisor.version, ATTR_HOMEASSISTANT: self.sys_homeassistant.version, diff --git a/hassio/api/security.py b/hassio/api/security.py index c0239e05d..fe824e588 100644 --- a/hassio/api/security.py +++ b/hassio/api/security.py @@ -7,7 +7,7 @@ from aiohttp.web_exceptions import HTTPUnauthorized, HTTPForbidden from ..const import ( HEADER_TOKEN, REQUEST_FROM, ROLE_ADMIN, ROLE_DEFAULT, ROLE_HOMEASSISTANT, - ROLE_MANAGER) + ROLE_MANAGER, ROLE_BACKUP) from ..coresys import CoreSysAttributes _LOGGER = logging.getLogger(__name__) @@ -33,9 +33,10 @@ NO_SECURITY_CHECK = re.compile( ADDONS_API_BYPASS = re.compile( r"^(?:" r"|/addons/self/(?!security|update)[^/]+" - r"|/version" + r"|/info" r"|/services.*" r"|/discovery.*" + r"|/auth" r")$" ) @@ -52,6 +53,11 @@ ADDONS_ROLE_ACCESS = { r"|/homeassistant/.+" r")$" ), + ROLE_BACKUP: re.compile( + r"^(?:" + r"|/snapshots.*" + r")$" + ), ROLE_MANAGER: re.compile( r"^(?:" r"|/homeassistant/.+" 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 d14a33d0e..d6b2bb8d0 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -2,7 +2,7 @@ from pathlib import Path from ipaddress import ip_network -HASSIO_VERSION = '135' +HASSIO_VERSION = '136' URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" URL_HASSIO_VERSION = \ @@ -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' @@ -253,5 +256,6 @@ FEATURES_SERVICES = 'services' ROLE_DEFAULT = 'default' ROLE_HOMEASSISTANT = 'homeassistant' +ROLE_BACKUP = 'backup' ROLE_MANAGER = 'manager' ROLE_ADMIN = 'admin' 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/docker/addon.py b/hassio/docker/addon.py index 6b73de736..4f389194d 100644 --- a/hassio/docker/addon.py +++ b/hassio/docker/addon.py @@ -1,7 +1,6 @@ """Init file for Hass.io add-on Docker object.""" import logging import os -from pathlib import Path import docker import requests @@ -101,7 +100,7 @@ class DockerAddon(DockerInterface): devices = self.addon.devices or [] # Use audio devices - if self.addon.with_audio and AUDIO_DEVICE not in devices: + if self.addon.with_audio and self.sys_hardware.support_audio: devices.append(AUDIO_DEVICE) # Auto mapping UART devices @@ -216,10 +215,8 @@ class DockerAddon(DockerInterface): # Init other hardware mappings # GPIO support - if self.addon.with_gpio: + if self.addon.with_gpio and self.sys_hardware.support_gpio: for gpio_path in ("/sys/class/gpio", "/sys/devices/platform/soc"): - if not Path(gpio_path).exists(): - continue volumes.update({ gpio_path: { 'bind': gpio_path, 'mode': 'rw' 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/misc/hardware.py b/hassio/misc/hardware.py index eeebf8160..2e205a691 100644 --- a/hassio/misc/hardware.py +++ b/hassio/misc/hardware.py @@ -20,6 +20,7 @@ PROC_STAT = Path("/proc/stat") RE_BOOT_TIME = re.compile(r"btime (\d+)") GPIO_DEVICES = Path("/sys/class/gpio") +SOC_DEVICES = Path("/sys/devices/platform/soc") RE_TTY = re.compile(r"tty[A-Z]+") @@ -60,6 +61,11 @@ class Hardware: return dev_list + @property + def support_audio(self): + """Return True if the system have audio support.""" + return bool(self.audio_devices) + @property def audio_devices(self): """Return all available audio interfaces.""" @@ -68,10 +74,8 @@ class Hardware: return {} try: - with ASOUND_CARDS.open('r') as cards_file: - cards = cards_file.read() - with ASOUND_DEVICES.open('r') as devices_file: - devices = devices_file.read() + cards = ASOUND_CARDS.read_text() + devices = ASOUND_DEVICES.read_text() except OSError as err: _LOGGER.error("Can't read asound data: %s", err) return {} @@ -97,6 +101,11 @@ class Hardware: return audio_list + @property + def support_gpio(self): + """Return True if device support GPIOs.""" + return SOC_DEVICES.exists() and GPIO_DEVICES.exists() + @property def gpio_devices(self): """Return list of GPIO interface on device.""" 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 +})