mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00:00
Allow creating signed urls (#17759)
* Allow creating signed urls * Fix parameter * Lint
This commit is contained in:
parent
b5284aa445
commit
312d49caec
@ -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,
|
||||||
|
@ -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']))
|
||||||
|
}))
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user