Skip to content

Commit

Permalink
By default to use access_token if hass.auth.active (#15212)
Browse files Browse the repository at this point in the history
* Force to use access_token if hass.auth.active

* Not allow Basic auth with api_password if hass.auth.active

* Block websocket api_password auth when hass.auth.active

* Add legacy_api_password auth provider

* lint

* lint
  • Loading branch information
awarecan authored and balloob committed Jul 1, 2018
1 parent 3da4642 commit f874efb
Show file tree
Hide file tree
Showing 8 changed files with 466 additions and 81 deletions.
14 changes: 13 additions & 1 deletion homeassistant/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,18 @@ def active(self):
"""Return if any auth providers are registered."""
return bool(self._providers)

@property
def support_legacy(self):
"""
Return if legacy_api_password auth providers are registered.
Should be removed when we removed legacy_api_password auth providers.
"""
for provider_type, _ in self._providers:
if provider_type == 'legacy_api_password':
return True
return False

@property
def async_auth_providers(self):
"""Return a list of available auth providers."""
Expand Down Expand Up @@ -534,7 +546,7 @@ async def async_load(self):
client_id=rt_dict['client_id'],
created_at=dt_util.parse_datetime(rt_dict['created_at']),
access_token_expiration=timedelta(
rt_dict['access_token_expiration']),
seconds=rt_dict['access_token_expiration']),
token=rt_dict['token'],
)
refresh_tokens[token.id] = token
Expand Down
104 changes: 104 additions & 0 deletions homeassistant/auth_providers/legacy_api_password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""
Support Legacy API password auth provider.
It will be removed when auth system production ready
"""
from collections import OrderedDict
import hmac

import voluptuous as vol

from homeassistant.exceptions import HomeAssistantError
from homeassistant import auth, data_entry_flow
from homeassistant.core import callback

USER_SCHEMA = vol.Schema({
vol.Required('username'): str,
})


CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({
}, extra=vol.PREVENT_EXTRA)

LEGACY_USER = 'homeassistant'


class InvalidAuthError(HomeAssistantError):
"""Raised when submitting invalid authentication."""


@auth.AUTH_PROVIDERS.register('legacy_api_password')
class LegacyApiPasswordAuthProvider(auth.AuthProvider):
"""Example auth provider based on hardcoded usernames and passwords."""

DEFAULT_TITLE = 'Legacy API Password'

async def async_credential_flow(self):
"""Return a flow to login."""
return LoginFlow(self)

@callback
def async_validate_login(self, password):
"""Helper to validate a username and password."""
if not hasattr(self.hass, 'http'):
raise ValueError('http component is not loaded')

if self.hass.http.api_password is None:
raise ValueError('http component is not configured using'
' api_password')

if not hmac.compare_digest(self.hass.http.api_password.encode('utf-8'),
password.encode('utf-8')):
raise InvalidAuthError

async def async_get_or_create_credentials(self, flow_result):
"""Return LEGACY_USER always."""
for credential in await self.async_credentials():
if credential.data['username'] == LEGACY_USER:
return credential

return self.async_create_credentials({
'username': LEGACY_USER
})

async def async_user_meta_for_credentials(self, credentials):
"""
Set name as LEGACY_USER always.
Will be used to populate info when creating a new user.
"""
return {'name': LEGACY_USER}


class LoginFlow(data_entry_flow.FlowHandler):
"""Handler for the login flow."""

def __init__(self, auth_provider):
"""Initialize the login flow."""
self._auth_provider = auth_provider

async def async_step_init(self, user_input=None):
"""Handle the step of the form."""
errors = {}

if user_input is not None:
try:
self._auth_provider.async_validate_login(
user_input['password'])
except InvalidAuthError:
errors['base'] = 'invalid_auth'

if not errors:
return self.async_create_entry(
title=self._auth_provider.name,
data={}
)

schema = OrderedDict()
schema['password'] = str

return self.async_show_form(
step_id='init',
data_schema=vol.Schema(schema),
errors=errors,
)
17 changes: 16 additions & 1 deletion homeassistant/components/http/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,22 @@ def __init__(self, hass, api_password,
if is_ban_enabled:
setup_bans(hass, app, login_threshold)

setup_auth(app, trusted_networks, api_password)
if hass.auth.active:
if hass.auth.support_legacy:
_LOGGER.warning("Experimental auth api enabled and "
"legacy_api_password support enabled. Please "
"use access_token instead api_password, "
"although you can still use legacy "
"api_password")
else:
_LOGGER.warning("Experimental auth api enabled. Please use "
"access_token instead api_password.")
elif api_password is None:
_LOGGER.warning("You have been advised to set http.api_password.")

setup_auth(app, trusted_networks, hass.auth.active,
support_legacy=hass.auth.support_legacy,
api_password=api_password)

if cors_origins:
setup_cors(app, cors_origins)
Expand Down
66 changes: 39 additions & 27 deletions homeassistant/components/http/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,37 +17,44 @@


@callback
def setup_auth(app, trusted_networks, api_password):
def setup_auth(app, trusted_networks, use_auth,
support_legacy=False, api_password=None):
"""Create auth middleware for the app."""
@middleware
async 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 await handler(request)

# Check authentication
authenticated = False

if (HTTP_HEADER_HA_AUTH in request.headers and
hmac.compare_digest(
api_password.encode('utf-8'),
request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))):
if use_auth and (HTTP_HEADER_HA_AUTH in request.headers or
DATA_API_PASSWORD in request.query):
_LOGGER.warning('Please use access_token instead api_password.')

legacy_auth = (not use_auth or support_legacy) and api_password
if (hdrs.AUTHORIZATION in request.headers and
await async_validate_auth_header(
request, api_password if legacy_auth else None)):
# it included both use_auth and api_password Basic auth
authenticated = True

elif (legacy_auth and HTTP_HEADER_HA_AUTH in request.headers and
hmac.compare_digest(
api_password.encode('utf-8'),
request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))):
# A valid auth header has been set
authenticated = True

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

elif (hdrs.AUTHORIZATION in request.headers and
await async_validate_auth_header(api_password, request)):
elif _is_trusted_ip(request, trusted_networks):
authenticated = True

elif _is_trusted_ip(request, trusted_networks):
elif not use_auth and api_password is None:
# If neither password nor auth_providers set,
# just always set authenticated=True
authenticated = True

request[KEY_AUTHENTICATED] = authenticated
Expand Down Expand Up @@ -76,8 +83,12 @@ def validate_password(request, api_password):
request.app['hass'].http.api_password.encode('utf-8'))


async def async_validate_auth_header(api_password, request):
"""Test an authorization header if valid password."""
async def async_validate_auth_header(request, api_password=None):
"""
Test authorization header against access token.
Basic auth_type is legacy code, should be removed with api_password.
"""
if hdrs.AUTHORIZATION not in request.headers:
return False

Expand All @@ -88,7 +99,16 @@ async def async_validate_auth_header(api_password, request):
# If no space in authorization header
return False

if auth_type == 'Basic':
if auth_type == 'Bearer':
hass = request.app['hass']
access_token = hass.auth.async_get_access_token(auth_val)
if access_token is None:
return False

request['hass_user'] = access_token.refresh_token.user
return True

elif auth_type == 'Basic' and api_password is not None:
decoded = base64.b64decode(auth_val).decode('utf-8')
try:
username, password = decoded.split(':', 1)
Expand All @@ -102,13 +122,5 @@ async def async_validate_auth_header(api_password, request):
return hmac.compare_digest(api_password.encode('utf-8'),
password.encode('utf-8'))

if auth_type != 'Bearer':
else:
return False

hass = request.app['hass']
access_token = hass.auth.async_get_access_token(auth_val)
if access_token is None:
return False

request['hass_user'] = access_token.refresh_token.user
return True
24 changes: 15 additions & 9 deletions homeassistant/components/websocket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,26 +315,32 @@ def handle_hass_stop(event):
authenticated = True

else:
self.debug("Request auth")
await self.wsock.send_json(auth_required_message())
msg = await wsock.receive_json()
msg = AUTH_MESSAGE_SCHEMA(msg)

if 'api_password' in msg:
authenticated = validate_password(
request, msg['api_password'])

elif 'access_token' in msg:
if self.hass.auth.active and 'access_token' in msg:
self.debug("Received access_token")
token = self.hass.auth.async_get_access_token(
msg['access_token'])
authenticated = token is not None

elif ((not self.hass.auth.active or
self.hass.auth.support_legacy) and
'api_password' in msg):
self.debug("Received api_password")
authenticated = validate_password(
request, msg['api_password'])

if not authenticated:
self.debug("Invalid password")
self.debug("Authorization failed")
await self.wsock.send_json(
auth_invalid_message('Invalid password'))
auth_invalid_message('Invalid access token or password'))
await process_wrong_login(request)
return wsock

self.debug("Auth OK")
await self.wsock.send_json(auth_ok_message())

# ---------- AUTH PHASE OVER ----------
Expand Down Expand Up @@ -392,7 +398,7 @@ def handle_hass_stop(event):
if wsock.closed:
self.debug("Connection closed by client")
else:
_LOGGER.exception("Unexpected TypeError: %s", msg)
_LOGGER.exception("Unexpected TypeError: %s", err)

except ValueError as err:
msg = "Received invalid JSON"
Expand All @@ -403,7 +409,7 @@ def handle_hass_stop(event):
self._writer_task.cancel()

except CANCELLATION_ERRORS:
self.debug("Connection cancelled by server")
self.debug("Connection cancelled")

except asyncio.QueueFull:
self.log_error("Client exceeded max pending messages [1]:",
Expand Down
67 changes: 67 additions & 0 deletions tests/auth_providers/test_legacy_api_password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Tests for the legacy_api_password auth provider."""
from unittest.mock import Mock

import pytest

from homeassistant import auth
from homeassistant.auth_providers import legacy_api_password


@pytest.fixture
def store(hass):
"""Mock store."""
return auth.AuthStore(hass)


@pytest.fixture
def provider(hass, store):
"""Mock provider."""
return legacy_api_password.LegacyApiPasswordAuthProvider(hass, store, {
'type': 'legacy_api_password',
})


async def test_create_new_credential(provider):
"""Test that we create a new credential."""
credentials = await provider.async_get_or_create_credentials({})
assert credentials.data["username"] is legacy_api_password.LEGACY_USER
assert credentials.is_new is True


async def test_only_one_credentials(store, provider):
"""Call create twice will return same credential."""
credentials = await provider.async_get_or_create_credentials({})
await store.async_get_or_create_user(credentials, provider)
credentials2 = await provider.async_get_or_create_credentials({})
assert credentials2.data["username"] is legacy_api_password.LEGACY_USER
assert credentials2.id is credentials.id
assert credentials2.is_new is False


async def test_verify_not_load(hass, provider):
"""Test we raise if http module not load."""
with pytest.raises(ValueError):
provider.async_validate_login('test-password')
hass.http = Mock(api_password=None)
with pytest.raises(ValueError):
provider.async_validate_login('test-password')
hass.http = Mock(api_password='test-password')
provider.async_validate_login('test-password')


async def test_verify_login(hass, provider):
"""Test we raise if http module not load."""
hass.http = Mock(api_password='test-password')
provider.async_validate_login('test-password')
hass.http = Mock(api_password='test-password')
with pytest.raises(legacy_api_password.InvalidAuthError):
provider.async_validate_login('invalid-password')


async def test_utf_8_username_password(provider):
"""Test that we create a new credential."""
credentials = await provider.async_get_or_create_credentials({
'username': '🎉',
'password': '😎',
})
assert credentials.is_new is True
Loading

0 comments on commit f874efb

Please sign in to comment.