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
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."""

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."""
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)

View File

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

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