diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e584d5b70e5..9fd9bf3fa50 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -342,7 +342,6 @@ class AuthManager: """Create a new access token.""" self._store.async_log_refresh_token_usage(refresh_token, remote_ip) - # pylint: disable=no-self-use now = dt_util.utcnow() return jwt.encode({ 'iss': refresh_token.id, diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index f4b314918f7..2e74961d11b 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -129,6 +129,7 @@ from homeassistant.auth.models import User, Credentials, \ TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN from homeassistant.components import websocket_api from homeassistant.components.http import KEY_REAL_IP +from homeassistant.components.http.auth import async_sign_path from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView @@ -169,6 +170,14 @@ SCHEMA_WS_DELETE_REFRESH_TOKEN = \ vol.Required('refresh_token_id'): str, }) +WS_TYPE_SIGN_PATH = 'auth/sign_path' +SCHEMA_WS_SIGN_PATH = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SIGN_PATH, + vol.Required('path'): str, + vol.Optional('expires', default=30): int, + }) + RESULT_TYPE_CREDENTIALS = 'credentials' RESULT_TYPE_USER = 'user' @@ -201,6 +210,11 @@ async def async_setup(hass, config): websocket_delete_refresh_token, SCHEMA_WS_DELETE_REFRESH_TOKEN ) + hass.components.websocket_api.async_register_command( + WS_TYPE_SIGN_PATH, + websocket_sign_path, + SCHEMA_WS_SIGN_PATH + ) await login_flow.async_setup(hass, store_result) await mfa_setup_flow.async_setup(hass) @@ -500,3 +514,14 @@ async def websocket_delete_refresh_token( connection.send_message( websocket_api.result_message(msg['id'], {})) + + +@websocket_api.ws_require_user() +@callback +def websocket_sign_path( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Handle a sign path request.""" + connection.send_message(websocket_api.result_message(msg['id'], { + 'path': async_sign_path(hass, connection.refresh_token_id, msg['path'], + timedelta(seconds=msg['expires'])) + })) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index bcc86b36dbe..64ee7fb8a3f 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -6,16 +6,39 @@ import logging from aiohttp import hdrs from aiohttp.web import middleware +import jwt from homeassistant.core import callback from homeassistant.const import HTTP_HEADER_HA_AUTH +from homeassistant.auth.util import generate_secret +from homeassistant.util import dt as dt_util + from .const import KEY_AUTHENTICATED, KEY_REAL_IP DATA_API_PASSWORD = 'api_password' +DATA_SIGN_SECRET = 'http.auth.sign_secret' +SIGN_QUERY_PARAM = 'authSig' _LOGGER = logging.getLogger(__name__) +@callback +def async_sign_path(hass, refresh_token_id, path, expiration): + """Sign a path for temporary access without auth header.""" + secret = hass.data.get(DATA_SIGN_SECRET) + + if secret is None: + secret = hass.data[DATA_SIGN_SECRET] = generate_secret() + + now = dt_util.utcnow() + return "{}?{}={}".format(path, SIGN_QUERY_PARAM, jwt.encode({ + 'iss': refresh_token_id, + 'path': path, + 'iat': now, + 'exp': now + expiration, + }, secret, algorithm='HS256').decode()) + + @callback def setup_auth(app, trusted_networks, use_auth, support_legacy=False, api_password=None): @@ -43,6 +66,12 @@ def setup_auth(app, trusted_networks, use_auth, # it included both use_auth and api_password Basic auth authenticated = True + # We first start with a string check to avoid parsing query params + # for every request. + elif (request.method == "GET" and SIGN_QUERY_PARAM in request.query and + await async_validate_signed_request(request)): + authenticated = True + elif (legacy_auth and HTTP_HEADER_HA_AUTH in request.headers and hmac.compare_digest( api_password.encode('utf-8'), @@ -131,3 +160,40 @@ async def async_validate_auth_header(request, api_password=None): password.encode('utf-8')) return False + + +async def async_validate_signed_request(request): + """Validate a signed request.""" + hass = request.app['hass'] + secret = hass.data.get(DATA_SIGN_SECRET) + + if secret is None: + return False + + signature = request.query.get(SIGN_QUERY_PARAM) + + if signature is None: + return False + + try: + claims = jwt.decode( + signature, + secret, + algorithms=['HS256'], + options={'verify_iss': False} + ) + except jwt.InvalidTokenError: + return False + + if claims['path'] != request.path: + return False + + refresh_token = await hass.auth.async_get_refresh_token(claims['iss']) + + if refresh_token is None: + return False + + request['hass_refresh_token'] = refresh_token + request['hass_user'] = refresh_token.user + + return True diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index a3974553661..e28f7be4341 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -347,3 +347,30 @@ async def test_ws_delete_refresh_token(hass, hass_ws_client, refresh_token = await hass.auth.async_validate_access_token( hass_access_token) assert refresh_token is None + + +async def test_ws_sign_path(hass, hass_ws_client, hass_access_token): + """Test signing a path.""" + assert await async_setup_component(hass, 'auth', {'http': {}}) + ws_client = await hass_ws_client(hass, hass_access_token) + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + + with patch('homeassistant.components.auth.async_sign_path', + return_value='hello_world') as mock_sign: + await ws_client.send_json({ + 'id': 5, + 'type': auth.WS_TYPE_SIGN_PATH, + 'path': '/api/hello', + 'expires': 20 + }) + + result = await ws_client.receive_json() + assert result['success'], result + assert result['result'] == {'path': 'hello_world'} + assert len(mock_sign.mock_calls) == 1 + hass, p_refresh_token, path, expires = mock_sign.mock_calls[0][1] + assert p_refresh_token == refresh_token.id + assert path == '/api/hello' + assert expires.total_seconds() == 20 diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index e96531c0961..2746abcf15c 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,5 +1,5 @@ """The tests for the Home Assistant HTTP component.""" -# pylint: disable=protected-access +from datetime import timedelta from ipaddress import ip_network from unittest.mock import patch @@ -7,7 +7,7 @@ import pytest from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -from homeassistant.components.http.auth import setup_auth +from homeassistant.components.http.auth import setup_auth, async_sign_path from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.real_ip import setup_real_ip from homeassistant.const import HTTP_HEADER_HA_AUTH @@ -33,7 +33,16 @@ async def mock_handler(request): """Return if request was authenticated.""" if not request[KEY_AUTHENTICATED]: raise HTTPUnauthorized - return web.Response(status=200) + + token = request.get('hass_refresh_token') + token_id = token.id if token else None + user = request.get('hass_user') + user_id = user.id if user else None + + return web.json_response(status=200, data={ + 'refresh_token_id': token_id, + 'user_id': user_id, + }) @pytest.fixture @@ -248,3 +257,47 @@ async def test_auth_legacy_support_api_password_access(app, aiohttp_client): '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 + + +async def test_auth_access_signed_path( + hass, app, aiohttp_client, hass_access_token): + """Test access with signed url.""" + app.router.add_post('/', mock_handler) + app.router.add_get('/another_path', mock_handler) + setup_auth(app, [], True, api_password=None) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + + signed_path = async_sign_path( + hass, refresh_token.id, '/', timedelta(seconds=5) + ) + + req = await client.get(signed_path) + assert req.status == 200 + data = await req.json() + assert data['refresh_token_id'] == refresh_token.id + assert data['user_id'] == refresh_token.user.id + + # Use signature on other path + req = await client.get( + '/another_path?{}'.format(signed_path.split('?')[1])) + assert req.status == 401 + + # We only allow GET + req = await client.post(signed_path) + assert req.status == 401 + + # Never valid as expired in the past. + expired_signed_path = async_sign_path( + hass, refresh_token.id, '/', timedelta(seconds=-5) + ) + + req = await client.get(expired_signed_path) + assert req.status == 401 + + # refresh token gone should also invalidate signature + await hass.auth.async_remove_refresh_token(refresh_token) + req = await client.get(signed_path) + assert req.status == 401