diff --git a/API.md b/API.md index 69736bb3f..794b99f09 100644 --- a/API.md +++ b/API.md @@ -108,6 +108,40 @@ Reload addons/version. Output the raw docker log +### Security + +- GET `/security/info` +```json +{ + "initialize": "bool", + "totp": "bool" +} +``` + +- POST `/security/options` +```json +{ + "password": "xy" +} +``` + +- POST `/security/totp` +```json +{ + "password": "xy" +} +``` + +Return QR-Code + +- POST `/security/session` +```json +{ + "password": "xy", + "totp": "null|123456" +} +``` + ### Host - POST `/host/shutdown` diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index caca6d2f0..c037ee53c 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -8,6 +8,7 @@ from .homeassistant import APIHomeAssistant from .host import APIHost from .network import APINetwork from .supervisor import APISupervisor +from .security import APISecurity _LOGGER = logging.getLogger(__name__) @@ -86,6 +87,15 @@ class RestAPI(object): '/addons/{addon}/options', api_addons.options) self.webapp.router.add_get('/addons/{addon}/logs', api_addons.logs) + def register_security(self): + """Register security function.""" + api_security = APISecurity(self.config, self.loop) + + self.webapp.router.add_get('/security/info', api_security.info) + self.webapp.router.add_post('/security/options', api_security.options) + self.webapp.router.add_post('/security/totp', api_security.totp) + self.webapp.router.add_post('/security/session', api_security.session) + async def start(self): """Run rest api webserver.""" self._handler = self.webapp.make_handler(loop=self.loop) diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index 35c8f67d5..8410fe7d5 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -26,13 +26,11 @@ class APIHomeAssistant(object): @api_process async def info(self, request): """Return host information.""" - info = { + return { ATTR_VERSION: self.homeassistant.version, ATTR_LAST_VERSION: self.config.last_homeassistant, } - return info - @api_process async def update(self, request): """Update homeassistant.""" diff --git a/hassio/api/security.py b/hassio/api/security.py new file mode 100644 index 000000000..12a09f638 --- /dev/null +++ b/hassio/api/security.py @@ -0,0 +1,102 @@ +"""Init file for HassIO security rest api.""" +from datetime import datetime, timedelta +import io +import logging +import hashlib +import os + +from aiohttp import web +import voluptuous as vol +import pyotp +import pyqrcode + +from .util import api_process, api_validate, hash_password +from ..const import ATTR_INITIALIZE, ATTR_PASSWORD, ATTR_TOTP, ATTR_SESSION + +_LOGGER = logging.getLogger(__name__) + +SCHEMA_PASSWORD = vol.Schema({ + vol.Required(ATTR_PASSWORD): vol.Coerce(str), +}) + +SCHEMA_SESSION = SCHEMA_PASSWORD.extend({ + vol.Optional(ATTR_TOTP, default=None): vol.Coerce(str), +}) + + +class APISecurity(object): + """Handle rest api for security functions.""" + + def __init__(self, config, loop): + """Initialize security rest api part.""" + self.config = config + self.loop = loop + + def _check_password(self, body): + """Check if password is valid and security is initialize.""" + if not self.config.security_initialize: + raise RuntimeError("First set a password") + + password = hash_password(body[ATTR_PASSWORD]) + if password != self.config.security_password: + raise RuntimeError("Wrong password") + + @api_process + async def info(self, request): + """Return host information.""" + return { + ATTR_INITIALIZE: self.config.security_initialize, + ATTR_TOTP: self.config.security_totp is not None, + } + + @api_process + async def options(self, request): + """Set options / password.""" + body = await api_validate(SCHEMA_PASSWORD, request) + + if self.config.security_initialize: + raise RuntimeError("Password is already set!") + + self.config.security_password = hash_password(body[ATTR_PASSWORD]) + self.config.security_initialize = True + return True + + @api_process + async def totp(self, request): + """Set and initialze TOTP.""" + body = await api_validate(SCHEMA_PASSWORD, request) + self._check_password(body) + + # generate TOTP + totp_init_key = pyotp.random_base32() + totp = pyotp.TOTP(totp_init_key) + + # init qrcode + buff = io.BytesIO() + + qrcode = pyqrcode.create(totp.provisioning_uri("Hass.IO")) + qrcode.svg(buff) + + # finish + self.config.security_totp = totp_init_key + return web.Response(body=buff.getvalue(), content_type='image/svg+xml') + + @api_process + async def session(self, request): + """Set and initialze session.""" + body = await api_validate(SCHEMA_SESSION, request) + self._check_password(body) + + # check TOTP + if self.config.security_totp: + totp = pyotp.TOTP(self.config.security_totp) + if body[ATTR_TOTP] != totp.now(): + raise RuntimeError("Invalid TOTP token!") + + # create session + valid_until = datetime.now() + timedelta(days=1) + session = hashlib.sha256(os.urandom(54)).hexdigest() + + # store session + self.config.security_sessions = (session, valid_until) + return {ATTR_SESSION: session} diff --git a/hassio/api/util.py b/hassio/api/util.py index 5204e9079..b59352dea 100644 --- a/hassio/api/util.py +++ b/hassio/api/util.py @@ -1,5 +1,6 @@ """Init file for HassIO util for rest api.""" import json +import hashlib import logging from aiohttp import web @@ -32,6 +33,8 @@ def api_process(method): if isinstance(answer, dict): return api_return_ok(data=answer) + if isinstance(answer, web.Response): + return answer elif answer: return api_return_ok() return api_return_error() @@ -101,3 +104,9 @@ async def api_validate(schema, request): raise RuntimeError(humanize_error(data, ex)) from None return data + + +def hash_password(password): + """Hash and salt our passwords.""" + key = ")*()*SALT_HASSIO2123{}6554547485HSKA!!*JSLAfdasda$".format(password) + return hashlib.sha256(key.encode()).hexdigest() diff --git a/hassio/config.py b/hassio/config.py index 8965ced69..363325e4e 100644 --- a/hassio/config.py +++ b/hassio/config.py @@ -1,4 +1,5 @@ """Bootstrap HassIO.""" +from datetime import datetime import logging import json import os @@ -13,6 +14,8 @@ from .tools import ( _LOGGER = logging.getLogger(__name__) +DATETIME_FORMAT = "%Y%m%d %H:%M:%S" + HOMEASSISTANT_CONFIG = PurePath("homeassistant") HOMEASSISTANT_LAST = 'homeassistant_last' @@ -32,6 +35,11 @@ UPSTREAM_BETA = 'upstream_beta' API_ENDPOINT = 'api_endpoint' +SECURITY_INITIALIZE = 'security_initialize' +SECURITY_TOTP = 'security_totp' +SECURITY_PASSWORD = 'security_password' +SECURITY_SESSIONS = 'security_sessions' + # pylint: disable=no-value-for-parameter SCHEMA_CONFIG = vol.Schema({ @@ -41,6 +49,11 @@ SCHEMA_CONFIG = vol.Schema({ vol.Optional(HASSIO_LAST): vol.Coerce(str), vol.Optional(HASSIO_CLEANUP): vol.Coerce(str), vol.Optional(ADDONS_CUSTOM_LIST, default=[]): [vol.Url()], + vol.Optional(SECURITY_INITIALIZE, default=False): vol.Boolean(), + vol.Optional(SECURITY_TOTP): vol.Coerce(str), + vol.Optional(SECURITY_PASSWORD): vol.Coerce(str), + vol.Optional(SECURITY_SESSIONS, default={}): + {vol.Coerce(str): vol.Coerce(str)}, }, extra=vol.REMOVE_EXTRA) @@ -235,3 +248,55 @@ class CoreConfig(Config): self._data[ADDONS_CUSTOM_LIST].remove(repo) self.save() + + @property + def security_initialize(self): + """Return is security was initialize.""" + return self._data[SECURITY_INITIALIZE] + + @security_initialize.setter + def security_initialize(self, value): + """Set is security initialize.""" + self._data[SECURITY_INITIALIZE] = value + self.save() + + @property + def security_totp(self): + """Return the TOTP key.""" + return self._data.get(SECURITY_TOTP) + + @security_totp.setter + def security_totp(self, value): + """Set the TOTP key.""" + self._data[SECURITY_TOTP] = value + self.save() + + @property + def security_password(self): + """Return the password key.""" + return self._data.get(SECURITY_PASSWORD) + + @security_password.setter + def security_password(self, value): + """Set the password key.""" + self._data[SECURITY_PASSWORD] = value + self.save() + + @property + def security_sessions(self): + """Return api sessions.""" + return {session: datetime.strptime(until, DATETIME_FORMAT) for + session, until in self._data[SECURITY_SESSIONS].items()} + + @security_sessions.setter + def security_sessions(self, value): + """Set the a new session.""" + session, valid = value + if valid is None: + self._data[SECURITY_SESSIONS].pop(session, None) + else: + self._data[SECURITY_SESSIONS].update( + {session: valid.strftime(DATETIME_FORMAT)} + ) + + self.save() diff --git a/hassio/const.py b/hassio/const.py index 5eb153a77..0997d2af6 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -18,6 +18,7 @@ RUN_UPDATE_INFO_TASKS = 28800 RUN_UPDATE_SUPERVISOR_TASKS = 29100 RUN_RELOAD_ADDONS_TASKS = 28800 RUN_WATCHDOG_HOMEASSISTANT = 15 +RUN_CLEANUP_API_SESSIONS = 900 RESTART_EXIT_CODE = 100 @@ -62,6 +63,10 @@ ATTR_REPOSITORY = 'repository' ATTR_REPOSITORIES = 'repositories' ATTR_URL = 'url' ATTR_MAINTAINER = 'maintainer' +ATTR_PASSWORD = 'password' +ATTR_TOTP = 'totp' +ATTR_INITIALIZE = 'initialize' +ATTR_SESSION = 'session' STARTUP_BEFORE = 'before' STARTUP_AFTER = 'after' diff --git a/hassio/core.py b/hassio/core.py index 8f2c59830..cbcb4cc46 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -11,12 +11,14 @@ from .api import RestAPI from .host_control import HostControl from .const import ( SOCKET_DOCKER, RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS, - RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT, STARTUP_AFTER, - STARTUP_BEFORE) + RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT, + RUN_CLEANUP_API_SESSIONS, STARTUP_AFTER, STARTUP_BEFORE) from .scheduler import Scheduler from .dock.homeassistant import DockerHomeAssistant from .dock.supervisor import DockerSupervisor -from .tasks import hassio_update, homeassistant_watchdog, homeassistant_setup +from .tasks import ( + hassio_update, homeassistant_watchdog, homeassistant_setup, + api_sessions_cleanup) from .tools import get_arch_from_image, get_local_ip _LOGGER = logging.getLogger(__name__) @@ -71,6 +73,12 @@ class HassIO(object): self.supervisor, self.addons, self.host_control) self.api.register_homeassistant(self.homeassistant) self.api.register_addons(self.addons) + self.api.register_security() + + # schedule api session cleanup + self.scheduler.register_task( + api_sessions_cleanup(self.config), RUN_CLEANUP_API_SESSIONS, + now=True) # schedule update info tasks self.scheduler.register_task( @@ -102,24 +110,26 @@ class HassIO(object): await self.api.start() _LOGGER.info("Start hassio api on %s", self.config.api_endpoint) - # HomeAssistant is already running / supervisor have only reboot - if await self.homeassistant.is_running(): - _LOGGER.info("HassIO reboot detected") - return + try: + # HomeAssistant is already running / supervisor have only reboot + if await self.homeassistant.is_running(): + _LOGGER.info("HassIO reboot detected") + return - # start addon mark as before - await self.addons.auto_boot(STARTUP_BEFORE) + # start addon mark as before + await self.addons.auto_boot(STARTUP_BEFORE) - # run HomeAssistant - await self.homeassistant.run() + # run HomeAssistant + await self.homeassistant.run() - # schedule homeassistant watchdog - self.scheduler.register_task( - homeassistant_watchdog(self.loop, self.homeassistant), - RUN_WATCHDOG_HOMEASSISTANT) + # start addon mark as after + await self.addons.auto_boot(STARTUP_AFTER) - # start addon mark as after - await self.addons.auto_boot(STARTUP_AFTER) + finally: + # schedule homeassistant watchdog + self.scheduler.register_task( + homeassistant_watchdog(self.loop, self.homeassistant), + RUN_WATCHDOG_HOMEASSISTANT) async def stop(self, exit_code=0): """Stop a running orchestration.""" diff --git a/hassio/tasks.py b/hassio/tasks.py index bc55eae99..32e4c8a6d 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -1,10 +1,23 @@ """Multible tasks.""" import asyncio +from datetime import datetime import logging _LOGGER = logging.getLogger(__name__) +def api_sessions_cleanup(config): + """Create scheduler task for cleanup api sessions.""" + async def _api_sessions_cleanup(): + """Cleanup old api sessions.""" + now = datetime.now() + for session, until_valid in config.security_sessions.items(): + if now >= until_valid: + config.security_sessions = (session, None) + + return _api_sessions_cleanup + + def hassio_update(config, supervisor): """Create scheduler task for update of supervisor hassio.""" async def _hassio_update(): diff --git a/setup.py b/setup.py index 31f333ffe..14374be3a 100644 --- a/setup.py +++ b/setup.py @@ -38,5 +38,7 @@ setup( 'colorlog', 'voluptuous', 'gitpython', + 'pyotp', + 'pyqrcode' ] )