Allow creating signed urls (#17759)

* Allow creating signed urls

* Fix parameter

* Lint
This commit is contained in:
Paulus Schoutsen 2018-10-25 16:44:57 +02:00 committed by Pascal Vizeli
parent b5284aa445
commit 312d49caec
5 changed files with 174 additions and 4 deletions

View File

@ -342,7 +342,6 @@ class AuthManager:
"""Create a new access token.""" """Create a new access token."""
self._store.async_log_refresh_token_usage(refresh_token, remote_ip) self._store.async_log_refresh_token_usage(refresh_token, remote_ip)
# pylint: disable=no-self-use
now = dt_util.utcnow() now = dt_util.utcnow()
return jwt.encode({ return jwt.encode({
'iss': refresh_token.id, 'iss': refresh_token.id,

View File

@ -129,6 +129,7 @@ from homeassistant.auth.models import User, Credentials, \
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_REAL_IP 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.ban import log_invalid_auth
from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.http.view import HomeAssistantView
@ -169,6 +170,14 @@ SCHEMA_WS_DELETE_REFRESH_TOKEN = \
vol.Required('refresh_token_id'): str, 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_CREDENTIALS = 'credentials'
RESULT_TYPE_USER = 'user' RESULT_TYPE_USER = 'user'
@ -201,6 +210,11 @@ async def async_setup(hass, config):
websocket_delete_refresh_token, websocket_delete_refresh_token,
SCHEMA_WS_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 login_flow.async_setup(hass, store_result)
await mfa_setup_flow.async_setup(hass) await mfa_setup_flow.async_setup(hass)
@ -500,3 +514,14 @@ async def websocket_delete_refresh_token(
connection.send_message( connection.send_message(
websocket_api.result_message(msg['id'], {})) 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']))
}))

View File

@ -6,16 +6,39 @@ import logging
from aiohttp import hdrs from aiohttp import hdrs
from aiohttp.web import middleware from aiohttp.web import middleware
import jwt
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.const import HTTP_HEADER_HA_AUTH 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 from .const import KEY_AUTHENTICATED, KEY_REAL_IP
DATA_API_PASSWORD = 'api_password' DATA_API_PASSWORD = 'api_password'
DATA_SIGN_SECRET = 'http.auth.sign_secret'
SIGN_QUERY_PARAM = 'authSig'
_LOGGER = logging.getLogger(__name__) _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 @callback
def setup_auth(app, trusted_networks, use_auth, def setup_auth(app, trusted_networks, use_auth,
support_legacy=False, api_password=None): 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 # it included both use_auth and api_password Basic auth
authenticated = True 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 elif (legacy_auth and HTTP_HEADER_HA_AUTH in request.headers and
hmac.compare_digest( hmac.compare_digest(
api_password.encode('utf-8'), api_password.encode('utf-8'),
@ -131,3 +160,40 @@ async def async_validate_auth_header(request, api_password=None):
password.encode('utf-8')) password.encode('utf-8'))
return False 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

View File

@ -347,3 +347,30 @@ async def test_ws_delete_refresh_token(hass, hass_ws_client,
refresh_token = await hass.auth.async_validate_access_token( refresh_token = await hass.auth.async_validate_access_token(
hass_access_token) hass_access_token)
assert refresh_token is None 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

View File

@ -1,5 +1,5 @@
"""The tests for the Home Assistant HTTP component.""" """The tests for the Home Assistant HTTP component."""
# pylint: disable=protected-access from datetime import timedelta
from ipaddress import ip_network from ipaddress import ip_network
from unittest.mock import patch from unittest.mock import patch
@ -7,7 +7,7 @@ import pytest
from aiohttp import BasicAuth, web from aiohttp import BasicAuth, web
from aiohttp.web_exceptions import HTTPUnauthorized 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.const import KEY_AUTHENTICATED
from homeassistant.components.http.real_ip import setup_real_ip from homeassistant.components.http.real_ip import setup_real_ip
from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.const import HTTP_HEADER_HA_AUTH
@ -33,7 +33,16 @@ async def mock_handler(request):
"""Return if request was authenticated.""" """Return if request was authenticated."""
if not request[KEY_AUTHENTICATED]: if not request[KEY_AUTHENTICATED]:
raise HTTPUnauthorized 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 @pytest.fixture
@ -248,3 +257,47 @@ async def test_auth_legacy_support_api_password_access(app, aiohttp_client):
'/', '/',
auth=BasicAuth('homeassistant', API_PASSWORD)) auth=BasicAuth('homeassistant', API_PASSWORD))
assert req.status == 200 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