Add OwnTracks over HTTP (#9582)

* Add OwnTracks over HTTP

* Fix tests
This commit is contained in:
Paulus Schoutsen 2017-09-28 00:49:35 -07:00 committed by Pascal Vizeli
parent 7c8e7d6eb0
commit 6fb55b363a
6 changed files with 198 additions and 8 deletions

View File

@ -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 For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/device_tracker.owntracks/ https://home-assistant.io/components/device_tracker.owntracks/
@ -64,13 +64,7 @@ def get_cipher():
@asyncio.coroutine @asyncio.coroutine
def async_setup_scanner(hass, config, async_see, discovery_info=None): def async_setup_scanner(hass, config, async_see, discovery_info=None):
"""Set up an OwnTracks tracker.""" """Set up an OwnTracks tracker."""
max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) context = context_from_config(async_see, config)
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)
@asyncio.coroutine @asyncio.coroutine
def async_handle_mqtt_message(topic, payload, qos): def async_handle_mqtt_message(topic, payload, qos):
@ -179,6 +173,17 @@ def _decrypt_payload(secret, topic, ciphertext):
return None 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: class OwnTracksContext:
"""Hold the current OwnTracks context.""" """Hold the current OwnTracks context."""

View File

@ -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

View File

@ -1,8 +1,11 @@
"""Authentication for HTTP component.""" """Authentication for HTTP component."""
import asyncio import asyncio
import base64
import hmac import hmac
import logging import logging
from aiohttp import hdrs
from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.const import HTTP_HEADER_HA_AUTH
from .util import get_real_ip from .util import get_real_ip
from .const import KEY_TRUSTED_NETWORKS, KEY_AUTHENTICATED 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])): validate_password(request, request.query[DATA_API_PASSWORD])):
authenticated = True authenticated = True
elif (hdrs.AUTHORIZATION in request.headers and
validate_authorization_header(request)):
authenticated = True
elif is_trusted_ip(request): elif is_trusted_ip(request):
authenticated = True authenticated = True
@ -64,3 +71,22 @@ def validate_password(request, api_password):
"""Test if password is valid.""" """Test if password is valid."""
return hmac.compare_digest( return hmac.compare_digest(
api_password, request.app['hass'].http.api_password) 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)

View File

@ -373,6 +373,7 @@ jsonrpc-websocket==0.5
keyring>=9.3,<10.0 keyring>=9.3,<10.0
# homeassistant.components.device_tracker.owntracks # homeassistant.components.device_tracker.owntracks
# homeassistant.components.device_tracker.owntracks_http
libnacl==1.5.2 libnacl==1.5.2
# homeassistant.components.dyson # homeassistant.components.dyson

View File

@ -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

View File

@ -4,6 +4,7 @@ import asyncio
from ipaddress import ip_address, ip_network from ipaddress import ip_address, ip_network
from unittest.mock import patch from unittest.mock import patch
import aiohttp
import pytest import pytest
from homeassistant import const from homeassistant import const
@ -149,3 +150,46 @@ def test_access_granted_with_trusted_ip(mock_api_client, caplog,
assert resp.status == 200, \ assert resp.status == 200, \
'{} should be trusted'.format(remote_addr) '{} 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