Change access to API (#686)

* Update API.md

* Update API.md

* Update API.md

* Update addons.py

* Update addons.py

* Update addons.py

* Update addons.py

* Update __init__.py

* Update security.py

* Update security.py

* Update const.py

* Update validate.py

* Update __init__.py

* Update validate.py

* Update homeassistant.py

* Update homeassistant.py

* Update homeassistant.py

* Update addon.py

* Update addon.py

* Update homeassistant.py

* Fix lint

* Fix lint

* Backward combatibility

* Make token more robust

* Fix bug

* Logic error

* Fix access

* fix valid
This commit is contained in:
Pascal Vizeli 2018-09-07 22:59:31 +02:00 committed by GitHub
parent ff7f6a0b4c
commit cecefd6972
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 146 additions and 35 deletions

18
API.md
View File

@ -1,4 +1,4 @@
# Hass.io Server
# Hass.io
## Hass.io RESTful API
@ -27,6 +27,9 @@ For access to API you need set the `X-HASSIO-KEY` they will be available for Add
### Hass.io
- GET `/supervisor/ping`
This API call don't need a token.
- GET `/supervisor/info`
The addons from `addons` are only installed one.
@ -412,6 +415,8 @@ Proxy to real websocket instance.
### RESTful for API addons
If a add-on will call itself, you can use `/addons/self/...`.
- GET `/addons`
Get all available addons.
@ -510,7 +515,6 @@ Get all available addons.
"CONTAINER": "port|[ip, port]"
},
"options": {},
"protected": "bool",
"audio_output": "null|0,0",
"audio_input": "null|0,0"
}
@ -518,6 +522,16 @@ Get all available addons.
Reset custom network/audio/options, set it `null`.
- POST `/addons/{addon}/security`
This function is not callable by itself.
```json
{
"protected": "bool",
}
```
- POST `/addons/{addon}/start`
- POST `/addons/{addon}/stop`

View File

@ -50,6 +50,13 @@ class AddonManager(CoreSysAttributes):
return addon
return None
def from_token(self, token):
"""Return an add-on from hassio token."""
for addon in self.list_addons:
if addon.is_installed and token == addon.hassio_token:
return addon
return None
async def load(self):
"""Startup addon management."""
self.data.reload()

View File

@ -26,10 +26,11 @@ from ..const import (
ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY, ATTR_HOST_IPC,
ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_DISCOVERY, ATTR_SERVICES,
ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_FULL_ACCESS,
ATTR_PROTECTED,
ATTR_PROTECTED, ATTR_ACCESS_TOKEN,
SECURITY_PROFILE, SECURITY_DISABLE, SECURITY_DEFAULT)
from ..coresys import CoreSysAttributes
from ..docker.addon import DockerAddon
from ..utils import create_token
from ..utils.json import write_json_file, read_json_file
from ..utils.apparmor import adjust_profile
from ..exceptions import HostAppArmorError
@ -172,6 +173,13 @@ class Addon(CoreSysAttributes):
return self._data.user[self._id][ATTR_UUID]
return None
@property
def hassio_token(self):
"""Return access token for hass.io API."""
if self.is_installed:
return self._data.user[self._id].get(ATTR_ACCESS_TOKEN)
return None
@property
def description(self):
"""Return description of addon."""
@ -686,6 +694,14 @@ class Addon(CoreSysAttributes):
@check_installed
async def start(self):
"""Set options and start addon."""
if await self.instance.is_running():
_LOGGER.warning("%s allready running!", self.slug)
return
# Access Token
self._data.user[self._id][ATTR_ACCESS_TOKEN] = create_token()
self._data.save_data()
# Options
if not self.write_options():
return False

View File

@ -19,7 +19,7 @@ from ..const import (
ATTR_ARGS, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY,
ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_SERVICES, ATTR_DISCOVERY,
ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_PROTECTED,
ATTR_FULL_ACCESS,
ATTR_FULL_ACCESS, ATTR_ACCESS_TOKEN,
PRIVILEGED_NET_ADMIN, PRIVILEGED_SYS_ADMIN, PRIVILEGED_SYS_RAWIO,
PRIVILEGED_IPC_LOCK, PRIVILEGED_SYS_TIME, PRIVILEGED_SYS_NICE,
PRIVILEGED_SYS_RESOURCE)
@ -168,6 +168,7 @@ SCHEMA_ADDON_USER = vol.Schema({
vol.Required(ATTR_VERSION): vol.Coerce(str),
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex):
vol.Match(r"^[0-9a-f]{32}$"),
vol.Optional(ATTR_ACCESS_TOKEN): vol.Match(r"^[0-9a-f]{64}$"),
vol.Optional(ATTR_OPTIONS, default=dict): dict,
vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(),
vol.Optional(ATTR_BOOT):

View File

@ -158,6 +158,7 @@ class RestAPI(CoreSysAttributes):
web.get('/addons/{addon}/logo', api_addons.logo),
web.get('/addons/{addon}/changelog', api_addons.changelog),
web.post('/addons/{addon}/stdin', api_addons.stdin),
web.post('/addons/{addon}/security', api_addons.security),
web.get('/addons/{addon}/stats', api_addons.stats),
])

View File

@ -20,7 +20,8 @@ from ..const import (
ATTR_NETWORK_RX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_ICON, ATTR_SERVICES,
ATTR_DISCOVERY, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API,
ATTR_FULL_ACCESS, ATTR_PROTECTED, ATTR_RATING,
CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT, REQUEST_FROM)
CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT,
REQUEST_FROM)
from ..coresys import CoreSysAttributes
from ..validate import DOCKER_PORTS, ALSA_DEVICE
from ..exceptions import APINotSupportedError
@ -38,6 +39,10 @@ SCHEMA_OPTIONS = vol.Schema({
vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(),
vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_DEVICE,
vol.Optional(ATTR_AUDIO_INPUT): ALSA_DEVICE,
})
# pylint: disable=no-value-for-parameter
SCHEMA_SECURITY = vol.Schema({
vol.Optional(ATTR_PROTECTED): vol.Boolean(),
})
@ -47,7 +52,13 @@ class APIAddons(CoreSysAttributes):
def _extract_addon(self, request, check_installed=True):
"""Return addon, throw an exception it it doesn't exist."""
addon = self.sys_addons.get(request.match_info.get('addon'))
addon_slug = request.match_info.get('addon')
# Lookup itself
if addon_slug == 'self':
addon_slug = request.get(REQUEST_FROM)
addon = self.sys_addons.get(addon_slug)
if not addon:
raise RuntimeError("Addon does not exist")
@ -157,11 +168,6 @@ class APIAddons(CoreSysAttributes):
"""Store user options for addon."""
addon = self._extract_addon(request)
# Have Access
if addon.slug == request[REQUEST_FROM]:
_LOGGER.error("Add-on can't self modify his options!")
raise APINotSupportedError()
addon_schema = SCHEMA_OPTIONS.extend({
vol.Optional(ATTR_OPTIONS): vol.Any(None, addon.schema),
})
@ -180,6 +186,22 @@ class APIAddons(CoreSysAttributes):
addon.audio_input = body[ATTR_AUDIO_INPUT]
if ATTR_AUDIO_OUTPUT in body:
addon.audio_output = body[ATTR_AUDIO_OUTPUT]
addon.save_data()
return True
@api_process
async def security(self, request):
"""Store security options for addon."""
addon = self._extract_addon(request)
# Have Access
if addon.slug == request[REQUEST_FROM]:
_LOGGER.error("Can't self modify his security!")
raise APINotSupportedError()
body = await api_validate(SCHEMA_SECURITY, request)
if ATTR_PROTECTED in body:
_LOGGER.warning("Protected flag changing for %s!", addon.slug)
addon.protected = body[ATTR_PROTECTED]

View File

@ -3,18 +3,28 @@ import logging
import re
from aiohttp.web import middleware
from aiohttp.web_exceptions import HTTPUnauthorized
from aiohttp.web_exceptions import HTTPUnauthorized, HTTPForbidden
from ..const import HEADER_TOKEN, REQUEST_FROM
from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__)
NO_SECURITY_CHECK = set((
re.compile(r"^/homeassistant/api/.*$"),
re.compile(r"^/homeassistant/websocket$"),
re.compile(r"^/supervisor/ping$"),
))
NO_SECURITY_CHECK = re.compile(
r"^(?:"
r"|/homeassistant/api/.*$"
r"|/homeassistant/websocket$"
r"|/supervisor/ping$"
r")$"
)
ADDONS_API_BYPASS = re.compile(
r"^(?:"
r"|/homeassistant/info$"
r"|/supervisor/info$"
r"|/addons(?:/self/[^/]+)?$"
r")$"
)
class SecurityMiddleware(CoreSysAttributes):
@ -27,33 +37,50 @@ class SecurityMiddleware(CoreSysAttributes):
@middleware
async def token_validation(self, request, handler):
"""Check security access of this layer."""
request_from = None
hassio_token = request.headers.get(HEADER_TOKEN)
# Ignore security check
for rule in NO_SECURITY_CHECK:
if rule.match(request.path):
_LOGGER.debug("Passthrough %s", request.path)
return await handler(request)
if NO_SECURITY_CHECK.match(request.path):
_LOGGER.debug("Passthrough %s", request.path)
return await handler(request)
# Not token
if not hassio_token:
_LOGGER.warning("No API token provided for %s", request.path)
raise HTTPUnauthorized()
# Home-Assistant
if hassio_token == self.sys_homeassistant.uuid:
# UUID check need removed with 130
if hassio_token in (self.sys_homeassistant.uuid,
self.sys_homeassistant.hassio_token):
_LOGGER.debug("%s access from Home-Assistant", request.path)
request[REQUEST_FROM] = 'homeassistant'
request_from = 'homeassistant'
# Host
if hassio_token == self.sys_machine_id:
_LOGGER.debug("%s access from Host", request.path)
request[REQUEST_FROM] = 'host'
request_from = 'host'
# Add-on
addon = self.sys_addons.from_uuid(hassio_token) \
if hassio_token else None
if addon:
_LOGGER.info("%s access from %s", request.path, addon.slug)
request[REQUEST_FROM] = addon.slug
addon = None
if hassio_token and not request_from:
addon = self.sys_addons.from_token(hassio_token)
# Need removed with 130
if not addon:
addon = self.sys_addons.from_uuid(hassio_token)
if request.get(REQUEST_FROM):
# Check Add-on API access
if addon and addon.access_hassio_api:
_LOGGER.info("%s access from %s", request.path, addon.slug)
request_from = addon.slug
elif addon and ADDONS_API_BYPASS.match(request.path):
_LOGGER.debug("Passthrough %s from %s", request.path, addon.slug)
request_from = addon.slug
if request_from:
request[REQUEST_FROM] = request_from
return await handler(request)
_LOGGER.warning("Invalid token for access %s", request.path)
raise HTTPUnauthorized()
raise HTTPForbidden()

View File

@ -178,6 +178,7 @@ ATTR_HASSOS_CLI = 'hassos_cli'
ATTR_VERSION_CLI = 'version_cli'
ATTR_VERSION_CLI_LATEST = 'version_cli_latest'
ATTR_REFRESH_TOKEN = 'refresh_token'
ATTR_ACCESS_TOKEN = 'access_token'
ATTR_DOCKER_API = 'docker_api'
ATTR_FULL_ACCESS = 'full_access'
ATTR_PROTECTED = 'protected'

View File

@ -92,7 +92,7 @@ class DockerAddon(DockerInterface):
return {
**addon_env,
ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.addon.uuid,
ENV_TOKEN: self.addon.hassio_token,
}
@property

View File

@ -62,7 +62,7 @@ class DockerHomeAssistant(DockerInterface):
environment={
'HASSIO': self.sys_docker.network.supervisor,
ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.sys_homeassistant.uuid,
ENV_TOKEN: self.sys_homeassistant.hassio_token,
},
volumes={
str(self.sys_config.path_extern_homeassistant):

View File

@ -16,14 +16,14 @@ import attr
from .const import (
FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID,
ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG,
ATTR_WAIT_BOOT, ATTR_REFRESH_TOKEN,
ATTR_WAIT_BOOT, ATTR_REFRESH_TOKEN, ATTR_ACCESS_TOKEN,
HEADER_HA_ACCESS)
from .coresys import CoreSysAttributes
from .docker.homeassistant import DockerHomeAssistant
from .exceptions import (
HomeAssistantUpdateError, HomeAssistantError, HomeAssistantAPIError,
HomeAssistantAuthError)
from .utils import convert_to_ascii, process_lock
from .utils import convert_to_ascii, process_lock, create_token
from .utils.json import JsonConfig
from .validate import SCHEMA_HASS_CONFIG
@ -185,6 +185,11 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
"""Return a UUID of this HomeAssistant."""
return self._data[ATTR_UUID]
@property
def hassio_token(self):
"""Return a access token for Hass.io API."""
return self._data.get(ATTR_ACCESS_TOKEN)
@property
def refresh_token(self):
"""Return the refresh token to authenticate with HomeAssistant."""
@ -277,6 +282,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
async def _start(self):
"""Start HomeAssistant docker & wait."""
if await self.instance.is_running():
_LOGGER.warning("HomeAssistant allready running!")
return
# Create new API token
self._data[ATTR_ACCESS_TOKEN] = create_token()
self.save_data()
if not await self.instance.run():
raise HomeAssistantError()
await self._block_till_run()

View File

@ -1,7 +1,9 @@
"""Tools file for HassIO."""
from datetime import datetime
import hashlib
import logging
import re
import uuid
_LOGGER = logging.getLogger(__name__)
RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))")
@ -12,6 +14,11 @@ def convert_to_ascii(raw):
return RE_STRING.sub("", raw.decode())
def create_token():
"""Create token for API access."""
return hashlib.sha256(uuid.uuid4().bytes).hexdigest()
def process_lock(method):
"""Wrap function with only run once."""
async def wrap_api(api, *args, **kwargs):

View File

@ -10,6 +10,7 @@ from .const import (
ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO,
ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG,
ATTR_WAIT_BOOT, ATTR_UUID, ATTR_REFRESH_TOKEN, ATTR_HASSOS_CLI,
ATTR_ACCESS_TOKEN,
CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV)
@ -84,6 +85,7 @@ DOCKER_PORTS = vol.Schema({
SCHEMA_HASS_CONFIG = vol.Schema({
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex):
vol.Match(r"^[0-9a-f]{32}$"),
vol.Optional(ATTR_ACCESS_TOKEN): vol.Match(r"^[0-9a-f]{64}$"),
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),