Cloud Updates (#11404)

* Verify stored keys on startup

* Handle Google Assistant messages

* Fix tests

* Don't verify expiration when getting claims

* Remove email based check

* Lint

* Lint

* Lint
This commit is contained in:
Paulus Schoutsen 2018-01-03 10:16:59 -08:00 committed by Pascal Vizeli
parent 86e1d0f952
commit f314b6cb6c
7 changed files with 178 additions and 108 deletions

View file

@ -5,13 +5,17 @@ import json
import logging
import os
import aiohttp
import async_timeout
import voluptuous as vol
from homeassistant.const import (
EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE)
from homeassistant.helpers import entityfilter
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from homeassistant.components.alexa import smart_home
from homeassistant.components.alexa import smart_home as alexa_sh
from homeassistant.components.google_assistant import smart_home as ga_sh
from . import http_api, iot
from .const import CONFIG_DIR, DOMAIN, SERVERS
@ -21,7 +25,8 @@ REQUIREMENTS = ['warrant==0.6.1']
_LOGGER = logging.getLogger(__name__)
CONF_ALEXA = 'alexa'
CONF_ALEXA_FILTER = 'filter'
CONF_GOOGLE_ASSISTANT = 'google_assistant'
CONF_FILTER = 'filter'
CONF_COGNITO_CLIENT_ID = 'cognito_client_id'
CONF_RELAYER = 'relayer'
CONF_USER_POOL_ID = 'user_pool_id'
@ -30,9 +35,9 @@ MODE_DEV = 'development'
DEFAULT_MODE = 'production'
DEPENDENCIES = ['http']
ALEXA_SCHEMA = vol.Schema({
ASSISTANT_SCHEMA = vol.Schema({
vol.Optional(
CONF_ALEXA_FILTER,
CONF_FILTER,
default=lambda: entityfilter.generate_filter([], [], [], [])
): entityfilter.FILTER_SCHEMA,
})
@ -46,7 +51,8 @@ CONFIG_SCHEMA = vol.Schema({
vol.Optional(CONF_USER_POOL_ID): str,
vol.Optional(CONF_REGION): str,
vol.Optional(CONF_RELAYER): str,
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA
vol.Optional(CONF_ALEXA): ASSISTANT_SCHEMA,
vol.Optional(CONF_GOOGLE_ASSISTANT): ASSISTANT_SCHEMA,
}),
}, extra=vol.ALLOW_EXTRA)
@ -60,17 +66,19 @@ def async_setup(hass, config):
kwargs = {CONF_MODE: DEFAULT_MODE}
if CONF_ALEXA not in kwargs:
kwargs[CONF_ALEXA] = ALEXA_SCHEMA({})
kwargs[CONF_ALEXA] = ASSISTANT_SCHEMA({})
kwargs[CONF_ALEXA] = smart_home.Config(**kwargs[CONF_ALEXA])
if CONF_GOOGLE_ASSISTANT not in kwargs:
kwargs[CONF_GOOGLE_ASSISTANT] = ASSISTANT_SCHEMA({})
kwargs[CONF_ALEXA] = alexa_sh.Config(**kwargs[CONF_ALEXA])
kwargs['gass_should_expose'] = kwargs.pop(CONF_GOOGLE_ASSISTANT)
cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs)
@asyncio.coroutine
def init_cloud(event):
"""Initialize connection."""
yield from cloud.initialize()
success = yield from cloud.initialize()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, init_cloud)
if not success:
return False
yield from http_api.async_setup(hass)
return True
@ -79,12 +87,16 @@ def async_setup(hass, config):
class Cloud:
"""Store the configuration of the cloud connection."""
def __init__(self, hass, mode, cognito_client_id=None, user_pool_id=None,
region=None, relayer=None, alexa=None):
def __init__(self, hass, mode, alexa, gass_should_expose,
cognito_client_id=None, user_pool_id=None, region=None,
relayer=None):
"""Create an instance of Cloud."""
self.hass = hass
self.mode = mode
self.alexa_config = alexa
self._gass_should_expose = gass_should_expose
self._gass_config = None
self.jwt_keyset = None
self.id_token = None
self.access_token = None
self.refresh_token = None
@ -104,11 +116,6 @@ class Cloud:
self.region = info['region']
self.relayer = info['relayer']
@property
def cognito_email_based(self):
"""Return if cognito is email based."""
return not self.user_pool_id.endswith('GmV')
@property
def is_logged_in(self):
"""Get if cloud is logged in."""
@ -128,37 +135,37 @@ class Cloud:
@property
def claims(self):
"""Get the claims from the id token."""
from jose import jwt
return jwt.get_unverified_claims(self.id_token)
"""Return the claims from the id token."""
return self._decode_claims(self.id_token)
@property
def user_info_path(self):
"""Get path to the stored auth."""
return self.path('{}_auth.json'.format(self.mode))
@property
def gass_config(self):
"""Return the Google Assistant config."""
if self._gass_config is None:
self._gass_config = ga_sh.Config(
should_expose=self._gass_should_expose,
agent_user_id=self.claims['cognito:username']
)
return self._gass_config
@asyncio.coroutine
def initialize(self):
"""Initialize and load cloud info."""
def load_config():
"""Load the configuration."""
# Ensure config dir exists
path = self.hass.config.path(CONFIG_DIR)
if not os.path.isdir(path):
os.mkdir(path)
jwt_success = yield from self._fetch_jwt_keyset()
user_info = self.user_info_path
if os.path.isfile(user_info):
with open(user_info, 'rt') as file:
info = json.loads(file.read())
self.id_token = info['id_token']
self.access_token = info['access_token']
self.refresh_token = info['refresh_token']
if not jwt_success:
return False
yield from self.hass.async_add_job(load_config)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START,
self._start_cloud)
if self.id_token is not None:
yield from self.iot.connect()
return True
def path(self, *parts):
"""Get config path inside cloud dir.
@ -175,6 +182,7 @@ class Cloud:
self.id_token = None
self.access_token = None
self.refresh_token = None
self._gass_config = None
yield from self.hass.async_add_job(
lambda: os.remove(self.user_info_path))
@ -187,3 +195,79 @@ class Cloud:
'access_token': self.access_token,
'refresh_token': self.refresh_token,
}, indent=4))
def _start_cloud(self, event):
"""Start the cloud component."""
# Ensure config dir exists
path = self.hass.config.path(CONFIG_DIR)
if not os.path.isdir(path):
os.mkdir(path)
user_info = self.user_info_path
if not os.path.isfile(user_info):
return
with open(user_info, 'rt') as file:
info = json.loads(file.read())
# Validate tokens
try:
for token in 'id_token', 'access_token':
self._decode_claims(info[token])
except ValueError as err: # Raised when token is invalid
_LOGGER.warning('Found invalid token %s: %s', token, err)
return
self.id_token = info['id_token']
self.access_token = info['access_token']
self.refresh_token = info['refresh_token']
self.hass.add_job(self.iot.connect())
@asyncio.coroutine
def _fetch_jwt_keyset(self):
"""Fetch the JWT keyset for the Cognito instance."""
session = async_get_clientsession(self.hass)
url = ("https://cognito-idp.us-east-1.amazonaws.com/"
"{}/.well-known/jwks.json".format(self.user_pool_id))
try:
with async_timeout.timeout(10, loop=self.hass.loop):
req = yield from session.get(url)
self.jwt_keyset = yield from req.json()
return True
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
_LOGGER.error("Error fetching Cognito keyset: %s", err)
return False
def _decode_claims(self, token):
"""Decode the claims in a token."""
from jose import jwt, exceptions as jose_exceptions
try:
header = jwt.get_unverified_header(token)
except jose_exceptions.JWTError as err:
raise ValueError(str(err)) from None
kid = header.get("kid")
if kid is None:
raise ValueError('No kid in header')
# Locate the key for this kid
key = None
for key_dict in self.jwt_keyset["keys"]:
if key_dict["kid"] == kid:
key = key_dict
break
if not key:
raise ValueError(
"Unable to locate kid ({}) in keyset".format(kid))
try:
return jwt.decode(
token, key, audience=self.cognito_client_id, options={
'verify_exp': False,
})
except jose_exceptions.JWTError as err:
raise ValueError(str(err)) from None

View file

@ -1,5 +1,4 @@
"""Package to communicate with the authentication API."""
import hashlib
import logging
@ -58,11 +57,6 @@ def _map_aws_exception(err):
return ex(err.response['Error']['Message'])
def _generate_username(email):
"""Generate a username from an email address."""
return hashlib.sha512(email.encode('utf-8')).hexdigest()
def register(cloud, email, password):
"""Register a new account."""
from botocore.exceptions import ClientError
@ -72,10 +66,7 @@ def register(cloud, email, password):
# https://github.com/capless/warrant/pull/82
cognito.add_base_attributes()
try:
if cloud.cognito_email_based:
cognito.register(email, password)
else:
cognito.register(_generate_username(email), password)
cognito.register(email, password)
except ClientError as err:
raise _map_aws_exception(err)
@ -86,11 +77,7 @@ def confirm_register(cloud, confirmation_code, email):
cognito = _cognito(cloud)
try:
if cloud.cognito_email_based:
cognito.confirm_sign_up(confirmation_code, email)
else:
cognito.confirm_sign_up(confirmation_code,
_generate_username(email))
cognito.confirm_sign_up(confirmation_code, email)
except ClientError as err:
raise _map_aws_exception(err)
@ -114,10 +101,7 @@ def forgot_password(cloud, email):
"""Initiate forgotten password flow."""
from botocore.exceptions import ClientError
if cloud.cognito_email_based:
cognito = _cognito(cloud, username=email)
else:
cognito = _cognito(cloud, username=_generate_username(email))
cognito = _cognito(cloud, username=email)
try:
cognito.initiate_forgot_password()
@ -129,10 +113,7 @@ def confirm_forgot_password(cloud, confirmation_code, email, new_password):
"""Confirm forgotten password code and change password."""
from botocore.exceptions import ClientError
if cloud.cognito_email_based:
cognito = _cognito(cloud, username=email)
else:
cognito = _cognito(cloud, username=_generate_username(email))
cognito = _cognito(cloud, username=email)
try:
cognito.confirm_forgot_password(confirmation_code, new_password)

View file

@ -252,6 +252,6 @@ def _account_data(cloud):
return {
'email': claims['email'],
'sub_exp': claims.get('custom:sub-exp'),
'sub_exp': claims['custom:sub-exp'],
'cloud': cloud.iot.state,
}

View file

@ -5,7 +5,8 @@ import logging
from aiohttp import hdrs, client_exceptions, WSMsgType
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.components.alexa import smart_home
from homeassistant.components.alexa import smart_home as alexa
from homeassistant.components.google_assistant import smart_home as ga
from homeassistant.util.decorator import Registry
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import auth_api
@ -204,9 +205,18 @@ def async_handle_message(hass, cloud, handler_name, payload):
@asyncio.coroutine
def async_handle_alexa(hass, cloud, payload):
"""Handle an incoming IoT message for Alexa."""
return (yield from smart_home.async_handle_message(hass,
cloud.alexa_config,
payload))
result = yield from alexa.async_handle_message(hass, cloud.alexa_config,
payload)
return result
@HANDLERS.register('google_assistant')
@asyncio.coroutine
def async_handle_google_assistant(hass, cloud, payload):
"""Handle an incoming IoT message for Google Assistant."""
result = yield from ga.async_handle_message(hass, cloud.gass_config,
payload)
return result
@HANDLERS.register('cloud')

View file

@ -78,21 +78,17 @@ def test_login(mock_cognito):
def test_register(mock_cognito):
"""Test registering an account."""
cloud = MagicMock()
cloud.cognito_email_based = False
cloud = MagicMock()
cloud.cognito_email_based = False
auth_api.register(cloud, 'email@home-assistant.io', 'password')
assert len(mock_cognito.register.mock_calls) == 1
result_user, result_password = mock_cognito.register.mock_calls[0][1]
assert result_user == \
auth_api._generate_username('email@home-assistant.io')
assert result_user == 'email@home-assistant.io'
assert result_password == 'password'
def test_register_fails(mock_cognito):
"""Test registering an account."""
cloud = MagicMock()
cloud.cognito_email_based = False
mock_cognito.register.side_effect = aws_error('SomeError')
with pytest.raises(auth_api.CloudError):
auth_api.register(cloud, 'email@home-assistant.io', 'password')
@ -101,19 +97,16 @@ def test_register_fails(mock_cognito):
def test_confirm_register(mock_cognito):
"""Test confirming a registration of an account."""
cloud = MagicMock()
cloud.cognito_email_based = False
auth_api.confirm_register(cloud, '123456', 'email@home-assistant.io')
assert len(mock_cognito.confirm_sign_up.mock_calls) == 1
result_code, result_user = mock_cognito.confirm_sign_up.mock_calls[0][1]
assert result_user == \
auth_api._generate_username('email@home-assistant.io')
assert result_user == 'email@home-assistant.io'
assert result_code == '123456'
def test_confirm_register_fails(mock_cognito):
"""Test an error during confirmation of an account."""
cloud = MagicMock()
cloud.cognito_email_based = False
mock_cognito.confirm_sign_up.side_effect = aws_error('SomeError')
with pytest.raises(auth_api.CloudError):
auth_api.confirm_register(cloud, '123456', 'email@home-assistant.io')
@ -138,7 +131,6 @@ def test_resend_email_confirm_fails(mock_cognito):
def test_forgot_password(mock_cognito):
"""Test starting forgot password flow."""
cloud = MagicMock()
cloud.cognito_email_based = False
auth_api.forgot_password(cloud, 'email@home-assistant.io')
assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1
@ -146,7 +138,6 @@ def test_forgot_password(mock_cognito):
def test_forgot_password_fails(mock_cognito):
"""Test failure when starting forgot password flow."""
cloud = MagicMock()
cloud.cognito_email_based = False
mock_cognito.initiate_forgot_password.side_effect = aws_error('SomeError')
with pytest.raises(auth_api.CloudError):
auth_api.forgot_password(cloud, 'email@home-assistant.io')
@ -155,7 +146,6 @@ def test_forgot_password_fails(mock_cognito):
def test_confirm_forgot_password(mock_cognito):
"""Test confirming forgot password."""
cloud = MagicMock()
cloud.cognito_email_based = False
auth_api.confirm_forgot_password(
cloud, '123456', 'email@home-assistant.io', 'new password')
assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1
@ -168,7 +158,6 @@ def test_confirm_forgot_password(mock_cognito):
def test_confirm_forgot_password_fails(mock_cognito):
"""Test failure when confirming forgot password."""
cloud = MagicMock()
cloud.cognito_email_based = False
mock_cognito.confirm_forgot_password.side_effect = aws_error('SomeError')
with pytest.raises(auth_api.CloudError):
auth_api.confirm_forgot_password(

View file

@ -14,7 +14,8 @@ from tests.common import mock_coro
@pytest.fixture
def cloud_client(hass, test_client):
"""Fixture that can fetch from the cloud client."""
with patch('homeassistant.components.cloud.Cloud.initialize'):
with patch('homeassistant.components.cloud.Cloud.initialize',
return_value=mock_coro(True)):
hass.loop.run_until_complete(async_setup_component(hass, 'cloud', {
'cloud': {
'mode': 'development',
@ -24,6 +25,8 @@ def cloud_client(hass, test_client):
'relayer': 'relayer',
}
}))
hass.data['cloud']._decode_claims = \
lambda token: jwt.get_unverified_claims(token)
with patch('homeassistant.components.cloud.Cloud.write_user_info'):
yield hass.loop.run_until_complete(test_client(hass.http.app))

View file

@ -3,7 +3,6 @@ import asyncio
import json
from unittest.mock import patch, MagicMock, mock_open
from jose import jwt
import pytest
from homeassistant.components import cloud
@ -31,7 +30,8 @@ def test_constructor_loads_info_from_constant():
'region': 'test-region',
'relayer': 'test-relayer',
}
}):
}), patch('homeassistant.components.cloud.Cloud._fetch_jwt_keyset',
return_value=mock_coro(True)):
result = yield from cloud.async_setup(hass, {
'cloud': {cloud.CONF_MODE: 'beer'}
})
@ -50,15 +50,17 @@ def test_constructor_loads_info_from_config():
"""Test non-dev mode loads info from SERVERS constant."""
hass = MagicMock(data={})
result = yield from cloud.async_setup(hass, {
'cloud': {
cloud.CONF_MODE: cloud.MODE_DEV,
'cognito_client_id': 'test-cognito_client_id',
'user_pool_id': 'test-user_pool_id',
'region': 'test-region',
'relayer': 'test-relayer',
}
})
with patch('homeassistant.components.cloud.Cloud._fetch_jwt_keyset',
return_value=mock_coro(True)):
result = yield from cloud.async_setup(hass, {
'cloud': {
cloud.CONF_MODE: cloud.MODE_DEV,
'cognito_client_id': 'test-cognito_client_id',
'user_pool_id': 'test-user_pool_id',
'region': 'test-region',
'relayer': 'test-relayer',
}
})
assert result
cl = hass.data['cloud']
@ -79,12 +81,13 @@ def test_initialize_loads_info(mock_os, hass):
'refresh_token': 'test-refresh-token',
}))
cl = cloud.Cloud(hass, cloud.MODE_DEV)
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None)
cl.iot = MagicMock()
cl.iot.connect.return_value = mock_coro()
with patch('homeassistant.components.cloud.open', mopen, create=True):
yield from cl.initialize()
with patch('homeassistant.components.cloud.open', mopen, create=True), \
patch('homeassistant.components.cloud.Cloud._decode_claims'):
cl._start_cloud(None)
assert cl.id_token == 'test-id-token'
assert cl.access_token == 'test-access-token'
@ -95,7 +98,7 @@ def test_initialize_loads_info(mock_os, hass):
@asyncio.coroutine
def test_logout_clears_info(mock_os, hass):
"""Test logging out disconnects and removes info."""
cl = cloud.Cloud(hass, cloud.MODE_DEV)
cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None)
cl.iot = MagicMock()
cl.iot.disconnect.return_value = mock_coro()
@ -113,7 +116,7 @@ def test_write_user_info():
"""Test writing user info works."""
mopen = mock_open()
cl = cloud.Cloud(MagicMock(), cloud.MODE_DEV)
cl = cloud.Cloud(MagicMock(), cloud.MODE_DEV, None, None)
cl.id_token = 'test-id-token'
cl.access_token = 'test-access-token'
cl.refresh_token = 'test-refresh-token'
@ -135,24 +138,24 @@ def test_write_user_info():
@asyncio.coroutine
def test_subscription_expired():
"""Test subscription being expired."""
cl = cloud.Cloud(None, cloud.MODE_DEV)
cl.id_token = jwt.encode({
cl = cloud.Cloud(None, cloud.MODE_DEV, None, None)
token_val = {
'custom:sub-exp': '2017-11-13'
}, 'test')
with patch('homeassistant.util.dt.utcnow',
return_value=utcnow().replace(year=2018)):
}
with patch.object(cl, '_decode_claims', return_value=token_val), \
patch('homeassistant.util.dt.utcnow',
return_value=utcnow().replace(year=2018)):
assert cl.subscription_expired
@asyncio.coroutine
def test_subscription_not_expired():
"""Test subscription not being expired."""
cl = cloud.Cloud(None, cloud.MODE_DEV)
cl.id_token = jwt.encode({
cl = cloud.Cloud(None, cloud.MODE_DEV, None, None)
token_val = {
'custom:sub-exp': '2017-11-13'
}, 'test')
with patch('homeassistant.util.dt.utcnow',
return_value=utcnow().replace(year=2017, month=11, day=9)):
}
with patch.object(cl, '_decode_claims', return_value=token_val), \
patch('homeassistant.util.dt.utcnow',
return_value=utcnow().replace(year=2017, month=11, day=9)):
assert not cl.subscription_expired