Legacy api fix (#18733)

* Set user for API password requests

* Fix tests

* Fix typing
This commit is contained in:
Paulus Schoutsen 2018-11-27 10:41:44 +01:00 committed by GitHub
parent 9d7b1fc3a7
commit c2f8dfcb9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 148 additions and 74 deletions

View File

@ -4,16 +4,19 @@ Support Legacy API password auth provider.
It will be removed when auth system production ready
"""
import hmac
from typing import Any, Dict, Optional, cast
from typing import Any, Dict, Optional, cast, TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
from ..models import Credentials, UserMeta
from .. import AuthManager
from ..models import Credentials, UserMeta, User
if TYPE_CHECKING:
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
USER_SCHEMA = vol.Schema({
@ -31,6 +34,24 @@ class InvalidAuthError(HomeAssistantError):
"""Raised when submitting invalid authentication."""
async def async_get_user(hass: HomeAssistant) -> User:
"""Return the legacy API password user."""
auth = cast(AuthManager, hass.auth) # type: ignore
found = None
for prv in auth.auth_providers:
if prv.type == 'legacy_api_password':
found = prv
break
if found is None:
raise ValueError('Legacy API password provider not found')
return await auth.async_get_or_create_user(
await found.async_get_or_create_credentials({})
)
@AUTH_PROVIDERS.register('legacy_api_password')
class LegacyApiPasswordAuthProvider(AuthProvider):
"""Example auth provider based on hardcoded usernames and passwords."""

View File

@ -10,6 +10,7 @@ import jwt
from homeassistant.core import callback
from homeassistant.const import HTTP_HEADER_HA_AUTH
from homeassistant.auth.providers import legacy_api_password
from homeassistant.auth.util import generate_secret
from homeassistant.util import dt as dt_util
@ -78,12 +79,16 @@ def setup_auth(app, trusted_networks, use_auth,
request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))):
# A valid auth header has been set
authenticated = True
request['hass_user'] = await legacy_api_password.async_get_user(
app['hass'])
elif (legacy_auth and DATA_API_PASSWORD in request.query and
hmac.compare_digest(
api_password.encode('utf-8'),
request.query[DATA_API_PASSWORD].encode('utf-8'))):
authenticated = True
request['hass_user'] = await legacy_api_password.async_get_user(
app['hass'])
elif _is_trusted_ip(request, trusted_networks):
authenticated = True

View File

@ -23,7 +23,7 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3"
@pytest.fixture
def alexa_client(loop, hass, aiohttp_client):
def alexa_client(loop, hass, hass_client):
"""Initialize a Home Assistant server for testing this module."""
@callback
def mock_service(call):
@ -95,7 +95,7 @@ def alexa_client(loop, hass, aiohttp_client):
},
}
}))
return loop.run_until_complete(aiohttp_client(hass.http.app))
return loop.run_until_complete(hass_client())
def _intent_req(client, data=None):

View File

@ -1437,10 +1437,10 @@ async def test_unsupported_domain(hass):
assert not msg['payload']['endpoints']
async def do_http_discovery(config, hass, aiohttp_client):
async def do_http_discovery(config, hass, hass_client):
"""Submit a request to the Smart Home HTTP API."""
await async_setup_component(hass, alexa.DOMAIN, config)
http_client = await aiohttp_client(hass.http.app)
http_client = await hass_client()
request = get_new_request('Alexa.Discovery', 'Discover')
response = await http_client.post(
@ -1450,7 +1450,7 @@ async def do_http_discovery(config, hass, aiohttp_client):
return response
async def test_http_api(hass, aiohttp_client):
async def test_http_api(hass, hass_client):
"""With `smart_home:` HTTP API is exposed."""
config = {
'alexa': {
@ -1458,7 +1458,7 @@ async def test_http_api(hass, aiohttp_client):
}
}
response = await do_http_discovery(config, hass, aiohttp_client)
response = await do_http_discovery(config, hass, hass_client)
response_data = await response.json()
# Here we're testing just the HTTP view glue -- details of discovery are
@ -1466,12 +1466,12 @@ async def test_http_api(hass, aiohttp_client):
assert response_data['event']['header']['name'] == 'Discover.Response'
async def test_http_api_disabled(hass, aiohttp_client):
async def test_http_api_disabled(hass, hass_client):
"""Without `smart_home:`, the HTTP API is disabled."""
config = {
'alexa': {}
}
response = await do_http_discovery(config, hass, aiohttp_client)
response = await do_http_discovery(config, hass, hass_client)
assert response.status == 404

View File

@ -4,6 +4,7 @@ from unittest.mock import patch
import pytest
from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY
from homeassistant.auth.providers import legacy_api_password, homeassistant
from homeassistant.setup import async_setup_component
from homeassistant.components.websocket_api.http import URL
from homeassistant.components.websocket_api.auth import (
@ -88,7 +89,7 @@ def hass_access_token(hass, hass_admin_user):
@pytest.fixture
def hass_admin_user(hass):
def hass_admin_user(hass, local_auth):
"""Return a Home Assistant admin user."""
admin_group = hass.loop.run_until_complete(hass.auth.async_get_group(
GROUP_ID_ADMIN))
@ -96,8 +97,42 @@ def hass_admin_user(hass):
@pytest.fixture
def hass_read_only_user(hass):
def hass_read_only_user(hass, local_auth):
"""Return a Home Assistant read only user."""
read_only_group = hass.loop.run_until_complete(hass.auth.async_get_group(
GROUP_ID_READ_ONLY))
return MockUser(groups=[read_only_group]).add_to_hass(hass)
@pytest.fixture
def legacy_auth(hass):
"""Load legacy API password provider."""
prv = legacy_api_password.LegacyApiPasswordAuthProvider(
hass, hass.auth._store, {
'type': 'legacy_api_password'
}
)
hass.auth._providers[(prv.type, prv.id)] = prv
@pytest.fixture
def local_auth(hass):
"""Load local auth provider."""
prv = homeassistant.HassAuthProvider(
hass, hass.auth._store, {
'type': 'homeassistant'
}
)
hass.auth._providers[(prv.type, prv.id)] = prv
@pytest.fixture
def hass_client(hass, aiohttp_client, hass_access_token):
"""Return an authenticated HTTP client."""
async def auth_client():
"""Return an authenticated client."""
return await aiohttp_client(hass.http.app, headers={
'Authorization': "Bearer {}".format(hass_access_token)
})
return auth_client

View File

@ -27,7 +27,7 @@ def hassio_env():
@pytest.fixture
def hassio_client(hassio_env, hass, aiohttp_client):
def hassio_client(hassio_env, hass, aiohttp_client, legacy_auth):
"""Create mock hassio http client."""
with patch('homeassistant.components.hassio.HassIO.update_hass_api',
Mock(return_value=mock_coro({"result": "ok"}))), \

View File

@ -83,7 +83,8 @@ async def test_access_without_password(app, aiohttp_client):
assert resp.status == 200
async def test_access_with_password_in_header(app, aiohttp_client):
async def test_access_with_password_in_header(app, aiohttp_client,
legacy_auth):
"""Test access with password in header."""
setup_auth(app, [], False, api_password=API_PASSWORD)
client = await aiohttp_client(app)
@ -97,7 +98,7 @@ async def test_access_with_password_in_header(app, aiohttp_client):
assert req.status == 401
async def test_access_with_password_in_query(app, aiohttp_client):
async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth):
"""Test access with password in URL."""
setup_auth(app, [], False, api_password=API_PASSWORD)
client = await aiohttp_client(app)
@ -219,7 +220,8 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client):
"{} should be trusted".format(remote_addr)
async def test_auth_active_blocked_api_password_access(app, aiohttp_client):
async def test_auth_active_blocked_api_password_access(
app, aiohttp_client, legacy_auth):
"""Test access using api_password should be blocked when auth.active."""
setup_auth(app, [], True, api_password=API_PASSWORD)
client = await aiohttp_client(app)
@ -239,7 +241,8 @@ async def test_auth_active_blocked_api_password_access(app, aiohttp_client):
assert req.status == 401
async def test_auth_legacy_support_api_password_access(app, aiohttp_client):
async def test_auth_legacy_support_api_password_access(
app, aiohttp_client, legacy_auth):
"""Test access using api_password if auth.support_legacy."""
setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD)
client = await aiohttp_client(app)

View File

@ -124,7 +124,7 @@ async def test_api_no_base_url(hass):
assert hass.config.api.base_url == 'http://127.0.0.1:8123'
async def test_not_log_password(hass, aiohttp_client, caplog):
async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth):
"""Test access with password doesn't get logged."""
assert await async_setup_component(hass, 'api', {
'http': {

View File

@ -16,12 +16,10 @@ from tests.common import async_mock_service
@pytest.fixture
def mock_api_client(hass, aiohttp_client, hass_access_token):
def mock_api_client(hass, hass_client):
"""Start the Hass HTTP component and return admin API client."""
hass.loop.run_until_complete(async_setup_component(hass, 'api', {}))
return hass.loop.run_until_complete(aiohttp_client(hass.http.app, headers={
'Authorization': 'Bearer {}'.format(hass_access_token)
}))
return hass.loop.run_until_complete(hass_client())
@asyncio.coroutine
@ -408,7 +406,7 @@ def _listen_count(hass):
async def test_api_error_log(hass, aiohttp_client, hass_access_token,
hass_admin_user):
hass_admin_user, legacy_auth):
"""Test if we can fetch the error log."""
hass.data[DATA_LOGGING] = '/some/path'
await async_setup_component(hass, 'api', {
@ -566,5 +564,17 @@ async def test_rendering_template_admin(hass, mock_api_client,
hass_admin_user):
"""Test rendering a template requires admin."""
hass_admin_user.groups = []
resp = await mock_api_client.post('/api/template')
resp = await mock_api_client.post(const.URL_API_TEMPLATE)
assert resp.status == 401
async def test_rendering_template_legacy_user(
hass, mock_api_client, aiohttp_client, legacy_auth):
"""Test rendering a template with legacy API password."""
hass.states.async_set('sensor.temperature', 10)
client = await aiohttp_client(hass.http.app)
resp = await client.post(
const.URL_API_TEMPLATE,
json={"template": '{{ states.sensor.temperature.state }}'}
)
assert resp.status == 401

View File

@ -90,7 +90,7 @@ async def test_register_before_setup(hass):
assert intent.text_input == 'I would like the Grolsch beer'
async def test_http_processing_intent(hass, aiohttp_client):
async def test_http_processing_intent(hass, hass_client):
"""Test processing intent via HTTP API."""
class TestIntentHandler(intent.IntentHandler):
"""Test Intent Handler."""
@ -120,7 +120,7 @@ async def test_http_processing_intent(hass, aiohttp_client):
})
assert result
client = await aiohttp_client(hass.http.app)
client = await hass_client()
resp = await client.post('/api/conversation/process', json={
'text': 'I would like the Grolsch beer'
})
@ -244,7 +244,7 @@ async def test_toggle_intent(hass, sentence):
assert call.data == {'entity_id': 'light.kitchen'}
async def test_http_api(hass, aiohttp_client):
async def test_http_api(hass, hass_client):
"""Test the HTTP conversation API."""
result = await component.async_setup(hass, {})
assert result
@ -252,7 +252,7 @@ async def test_http_api(hass, aiohttp_client):
result = await async_setup_component(hass, 'conversation', {})
assert result
client = await aiohttp_client(hass.http.app)
client = await hass_client()
hass.states.async_set('light.kitchen', 'off')
calls = async_mock_service(hass, HASS_DOMAIN, 'turn_on')
@ -268,7 +268,7 @@ async def test_http_api(hass, aiohttp_client):
assert call.data == {'entity_id': 'light.kitchen'}
async def test_http_api_wrong_data(hass, aiohttp_client):
async def test_http_api_wrong_data(hass, hass_client):
"""Test the HTTP conversation API."""
result = await component.async_setup(hass, {})
assert result
@ -276,7 +276,7 @@ async def test_http_api_wrong_data(hass, aiohttp_client):
result = await async_setup_component(hass, 'conversation', {})
assert result
client = await aiohttp_client(hass.http.app)
client = await hass_client()
resp = await client.post('/api/conversation/process', json={
'text': 123

View File

@ -515,13 +515,13 @@ class TestComponentHistory(unittest.TestCase):
return zero, four, states
async def test_fetch_period_api(hass, aiohttp_client):
async def test_fetch_period_api(hass, hass_client):
"""Test the fetch period view for history."""
await hass.async_add_job(init_recorder_component, hass)
await async_setup_component(hass, 'history', {})
await hass.components.recorder.wait_connection_ready()
await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
client = await aiohttp_client(hass.http.app)
client = await hass_client()
response = await client.get(
'/api/history/period/{}'.format(dt_util.utcnow().isoformat()))
assert response.status == 200

View File

@ -55,7 +55,7 @@ def test_recent_items_intent(hass):
@asyncio.coroutine
def test_deprecated_api_get_all(hass, aiohttp_client):
def test_deprecated_api_get_all(hass, hass_client):
"""Test the API."""
yield from async_setup_component(hass, 'shopping_list', {})
@ -66,7 +66,7 @@ def test_deprecated_api_get_all(hass, aiohttp_client):
hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}}
)
client = yield from aiohttp_client(hass.http.app)
client = yield from hass_client()
resp = yield from client.get('/api/shopping_list')
assert resp.status == 200
@ -110,7 +110,7 @@ async def test_ws_get_items(hass, hass_ws_client):
@asyncio.coroutine
def test_deprecated_api_update(hass, aiohttp_client):
def test_deprecated_api_update(hass, hass_client):
"""Test the API."""
yield from async_setup_component(hass, 'shopping_list', {})
@ -124,7 +124,7 @@ def test_deprecated_api_update(hass, aiohttp_client):
beer_id = hass.data['shopping_list'].items[0]['id']
wine_id = hass.data['shopping_list'].items[1]['id']
client = yield from aiohttp_client(hass.http.app)
client = yield from hass_client()
resp = yield from client.post(
'/api/shopping_list/item/{}'.format(beer_id), json={
'name': 'soda'
@ -220,7 +220,7 @@ async def test_ws_update_item(hass, hass_ws_client):
@asyncio.coroutine
def test_api_update_fails(hass, aiohttp_client):
def test_api_update_fails(hass, hass_client):
"""Test the API."""
yield from async_setup_component(hass, 'shopping_list', {})
@ -228,7 +228,7 @@ def test_api_update_fails(hass, aiohttp_client):
hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
)
client = yield from aiohttp_client(hass.http.app)
client = yield from hass_client()
resp = yield from client.post(
'/api/shopping_list/non_existing', json={
'name': 'soda'
@ -275,7 +275,7 @@ async def test_ws_update_item_fail(hass, hass_ws_client):
@asyncio.coroutine
def test_api_clear_completed(hass, aiohttp_client):
def test_api_clear_completed(hass, hass_client):
"""Test the API."""
yield from async_setup_component(hass, 'shopping_list', {})
@ -289,7 +289,7 @@ def test_api_clear_completed(hass, aiohttp_client):
beer_id = hass.data['shopping_list'].items[0]['id']
wine_id = hass.data['shopping_list'].items[1]['id']
client = yield from aiohttp_client(hass.http.app)
client = yield from hass_client()
# Mark beer as completed
resp = yield from client.post(
@ -312,11 +312,11 @@ def test_api_clear_completed(hass, aiohttp_client):
@asyncio.coroutine
def test_deprecated_api_create(hass, aiohttp_client):
def test_deprecated_api_create(hass, hass_client):
"""Test the API."""
yield from async_setup_component(hass, 'shopping_list', {})
client = yield from aiohttp_client(hass.http.app)
client = yield from hass_client()
resp = yield from client.post('/api/shopping_list/item', json={
'name': 'soda'
})
@ -333,11 +333,11 @@ def test_deprecated_api_create(hass, aiohttp_client):
@asyncio.coroutine
def test_deprecated_api_create_fail(hass, aiohttp_client):
def test_deprecated_api_create_fail(hass, hass_client):
"""Test the API."""
yield from async_setup_component(hass, 'shopping_list', {})
client = yield from aiohttp_client(hass.http.app)
client = yield from hass_client()
resp = yield from client.post('/api/shopping_list/item', json={
'name': 1234
})

View File

@ -56,7 +56,7 @@ SENSOR_OUTPUT = {
@pytest.fixture
def mock_client(hass, aiohttp_client):
def mock_client(hass, hass_client):
"""Start the Home Assistant HTTP component."""
with patch('homeassistant.components.spaceapi',
return_value=mock_coro(True)):
@ -70,7 +70,7 @@ def mock_client(hass, aiohttp_client):
hass.states.async_set('test.hum1', 88,
attributes={'unit_of_measurement': '%'})
return hass.loop.run_until_complete(aiohttp_client(hass.http.app))
return hass.loop.run_until_complete(hass_client())
async def test_spaceapi_get(hass, mock_client):

View File

@ -14,9 +14,9 @@ BASIC_CONFIG = {
}
async def get_error_log(hass, aiohttp_client, expected_count):
async def get_error_log(hass, hass_client, expected_count):
"""Fetch all entries from system_log via the API."""
client = await aiohttp_client(hass.http.app)
client = await hass_client()
resp = await client.get('/api/error/all')
assert resp.status == 200
@ -45,37 +45,37 @@ def get_frame(name):
return (name, None, None, None)
async def test_normal_logs(hass, aiohttp_client):
async def test_normal_logs(hass, hass_client):
"""Test that debug and info are not logged."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
_LOGGER.debug('debug')
_LOGGER.info('info')
# Assert done by get_error_log
await get_error_log(hass, aiohttp_client, 0)
await get_error_log(hass, hass_client, 0)
async def test_exception(hass, aiohttp_client):
async def test_exception(hass, hass_client):
"""Test that exceptions are logged and retrieved correctly."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
_generate_and_log_exception('exception message', 'log message')
log = (await get_error_log(hass, aiohttp_client, 1))[0]
log = (await get_error_log(hass, hass_client, 1))[0]
assert_log(log, 'exception message', 'log message', 'ERROR')
async def test_warning(hass, aiohttp_client):
async def test_warning(hass, hass_client):
"""Test that warning are logged and retrieved correctly."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
_LOGGER.warning('warning message')
log = (await get_error_log(hass, aiohttp_client, 1))[0]
log = (await get_error_log(hass, hass_client, 1))[0]
assert_log(log, '', 'warning message', 'WARNING')
async def test_error(hass, aiohttp_client):
async def test_error(hass, hass_client):
"""Test that errors are logged and retrieved correctly."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
_LOGGER.error('error message')
log = (await get_error_log(hass, aiohttp_client, 1))[0]
log = (await get_error_log(hass, hass_client, 1))[0]
assert_log(log, '', 'error message', 'ERROR')
@ -121,26 +121,26 @@ async def test_error_posted_as_event(hass):
assert_log(events[0].data, '', 'error message', 'ERROR')
async def test_critical(hass, aiohttp_client):
async def test_critical(hass, hass_client):
"""Test that critical are logged and retrieved correctly."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
_LOGGER.critical('critical message')
log = (await get_error_log(hass, aiohttp_client, 1))[0]
log = (await get_error_log(hass, hass_client, 1))[0]
assert_log(log, '', 'critical message', 'CRITICAL')
async def test_remove_older_logs(hass, aiohttp_client):
async def test_remove_older_logs(hass, hass_client):
"""Test that older logs are rotated out."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
_LOGGER.error('error message 1')
_LOGGER.error('error message 2')
_LOGGER.error('error message 3')
log = await get_error_log(hass, aiohttp_client, 2)
log = await get_error_log(hass, hass_client, 2)
assert_log(log[0], '', 'error message 3', 'ERROR')
assert_log(log[1], '', 'error message 2', 'ERROR')
async def test_clear_logs(hass, aiohttp_client):
async def test_clear_logs(hass, hass_client):
"""Test that the log can be cleared via a service call."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
_LOGGER.error('error message')
@ -151,7 +151,7 @@ async def test_clear_logs(hass, aiohttp_client):
await hass.async_block_till_done()
# Assert done by get_error_log
await get_error_log(hass, aiohttp_client, 0)
await get_error_log(hass, hass_client, 0)
async def test_write_log(hass):
@ -197,13 +197,13 @@ async def test_write_choose_level(hass):
assert logger.method_calls[0] == ('debug', ('test_message',))
async def test_unknown_path(hass, aiohttp_client):
async def test_unknown_path(hass, hass_client):
"""Test error logged from unknown path."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
_LOGGER.findCaller = MagicMock(
return_value=('unknown_path', 0, None, None))
_LOGGER.error('error message')
log = (await get_error_log(hass, aiohttp_client, 1))[0]
log = (await get_error_log(hass, hass_client, 1))[0]
assert log['source'] == 'unknown_path'
@ -222,31 +222,31 @@ def log_error_from_test_path(path):
_LOGGER.error('error message')
async def test_homeassistant_path(hass, aiohttp_client):
async def test_homeassistant_path(hass, hass_client):
"""Test error logged from homeassistant path."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
with patch('homeassistant.components.system_log.HOMEASSISTANT_PATH',
new=['venv_path/homeassistant']):
log_error_from_test_path(
'venv_path/homeassistant/component/component.py')
log = (await get_error_log(hass, aiohttp_client, 1))[0]
log = (await get_error_log(hass, hass_client, 1))[0]
assert log['source'] == 'component/component.py'
async def test_config_path(hass, aiohttp_client):
async def test_config_path(hass, hass_client):
"""Test error logged from config path."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
with patch.object(hass.config, 'config_dir', new='config'):
log_error_from_test_path('config/custom_component/test.py')
log = (await get_error_log(hass, aiohttp_client, 1))[0]
log = (await get_error_log(hass, hass_client, 1))[0]
assert log['source'] == 'custom_component/test.py'
async def test_netdisco_path(hass, aiohttp_client):
async def test_netdisco_path(hass, hass_client):
"""Test error logged from netdisco path."""
await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
with patch.dict('sys.modules',
netdisco=MagicMock(__path__=['venv_path/netdisco'])):
log_error_from_test_path('venv_path/netdisco/disco_component.py')
log = (await get_error_log(hass, aiohttp_client, 1))[0]
log = (await get_error_log(hass, hass_client, 1))[0]
assert log['source'] == 'disco_component.py'

View File

@ -7,10 +7,10 @@ from homeassistant.setup import async_setup_component
@pytest.fixture
def mock_client(hass, aiohttp_client):
def mock_client(hass, hass_client):
"""Create http client for webhooks."""
hass.loop.run_until_complete(async_setup_component(hass, 'webhook', {}))
return hass.loop.run_until_complete(aiohttp_client(hass.http.app))
return hass.loop.run_until_complete(hass_client())
async def test_unregistering_webhook(hass, mock_client):