diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 58a2152f898..7f998311a6b 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -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 diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 0ca0451e565..500ff062a0f 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -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) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 25873ba158c..338e004ce52 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -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, } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 5c9c54afd14..560d9a61aef 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -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') diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py index bb28dfc50e3..70cd5d83f41 100644 --- a/tests/components/cloud/test_auth_api.py +++ b/tests/components/cloud/test_auth_api.py @@ -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( diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 2c71f504c50..7623b25d401 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -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)) diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index c5bb6f7fda7..7d23d9faad4 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -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