Skip to content

Commit

Permalink
Cleanup http (#12424)
Browse files Browse the repository at this point in the history
* Clean up HTTP component

* Clean up HTTP mock

* Remove unused import

* Fix test

* Lint
  • Loading branch information
balloob authored and pvizeli committed Feb 15, 2018
1 parent ad8fe8a commit f32911d
Show file tree
Hide file tree
Showing 28 changed files with 828 additions and 1,031 deletions.
4 changes: 2 additions & 2 deletions homeassistant/components/emulated_hue/__init__.py
Expand Up @@ -14,7 +14,7 @@
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.components.http import REQUIREMENTS # NOQA
from homeassistant.components.http import HomeAssistantWSGI
from homeassistant.components.http import HomeAssistantHTTP
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.deprecation import get_deprecated
import homeassistant.helpers.config_validation as cv
Expand Down Expand Up @@ -86,7 +86,7 @@ def setup(hass, yaml_config):
"""Activate the emulated_hue component."""
config = Config(hass, yaml_config.get(DOMAIN, {}))

server = HomeAssistantWSGI(
server = HomeAssistantHTTP(
hass,
server_host=config.host_ip_addr,
server_port=config.listen_port,
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/frontend/__init__.py
Expand Up @@ -17,7 +17,7 @@

import homeassistant.helpers.config_validation as cv
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.auth import is_trusted_ip
from homeassistant.components.http.const import KEY_AUTHENTICATED
from homeassistant.config import find_config_file, load_yaml_config_file
from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED
from homeassistant.core import callback
Expand Down Expand Up @@ -490,7 +490,7 @@ def get(self, request, extra=None):
panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_es5

no_auth = '1'
if hass.config.api.api_password and not is_trusted_ip(request):
if hass.config.api.api_password and not request[KEY_AUTHENTICATED]:
# do not try to auto connect on load
no_auth = '0'

Expand Down
74 changes: 26 additions & 48 deletions homeassistant/components/http/__init__.py
Expand Up @@ -12,35 +12,28 @@
import ssl

from aiohttp import web
from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE
from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently
import voluptuous as vol

from homeassistant.const import (
SERVER_PORT, CONTENT_TYPE_JSON, HTTP_HEADER_HA_AUTH,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START,
HTTP_HEADER_X_REQUESTED_WITH)
SERVER_PORT, CONTENT_TYPE_JSON,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START,)
from homeassistant.core import is_callback
import homeassistant.helpers.config_validation as cv
import homeassistant.remote as rem
import homeassistant.util as hass_util
from homeassistant.util.logging import HideSensitiveDataFilter

from .auth import auth_middleware
from .ban import ban_middleware
from .const import (
KEY_BANS_ENABLED, KEY_AUTHENTICATED, KEY_LOGIN_THRESHOLD,
KEY_TRUSTED_NETWORKS, KEY_USE_X_FORWARDED_FOR)
from .auth import setup_auth
from .ban import setup_bans
from .cors import setup_cors
from .real_ip import setup_real_ip
from .const import KEY_AUTHENTICATED, KEY_REAL_IP
from .static import (
CachingFileResponse, CachingStaticResource, staticresource_middleware)
from .util import get_real_ip

REQUIREMENTS = ['aiohttp_cors==0.6.0']

ALLOWED_CORS_HEADERS = [
ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE,
HTTP_HEADER_HA_AUTH]

DOMAIN = 'http'

CONF_API_PASSWORD = 'api_password'
Expand Down Expand Up @@ -127,7 +120,7 @@ def async_setup(hass, config):
logging.getLogger('aiohttp.access').addFilter(
HideSensitiveDataFilter(api_password))

server = HomeAssistantWSGI(
server = HomeAssistantHTTP(
hass,
server_host=server_host,
server_port=server_port,
Expand Down Expand Up @@ -173,47 +166,40 @@ def start_server(event):
return True


class HomeAssistantWSGI(object):
"""WSGI server for Home Assistant."""
class HomeAssistantHTTP(object):
"""HTTP server for Home Assistant."""

def __init__(self, hass, api_password, ssl_certificate,
ssl_key, server_host, server_port, cors_origins,
use_x_forwarded_for, trusted_networks,
login_threshold, is_ban_enabled):
"""Initialize the WSGI Home Assistant server."""
middlewares = [auth_middleware, staticresource_middleware]
"""Initialize the HTTP Home Assistant server."""
app = self.app = web.Application(
middlewares=[staticresource_middleware])

# This order matters
setup_real_ip(app, use_x_forwarded_for)

if is_ban_enabled:
middlewares.insert(0, ban_middleware)
setup_bans(hass, app, login_threshold)

setup_auth(app, trusted_networks, api_password)

if cors_origins:
setup_cors(app, cors_origins)

self.app = web.Application(middlewares=middlewares)
self.app['hass'] = hass
self.app[KEY_USE_X_FORWARDED_FOR] = use_x_forwarded_for
self.app[KEY_TRUSTED_NETWORKS] = trusted_networks
self.app[KEY_BANS_ENABLED] = is_ban_enabled
self.app[KEY_LOGIN_THRESHOLD] = login_threshold
app['hass'] = hass

self.hass = hass
self.api_password = api_password
self.ssl_certificate = ssl_certificate
self.ssl_key = ssl_key
self.server_host = server_host
self.server_port = server_port
self.is_ban_enabled = is_ban_enabled
self._handler = None
self.server = None

if cors_origins:
import aiohttp_cors

self.cors = aiohttp_cors.setup(self.app, defaults={
host: aiohttp_cors.ResourceOptions(
allow_headers=ALLOWED_CORS_HEADERS,
allow_methods='*',
) for host in cors_origins
})
else:
self.cors = None

def register_view(self, view):
"""Register a view with the WSGI server.
Expand Down Expand Up @@ -292,15 +278,7 @@ def serve_file(request):
@asyncio.coroutine
def start(self):
"""Start the WSGI server."""
cors_added = set()
if self.cors is not None:
for route in list(self.app.router.routes()):
if hasattr(route, 'resource'):
route = route.resource
if route in cors_added:
continue
self.cors.add(route)
cors_added.add(route)
yield from self.app.startup()

if self.ssl_certificate:
try:
Expand Down Expand Up @@ -420,7 +398,7 @@ def handle(request):
raise HTTPUnauthorized()

_LOGGER.info('Serving %s to %s (auth: %s)',
request.path, get_real_ip(request), authenticated)
request.path, request.get(KEY_REAL_IP), authenticated)

result = handler(request, **request.match_info)

Expand Down
83 changes: 47 additions & 36 deletions homeassistant/components/http/auth.py
Expand Up @@ -7,55 +7,66 @@
from aiohttp import hdrs
from aiohttp.web import middleware

from homeassistant.core import callback
from homeassistant.const import HTTP_HEADER_HA_AUTH
from .util import get_real_ip
from .const import KEY_TRUSTED_NETWORKS, KEY_AUTHENTICATED
from .const import KEY_AUTHENTICATED, KEY_REAL_IP

DATA_API_PASSWORD = 'api_password'

_LOGGER = logging.getLogger(__name__)


@middleware
@asyncio.coroutine
def auth_middleware(request, handler):
"""Authenticate as middleware."""
# If no password set, just always set authenticated=True
if request.app['hass'].http.api_password is None:
request[KEY_AUTHENTICATED] = True
@callback
def setup_auth(app, trusted_networks, api_password):
"""Create auth middleware for the app."""
@middleware
@asyncio.coroutine
def auth_middleware(request, handler):
"""Authenticate as middleware."""
# If no password set, just always set authenticated=True
if api_password is None:
request[KEY_AUTHENTICATED] = True
return (yield from handler(request))

# Check authentication
authenticated = False

if (HTTP_HEADER_HA_AUTH in request.headers and
hmac.compare_digest(
api_password, request.headers[HTTP_HEADER_HA_AUTH])):
# A valid auth header has been set
authenticated = True

elif (DATA_API_PASSWORD in request.query and
hmac.compare_digest(api_password,
request.query[DATA_API_PASSWORD])):
authenticated = True

elif (hdrs.AUTHORIZATION in request.headers and
validate_authorization_header(api_password, request)):
authenticated = True

elif _is_trusted_ip(request, trusted_networks):
authenticated = True

request[KEY_AUTHENTICATED] = authenticated
return (yield from handler(request))

# Check authentication
authenticated = False
@asyncio.coroutine
def auth_startup(app):
"""Initialize auth middleware when app starts up."""
app.middlewares.append(auth_middleware)

if (HTTP_HEADER_HA_AUTH in request.headers and
validate_password(
request, request.headers[HTTP_HEADER_HA_AUTH])):
# A valid auth header has been set
authenticated = True
app.on_startup.append(auth_startup)

elif (DATA_API_PASSWORD in request.query and
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

request[KEY_AUTHENTICATED] = authenticated
return (yield from handler(request))


def is_trusted_ip(request):
def _is_trusted_ip(request, trusted_networks):
"""Test if request is from a trusted ip."""
ip_addr = get_real_ip(request)
ip_addr = request[KEY_REAL_IP]

return ip_addr and any(
return any(
ip_addr in trusted_network for trusted_network
in request.app[KEY_TRUSTED_NETWORKS])
in trusted_networks)


def validate_password(request, api_password):
Expand All @@ -64,7 +75,7 @@ def validate_password(request, api_password):
api_password, request.app['hass'].http.api_password)


def validate_authorization_header(request):
def validate_authorization_header(api_password, request):
"""Test an authorization header if valid password."""
if hdrs.AUTHORIZATION not in request.headers:
return False
Expand All @@ -80,4 +91,4 @@ def validate_authorization_header(request):
if username != 'homeassistant':
return False

return validate_password(request, password)
return hmac.compare_digest(api_password, password)
44 changes: 27 additions & 17 deletions homeassistant/components/http/ban.py
Expand Up @@ -10,18 +10,20 @@
from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized
import voluptuous as vol

from homeassistant.core import callback
from homeassistant.components import persistent_notification
from homeassistant.config import load_yaml_config_file
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.util.yaml import dump
from .const import (
KEY_BANS_ENABLED, KEY_BANNED_IPS, KEY_LOGIN_THRESHOLD,
KEY_FAILED_LOGIN_ATTEMPTS)
from .util import get_real_ip
from .const import KEY_REAL_IP

_LOGGER = logging.getLogger(__name__)

KEY_BANNED_IPS = 'ha_banned_ips'
KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts'
KEY_LOGIN_THRESHOLD = 'ha_login_threshold'

NOTIFICATION_ID_BAN = 'ip-ban'
NOTIFICATION_ID_LOGIN = 'http-login'

Expand All @@ -33,21 +35,31 @@
})


@callback
def setup_bans(hass, app, login_threshold):
"""Create IP Ban middleware for the app."""
@asyncio.coroutine
def ban_startup(app):
"""Initialize bans when app starts up."""
app.middlewares.append(ban_middleware)
app[KEY_BANNED_IPS] = yield from hass.async_add_job(
load_ip_bans_config, hass.config.path(IP_BANS_FILE))
app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int)
app[KEY_LOGIN_THRESHOLD] = login_threshold

app.on_startup.append(ban_startup)


@middleware
@asyncio.coroutine
def ban_middleware(request, handler):
"""IP Ban middleware."""
if not request.app[KEY_BANS_ENABLED]:
return (yield from handler(request))

if KEY_BANNED_IPS not in request.app:
hass = request.app['hass']
request.app[KEY_BANNED_IPS] = yield from hass.async_add_job(
load_ip_bans_config, hass.config.path(IP_BANS_FILE))
_LOGGER.error('IP Ban middleware loaded but banned IPs not loaded')
return (yield from handler(request))

# Verify if IP is not banned
ip_address_ = get_real_ip(request)

ip_address_ = request[KEY_REAL_IP]
is_banned = any(ip_ban.ip_address == ip_address_
for ip_ban in request.app[KEY_BANNED_IPS])

Expand All @@ -64,7 +76,7 @@ def ban_middleware(request, handler):
@asyncio.coroutine
def process_wrong_login(request):
"""Process a wrong login attempt."""
remote_addr = get_real_ip(request)
remote_addr = request[KEY_REAL_IP]

msg = ('Login attempt or request with invalid authentication '
'from {}'.format(remote_addr))
Expand All @@ -73,13 +85,11 @@ def process_wrong_login(request):
request.app['hass'], msg, 'Login attempt failed',
NOTIFICATION_ID_LOGIN)

if (not request.app[KEY_BANS_ENABLED] or
# Check if ban middleware is loaded
if (KEY_BANNED_IPS not in request.app or
request.app[KEY_LOGIN_THRESHOLD] < 1):
return

if KEY_FAILED_LOGIN_ATTEMPTS not in request.app:
request.app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int)

request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] += 1

if (request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] >
Expand Down
8 changes: 0 additions & 8 deletions homeassistant/components/http/const.py
@@ -1,11 +1,3 @@
"""HTTP specific constants."""
KEY_AUTHENTICATED = 'ha_authenticated'
KEY_USE_X_FORWARDED_FOR = 'ha_use_x_forwarded_for'
KEY_TRUSTED_NETWORKS = 'ha_trusted_networks'
KEY_REAL_IP = 'ha_real_ip'
KEY_BANS_ENABLED = 'ha_bans_enabled'
KEY_BANNED_IPS = 'ha_banned_ips'
KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts'
KEY_LOGIN_THRESHOLD = 'ha_login_threshold'

HTTP_HEADER_X_FORWARDED_FOR = 'X-Forwarded-For'

0 comments on commit f32911d

Please sign in to comment.