diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 1c773f97692914a39b43d27a9584755625c0d23d..07dc9f1ab5cbc172266fca3da041c958483f9d09 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -1,5 +1,5 @@ """ -Support the OwnTracks platform. +Device tracker platform that adds support for OwnTracks over MQTT. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks/ @@ -64,13 +64,7 @@ def get_cipher(): @asyncio.coroutine def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up an OwnTracks tracker.""" - max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT) - waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) - secret = config.get(CONF_SECRET) - - context = OwnTracksContext(async_see, secret, max_gps_accuracy, - waypoint_import, waypoint_whitelist) + context = context_from_config(async_see, config) @asyncio.coroutine def async_handle_mqtt_message(topic, payload, qos): @@ -179,6 +173,17 @@ def _decrypt_payload(secret, topic, ciphertext): return None +def context_from_config(async_see, config): + """Create an async context from Home Assistant config.""" + max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) + secret = config.get(CONF_SECRET) + + return OwnTracksContext(async_see, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist) + + class OwnTracksContext: """Hold the current OwnTracks context.""" diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py new file mode 100644 index 0000000000000000000000000000000000000000..dcc3300cc12e7880095b3eb9954cf5f6e16163d9 --- /dev/null +++ b/homeassistant/components/device_tracker/owntracks_http.py @@ -0,0 +1,54 @@ +""" +Device tracker platform that adds support for OwnTracks over HTTP. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.owntracks_http/ +""" +import asyncio + +from aiohttp.web_exceptions import HTTPInternalServerError + +from homeassistant.components.http import HomeAssistantView + +# pylint: disable=unused-import +from .owntracks import ( # NOQA + REQUIREMENTS, PLATFORM_SCHEMA, context_from_config, async_handle_message) + + +DEPENDENCIES = ['http'] + + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up an OwnTracks tracker.""" + context = context_from_config(async_see, config) + + hass.http.register_view(OwnTracksView(context)) + + return True + + +class OwnTracksView(HomeAssistantView): + """View to handle OwnTracks HTTP requests.""" + + url = '/api/owntracks/{user}/{device}' + name = 'api:owntracks' + + def __init__(self, context): + """Initialize OwnTracks URL endpoints.""" + self.context = context + + @asyncio.coroutine + def post(self, request, user, device): + """Handle an OwnTracks message.""" + hass = request.app['hass'] + + message = yield from request.json() + message['topic'] = 'owntracks/{}/{}'.format(user, device) + + try: + yield from async_handle_message(hass, self.context, message) + return self.json([]) + + except ValueError: + raise HTTPInternalServerError diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index a00da9ee5b6d66edbfc2b0991e09379b94a6c034..4b971c883d3dd607ad66540628ac58e5fd853506 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -1,8 +1,11 @@ """Authentication for HTTP component.""" import asyncio +import base64 import hmac import logging +from aiohttp import hdrs + from homeassistant.const import HTTP_HEADER_HA_AUTH from .util import get_real_ip from .const import KEY_TRUSTED_NETWORKS, KEY_AUTHENTICATED @@ -41,6 +44,10 @@ def auth_middleware(app, handler): validate_password(request, request.query[DATA_API_PASSWORD])): authenticated = True + elif (hdrs.AUTHORIZATION in request.headers and + validate_authorization_header(request)): + authenticated = True + elif is_trusted_ip(request): authenticated = True @@ -64,3 +71,22 @@ def validate_password(request, api_password): """Test if password is valid.""" return hmac.compare_digest( api_password, request.app['hass'].http.api_password) + + +def validate_authorization_header(request): + """Test an authorization header if valid password.""" + if hdrs.AUTHORIZATION not in request.headers: + return False + + auth_type, auth = request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) + + if auth_type != 'Basic': + return False + + decoded = base64.b64decode(auth).decode('utf-8') + username, password = decoded.split(':', 1) + + if username != 'homeassistant': + return False + + return validate_password(request, password) diff --git a/requirements_all.txt b/requirements_all.txt index a5aab8adf0073199f9ab2b84c1af7870ac816eee..5f28a48ac8387027a8dfcb7ceebe1fb61fc13a02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -373,6 +373,7 @@ jsonrpc-websocket==0.5 keyring>=9.3,<10.0 # homeassistant.components.device_tracker.owntracks +# homeassistant.components.device_tracker.owntracks_http libnacl==1.5.2 # homeassistant.components.dyson diff --git a/tests/components/device_tracker/test_owntracks_http.py b/tests/components/device_tracker/test_owntracks_http.py new file mode 100644 index 0000000000000000000000000000000000000000..be8bdd94ecc6b18c80bd62a4b26c62c55f3f8ae1 --- /dev/null +++ b/tests/components/device_tracker/test_owntracks_http.py @@ -0,0 +1,60 @@ +"""Test the owntracks_http platform.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import mock_coro, mock_component + + +@pytest.fixture +def mock_client(hass, test_client): + """Start the Hass HTTP component.""" + mock_component(hass, 'group') + mock_component(hass, 'zone') + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])): + hass.loop.run_until_complete( + async_setup_component(hass, 'device_tracker', { + 'device_tracker': { + 'platform': 'owntracks_http' + } + })) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + +@pytest.fixture +def mock_handle_message(): + """Mock async_handle_message.""" + with patch('homeassistant.components.device_tracker.' + 'owntracks_http.async_handle_message') as mock: + mock.return_value = mock_coro(None) + yield mock + + +@asyncio.coroutine +def test_forward_message_correctly(mock_client, mock_handle_message): + """Test that we forward messages correctly to OwnTracks handle message.""" + resp = yield from mock_client.post('/api/owntracks/user/device', json={ + '_type': 'test' + }) + assert resp.status == 200 + assert len(mock_handle_message.mock_calls) == 1 + + data = mock_handle_message.mock_calls[0][1][2] + assert data == { + '_type': 'test', + 'topic': 'owntracks/user/device' + } + + +@asyncio.coroutine +def test_handle_value_error(mock_client, mock_handle_message): + """Test that we handle errors from handle message correctly.""" + mock_handle_message.side_effect = ValueError + resp = yield from mock_client.post('/api/owntracks/user/device', json={ + '_type': 'test' + }) + assert resp.status == 500 diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 5db42b01371e8c82c18a47005970d7cc0eaa6b62..ef9c63ad09e882053d79e6820ca59afa62d79304 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -4,6 +4,7 @@ import asyncio from ipaddress import ip_address, ip_network from unittest.mock import patch +import aiohttp import pytest from homeassistant import const @@ -149,3 +150,46 @@ def test_access_granted_with_trusted_ip(mock_api_client, caplog, assert resp.status == 200, \ '{} should be trusted'.format(remote_addr) + + +@asyncio.coroutine +def test_basic_auth_works(mock_api_client, caplog): + """Test access with basic authentication.""" + req = yield from mock_api_client.get( + const.URL_API, + auth=aiohttp.BasicAuth('homeassistant', API_PASSWORD)) + + assert req.status == 200 + assert const.URL_API in caplog.text + + +@asyncio.coroutine +def test_basic_auth_username_homeassistant(mock_api_client, caplog): + """Test access with basic auth requires username homeassistant.""" + req = yield from mock_api_client.get( + const.URL_API, + auth=aiohttp.BasicAuth('wrong_username', API_PASSWORD)) + + assert req.status == 401 + + +@asyncio.coroutine +def test_basic_auth_wrong_password(mock_api_client, caplog): + """Test access with basic auth not allowed with wrong password.""" + req = yield from mock_api_client.get( + const.URL_API, + auth=aiohttp.BasicAuth('homeassistant', 'wrong password')) + + assert req.status == 401 + + +@asyncio.coroutine +def test_authorization_header_must_be_basic_type(mock_api_client, caplog): + """Test only basic authorization is allowed for auth header.""" + req = yield from mock_api_client.get( + const.URL_API, + headers={ + 'authorization': 'NotBasic abcdefg' + }) + + assert req.status == 401