""" Component to integrate the Home Assistant cloud. For more details about this component, please refer to the documentation at https://home-assistant.io/components/cloud/ """ import asyncio from datetime import datetime, timedelta 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, CONF_NAME) from homeassistant.helpers import entityfilter, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import helpers as ga_h from homeassistant.components.google_assistant import const as ga_c from . import http_api, iot, auth_api from .const import CONFIG_DIR, DOMAIN, SERVERS REQUIREMENTS = ['warrant==0.6.1'] STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 STORAGE_ENABLE_ALEXA = 'alexa_enabled' STORAGE_ENABLE_GOOGLE = 'google_enabled' _LOGGER = logging.getLogger(__name__) _UNDEF = object() CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' CONF_COGNITO_CLIENT_ID = 'cognito_client_id' CONF_ENTITY_CONFIG = 'entity_config' CONF_FILTER = 'filter' CONF_GOOGLE_ACTIONS = 'google_actions' CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] MODE_DEV = 'development' ALEXA_ENTITY_SCHEMA = vol.Schema({ vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string, vol.Optional(alexa_sh.CONF_NAME): cv.string, }) GOOGLE_ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ga_c.CONF_ROOM_HINT): cv.string, }) ASSISTANT_SCHEMA = vol.Schema({ vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, }) ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA} }) GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA} }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In([MODE_DEV] + list(SERVERS)), # Change to optional when we include real servers vol.Optional(CONF_COGNITO_CLIENT_ID): str, vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_REGION): str, vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, }), }, extra=vol.ALLOW_EXTRA) async def async_setup(hass, config): """Initialize the Home Assistant cloud.""" if DOMAIN in config: kwargs = dict(config[DOMAIN]) else: kwargs = {CONF_MODE: DEFAULT_MODE} alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) if CONF_GOOGLE_ACTIONS not in kwargs: kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({}) kwargs[CONF_ALEXA] = alexa_sh.Config( should_expose=alexa_conf[CONF_FILTER], entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), ) cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start) await http_api.async_setup(hass) return True class Cloud: """Store the configuration of the cloud connection.""" def __init__(self, hass, mode, alexa, google_actions, cognito_client_id=None, user_pool_id=None, region=None, relayer=None, google_actions_sync_url=None, subscription_info_url=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode self.alexa_config = alexa self._google_actions = google_actions self._gactions_config = None self._prefs = None self.jwt_keyset = None self.id_token = None self.access_token = None self.refresh_token = None self.iot = iot.CloudIoT(self) self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) if mode == MODE_DEV: self.cognito_client_id = cognito_client_id self.user_pool_id = user_pool_id self.region = region self.relayer = relayer self.google_actions_sync_url = google_actions_sync_url self.subscription_info_url = subscription_info_url else: info = SERVERS[mode] self.cognito_client_id = info['cognito_client_id'] self.user_pool_id = info['user_pool_id'] self.region = info['region'] self.relayer = info['relayer'] self.google_actions_sync_url = info['google_actions_sync_url'] self.subscription_info_url = info['subscription_info_url'] @property def is_logged_in(self): """Get if cloud is logged in.""" return self.id_token is not None @property def subscription_expired(self): """Return a boolean if the subscription has expired.""" return dt_util.utcnow() > self.expiration_date + timedelta(days=7) @property def expiration_date(self): """Return the subscription expiration as a UTC datetime object.""" return datetime.combine( dt_util.parse_date(self.claims['custom:sub-exp']), datetime.min.time()).replace(tzinfo=dt_util.UTC) @property def claims(self): """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 gactions_config(self): """Return the Google Assistant config.""" if self._gactions_config is None: conf = self._google_actions def should_expose(entity): """If an entity should be exposed.""" return conf['filter'](entity.entity_id) self._gactions_config = ga_h.Config( should_expose=should_expose, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), ) return self._gactions_config @property def alexa_enabled(self): """Return if Alexa is enabled.""" return self._prefs[STORAGE_ENABLE_ALEXA] @property def google_enabled(self): """Return if Google is enabled.""" return self._prefs[STORAGE_ENABLE_GOOGLE] def path(self, *parts): """Get config path inside cloud dir. Async friendly. """ return self.hass.config.path(CONFIG_DIR, *parts) async def fetch_subscription_info(self): """Fetch subscription info.""" await self.hass.async_add_executor_job(auth_api.check_token, self) websession = self.hass.helpers.aiohttp_client.async_get_clientsession() return await websession.get( self.subscription_info_url, headers={ 'authorization': self.id_token }) async def logout(self): """Close connection and remove all credentials.""" await self.iot.disconnect() self.id_token = None self.access_token = None self.refresh_token = None self._gactions_config = None await self.hass.async_add_job( lambda: os.remove(self.user_info_path)) def write_user_info(self): """Write user info to a file.""" with open(self.user_info_path, 'wt') as file: file.write(json.dumps({ 'id_token': self.id_token, 'access_token': self.access_token, 'refresh_token': self.refresh_token, }, indent=4)) async def async_start(self, _): """Start the cloud component.""" prefs = await self._store.async_load() if prefs is None: prefs = {} if self.mode not in prefs: # Default to True if already logged in to make this not a # breaking change. enabled = await self.hass.async_add_executor_job( os.path.isfile, self.user_info_path) prefs = { STORAGE_ENABLE_ALEXA: enabled, STORAGE_ENABLE_GOOGLE: enabled, } self._prefs = prefs success = await self._fetch_jwt_keyset() # Fetching keyset can fail if internet is not up yet. if not success: self.hass.helpers.event.async_call_later(5, self.async_start) return def load_config(): """Load config.""" # 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 None with open(user_info, 'rt') as file: return json.loads(file.read()) info = await self.hass.async_add_job(load_config) if info is None: return # 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()) async def update_preferences(self, *, google_enabled=_UNDEF, alexa_enabled=_UNDEF): """Update user preferences.""" if google_enabled is not _UNDEF: self._prefs[STORAGE_ENABLE_GOOGLE] = google_enabled if alexa_enabled is not _UNDEF: self._prefs[STORAGE_ENABLE_ALEXA] = alexa_enabled await self._store.async_save(self._prefs) async 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 = await session.get(url) self.jwt_keyset = await 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