From 19d1d748d447e425031d3b5da74e2121b472ceba Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Mon, 14 Aug 2017 01:37:50 -0400 Subject: [PATCH] Add support for Automatic OAuth2 authentication (#8962) * Add support for Automatic OAuth2 authentication * Fix async conversion of configurator * Rename method for async * Use hass.components to get configurator component * Fix typo * Move session data to hidden directory * Make configurator callback optional --- homeassistant/components/apple_tv.py | 2 +- homeassistant/components/axis.py | 2 +- homeassistant/components/configurator.py | 107 ++++++---- .../components/device_tracker/automatic.py | 182 +++++++++++++++--- .../components/device_tracker/icloud.py | 13 +- homeassistant/components/ecobee.py | 7 +- homeassistant/components/fan/insteon_local.py | 7 +- .../www_static/images/logo_automatic.png | Bin 0 -> 6244 bytes homeassistant/components/light/hue.py | 9 +- .../components/light/insteon_local.py | 7 +- .../components/media_player/braviatv.py | 7 +- .../components/media_player/gpmdp.py | 7 +- homeassistant/components/media_player/plex.py | 6 +- .../components/media_player/spotify.py | 7 +- .../components/media_player/webostv.py | 7 +- homeassistant/components/nest.py | 7 +- homeassistant/components/sensor/fitbit.py | 14 +- homeassistant/components/sensor/sabnzbd.py | 4 +- .../components/switch/insteon_local.py | 7 +- homeassistant/components/tradfri.py | 5 +- homeassistant/components/wink.py | 11 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../device_tracker/test_automatic.py | 33 +++- tests/components/test_configurator.py | 8 +- 25 files changed, 314 insertions(+), 149 deletions(-) create mode 100644 homeassistant/components/frontend/www_static/images/logo_automatic.png diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index c5f40ca5db8..7a2ff7610f7 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -91,7 +91,7 @@ def request_configuration(hass, config, atv, credentials): hass.async_add_job(configurator.request_done, instance) instance = configurator.request_config( - hass, 'Apple TV Authentication', configuration_callback, + 'Apple TV Authentication', configuration_callback, description='Please enter PIN code shown on screen.', submit_caption='Confirm', fields=[{'id': 'pin', 'name': 'PIN Code', 'type': 'password'}] diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index d83e07989e6..eaf85937658 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -110,7 +110,7 @@ def request_configuration(hass, name, host, serialnumber): title = '{} ({})'.format(name, host) request_id = configurator.request_config( - hass, title, configuration_callback, + title, configuration_callback, description='Functionality: ' + str(AXIS_INCLUDE), entity_picture="/static/images/logo_axis.png", link_name='Axis platform documentation', diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index 660a62a5b89..2da8967bddf 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -7,19 +7,21 @@ A callback has to be provided to `request_config` which will be called when the user has submitted configuration information. """ import asyncio +import functools as ft import logging from homeassistant.core import callback as async_callback from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME, \ ATTR_ENTITY_PICTURE from homeassistant.loader import bind_hass -from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.util.async import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) -_REQUESTS = {} _KEY_INSTANCE = 'configurator' +DATA_REQUESTS = 'configurator_requests' + ATTR_CONFIGURE_ID = 'configure_id' ATTR_DESCRIPTION = 'description' ATTR_DESCRIPTION_IMAGE = 'description_image' @@ -39,63 +41,89 @@ STATE_CONFIGURED = 'configured' @bind_hass -def request_config( - hass, name, callback, description=None, description_image=None, +@async_callback +def async_request_config( + hass, name, callback=None, description=None, description_image=None, submit_caption=None, fields=None, link_name=None, link_url=None, entity_picture=None): """Create a new request for configuration. Will return an ID to be used for sequent calls. """ - instance = run_callback_threadsafe(hass.loop, - _async_get_instance, - hass).result() + instance = hass.data.get(_KEY_INSTANCE) - request_id = instance.request_config( + if instance is None: + instance = hass.data[_KEY_INSTANCE] = Configurator(hass) + + request_id = instance.async_request_config( name, callback, description, description_image, submit_caption, fields, link_name, link_url, entity_picture) - _REQUESTS[request_id] = instance + if DATA_REQUESTS not in hass.data: + hass.data[DATA_REQUESTS] = {} + + hass.data[DATA_REQUESTS][request_id] = instance return request_id -def notify_errors(request_id, error): +@bind_hass +def request_config(hass, *args, **kwargs): + """Create a new request for configuration. + + Will return an ID to be used for sequent calls. + """ + return run_callback_threadsafe( + hass.loop, ft.partial(async_request_config, hass, *args, **kwargs) + ).result() + + +@bind_hass +@async_callback +def async_notify_errors(hass, request_id, error): """Add errors to a config request.""" try: - _REQUESTS[request_id].notify_errors(request_id, error) + hass.data[DATA_REQUESTS][request_id].async_notify_errors( + request_id, error) except KeyError: # If request_id does not exist pass -def request_done(request_id): +@bind_hass +def notify_errors(hass, request_id, error): + """Add errors to a config request.""" + return run_callback_threadsafe( + hass.loop, async_notify_errors, hass, request_id, error + ).result() + + +@bind_hass +@async_callback +def async_request_done(hass, request_id): """Mark a configuration request as done.""" try: - _REQUESTS.pop(request_id).request_done(request_id) + hass.data[DATA_REQUESTS].pop(request_id).async_request_done(request_id) except KeyError: # If request_id does not exist pass +@bind_hass +def request_done(hass, request_id): + """Mark a configuration request as done.""" + return run_callback_threadsafe( + hass.loop, async_request_done, hass, request_id + ).result() + + @asyncio.coroutine def async_setup(hass, config): """Set up the configurator component.""" return True -@async_callback -def _async_get_instance(hass): - """Get an instance per hass object.""" - instance = hass.data.get(_KEY_INSTANCE) - - if instance is None: - instance = hass.data[_KEY_INSTANCE] = Configurator(hass) - - return instance - - class Configurator(object): """The class to keep track of current configuration requests.""" @@ -105,14 +133,16 @@ class Configurator(object): self._cur_id = 0 self._requests = {} hass.services.async_register( - DOMAIN, SERVICE_CONFIGURE, self.handle_service_call) + DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call) - def request_config( + @async_callback + def async_request_config( self, name, callback, description, description_image, submit_caption, fields, link_name, link_url, entity_picture): """Set up a request for configuration.""" - entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=self.hass) + entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, name, hass=self.hass) if fields is None: fields = [] @@ -138,11 +168,12 @@ class Configurator(object): ] if value is not None }) - self.hass.states.set(entity_id, STATE_CONFIGURE, data) + self.hass.states.async_set(entity_id, STATE_CONFIGURE, data) return request_id - def notify_errors(self, request_id, error): + @async_callback + def async_notify_errors(self, request_id, error): """Update the state with errors.""" if not self._validate_request_id(request_id): return @@ -154,9 +185,10 @@ class Configurator(object): new_data = dict(state.attributes) new_data[ATTR_ERRORS] = error - self.hass.states.set(entity_id, STATE_CONFIGURE, new_data) + self.hass.states.async_set(entity_id, STATE_CONFIGURE, new_data) - def request_done(self, request_id): + @async_callback + def async_request_done(self, request_id): """Remove the configuration request.""" if not self._validate_request_id(request_id): return @@ -167,15 +199,16 @@ class Configurator(object): # the result fo the service call (current design limitation). # Instead, we will set it to configured to give as feedback but delete # it shortly after so that it is deleted when the client updates. - self.hass.states.set(entity_id, STATE_CONFIGURED) + self.hass.states.async_set(entity_id, STATE_CONFIGURED) def deferred_remove(event): """Remove the request state.""" - self.hass.states.remove(entity_id) + self.hass.states.async_remove(entity_id) - self.hass.bus.listen_once(EVENT_TIME_CHANGED, deferred_remove) + self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove) - def handle_service_call(self, call): + @async_callback + def async_handle_service_call(self, call): """Handle a configure service call.""" request_id = call.data.get(ATTR_CONFIGURE_ID) @@ -186,8 +219,8 @@ class Configurator(object): entity_id, fields, callback = self._requests[request_id] # field validation goes here? - - self.hass.async_add_job(callback, call.data.get(ATTR_FIELDS, {})) + if callback: + self.hass.async_add_job(callback, call.data.get(ATTR_FIELDS, {})) def _generate_unique_id(self): """Generate a unique configurator ID.""" diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 891f1b22775..1cd0e84cd8f 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -6,28 +6,33 @@ https://home-assistant.io/components/device_tracker.automatic/ """ import asyncio from datetime import timedelta +import json import logging +import os +from aiohttp import web import voluptuous as vol from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA, ATTR_ATTRIBUTES, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_MAC, ATTR_GPS, ATTR_GPS_ACCURACY) +from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, - EVENT_HOMEASSISTANT_START) + CONF_USERNAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.4.0'] +REQUIREMENTS = ['aioautomatic==0.5.0'] +DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) CONF_CLIENT_ID = 'client_id' CONF_SECRET = 'secret' CONF_DEVICES = 'devices' +CONF_CURRENT_LOCATION = 'current_location' DEFAULT_TIMEOUT = 5 @@ -38,38 +43,76 @@ ATTR_FUEL_LEVEL = 'fuel_level' EVENT_AUTOMATIC_UPDATE = 'automatic_update' +AUTOMATIC_CONFIG_FILE = '.automatic/session-{}.json' + +DATA_CONFIGURING = 'automatic_configurator_clients' +DATA_REFRESH_TOKEN = 'refresh_token' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_SECRET): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, + vol.Inclusive(CONF_USERNAME, 'auth'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'auth'): cv.string, + vol.Optional(CONF_CURRENT_LOCATION, default=False): cv.boolean, vol.Optional(CONF_DEVICES, default=None): vol.All( cv.ensure_list, [cv.string]) }) +def _get_refresh_token_from_file(hass, filename): + """Attempt to load session data from file.""" + path = hass.config.path(filename) + + if not os.path.isfile(path): + return None + + try: + with open(path) as data_file: + data = json.load(data_file) + if data is None: + return None + + return data.get(DATA_REFRESH_TOKEN) + except ValueError: + return None + + +def _write_refresh_token_to_file(hass, filename, refresh_token): + """Attempt to store session data to file.""" + path = hass.config.path(filename) + + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w+') as data_file: + json.dump({ + DATA_REFRESH_TOKEN: refresh_token + }, data_file) + + @asyncio.coroutine def async_setup_scanner(hass, config, async_see, discovery_info=None): """Validate the configuration and return an Automatic scanner.""" import aioautomatic + hass.http.register_view(AutomaticAuthCallbackView()) + + scope = FULL_SCOPE if config.get(CONF_CURRENT_LOCATION) else DEFAULT_SCOPE + client = aioautomatic.Client( client_id=config[CONF_CLIENT_ID], client_secret=config[CONF_SECRET], client_session=async_get_clientsession(hass), request_kwargs={'timeout': DEFAULT_TIMEOUT}) - try: - try: - session = yield from client.create_session_from_password( - FULL_SCOPE, config[CONF_USERNAME], config[CONF_PASSWORD]) - except aioautomatic.exceptions.ForbiddenError as exc: - if not str(exc).startswith("invalid_scope"): - raise exc - _LOGGER.info("Client not authorized for current_location scope. " - "location:updated events will not be received.") - session = yield from client.create_session_from_password( - DEFAULT_SCOPE, config[CONF_USERNAME], config[CONF_PASSWORD]) + filename = AUTOMATIC_CONFIG_FILE.format(config[CONF_CLIENT_ID]) + refresh_token = yield from hass.async_add_job( + _get_refresh_token_from_file, hass, filename) + + @asyncio.coroutine + def initialize_data(session): + """Initialize the AutomaticData object from the created session.""" + hass.async_add_job( + _write_refresh_token_to_file, hass, filename, + session.refresh_token) data = AutomaticData( hass, client, session, config[CONF_DEVICES], async_see) @@ -77,26 +120,105 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): vehicles = yield from session.get_vehicles() for vehicle in vehicles: hass.async_add_job(data.load_vehicle(vehicle)) - except aioautomatic.exceptions.AutomaticError as err: - _LOGGER.error(str(err)) - return False - @callback - def ws_connect(event): - """Open the websocket connection.""" - hass.async_add_job(data.ws_connect()) + # Create a task instead of adding a tracking job, since this task will + # run until the websocket connection is closed. + hass.loop.create_task(data.ws_connect()) - @callback - def ws_close(event): - """Close the websocket connection.""" - hass.async_add_job(data.ws_close()) + if refresh_token is not None: + try: + session = yield from client.create_session_from_refresh_token( + refresh_token) + yield from initialize_data(session) + return True + except aioautomatic.exceptions.BadRequestError as err: + if str(err) == 'err_invalid_refresh_token': + _LOGGER.error("Stored refresh token was invalid.") + yield from hass.async_add_job( + _write_refresh_token_to_file, hass, filename, None) + else: + _LOGGER.error(str(err)) + return False + except aioautomatic.exceptions.AutomaticError as err: + _LOGGER.error(str(err)) + return False - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, ws_connect) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, ws_close) + if CONF_USERNAME in config: + try: + session = yield from client.create_session_from_password( + scope, config[CONF_USERNAME], config[CONF_PASSWORD]) + yield from initialize_data(session) + return True + except aioautomatic.exceptions.AutomaticError as err: + _LOGGER.error(str(err)) + return False + configurator = hass.components.configurator + request_id = configurator.async_request_config( + "Automatic", description=( + "Authorization required for Automatic device tracker."), + link_name="Click here to authorize Home Assistant.", + link_url=client.generate_oauth_url(scope), + entity_picture="/static/images/logo_automatic.png", + ) + + @asyncio.coroutine + def initialize_callback(code, state): + """Callback after OAuth2 response is returned.""" + try: + session = yield from client.create_session_from_oauth_code( + code, state) + yield from initialize_data(session) + configurator.async_request_done(request_id) + except aioautomatic.exceptions.AutomaticError as err: + _LOGGER.error(str(err)) + configurator.async_notify_errors(request_id, str(err)) + return False + + if DATA_CONFIGURING not in hass.data: + hass.data[DATA_CONFIGURING] = {} + + hass.data[DATA_CONFIGURING][client.state] = initialize_callback return True +class AutomaticAuthCallbackView(HomeAssistantView): + """Handle OAuth finish callback requests.""" + + requires_auth = False + url = '/api/automatic/callback' + name = 'api:automatic:callback' + + @callback + def get(self, request): # pylint: disable=no-self-use + """Finish OAuth callback request.""" + hass = request.app['hass'] + params = request.query + response = web.HTTPFound('/states') + + if 'state' not in params or 'code' not in params: + if 'error' in params: + _LOGGER.error( + "Error authorizing Automatic: %s", params['error']) + return response + else: + _LOGGER.error( + "Error authorizing Automatic. Invalid response returned.") + return response + + if DATA_CONFIGURING not in hass.data or \ + params['state'] not in hass.data[DATA_CONFIGURING]: + _LOGGER.error("Automatic configuration request not found.") + return response + + code = params['code'] + state = params['state'] + initialize_callback = hass.data[DATA_CONFIGURING][state] + hass.async_add_job(initialize_callback(code, state)) + + return response + + class AutomaticData(object): """A class representing an Automatic cloud service connection.""" @@ -115,6 +237,8 @@ class AutomaticData(object): lambda name, event: self.hass.async_add_job( self.handle_event(name, event))) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.ws_close()) + @asyncio.coroutine def handle_event(self, name, event): """Coroutine to update state for a realtime event.""" diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index 194a2f4bfac..f20dad1fceb 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -19,7 +19,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify import homeassistant.util.dt as dt_util from homeassistant.util.location import distance -from homeassistant.loader import get_component _LOGGER = logging.getLogger(__name__) @@ -209,7 +208,7 @@ class Icloud(DeviceScanner): if self.accountname in _CONFIGURING: request_id = _CONFIGURING.pop(self.accountname) - configurator = get_component('configurator') + configurator = self.hass.components.configurator configurator.request_done(request_id) # Trigger the next step immediately @@ -217,7 +216,7 @@ class Icloud(DeviceScanner): def icloud_need_trusted_device(self): """We need a trusted device.""" - configurator = get_component('configurator') + configurator = self.hass.components.configurator if self.accountname in _CONFIGURING: return @@ -229,7 +228,7 @@ class Icloud(DeviceScanner): devicesstring += "{}: {};".format(i, devicename) _CONFIGURING[self.accountname] = configurator.request_config( - self.hass, 'iCloud {}'.format(self.accountname), + 'iCloud {}'.format(self.accountname), self.icloud_trusted_device_callback, description=( 'Please choose your trusted device by entering' @@ -259,17 +258,17 @@ class Icloud(DeviceScanner): if self.accountname in _CONFIGURING: request_id = _CONFIGURING.pop(self.accountname) - configurator = get_component('configurator') + configurator = self.hass.components.configurator configurator.request_done(request_id) def icloud_need_verification_code(self): """Return the verification code.""" - configurator = get_component('configurator') + configurator = self.hass.components.configurator if self.accountname in _CONFIGURING: return _CONFIGURING[self.accountname] = configurator.request_config( - self.hass, 'iCloud {}'.format(self.accountname), + 'iCloud {}'.format(self.accountname), self.icloud_verification_callback, description=('Please enter the validation code:'), entity_picture="/static/images/config_icloud.png", diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index f0c95f7de3d..86261650685 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -13,7 +13,6 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.const import CONF_API_KEY -from homeassistant.loader import get_component from homeassistant.util import Throttle REQUIREMENTS = ['python-ecobee-api==0.0.7'] @@ -41,7 +40,7 @@ CONFIG_SCHEMA = vol.Schema({ def request_configuration(network, hass, config): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator if 'ecobee' in _CONFIGURING: configurator.notify_errors( _CONFIGURING['ecobee'], "Failed to register, please try again.") @@ -56,7 +55,7 @@ def request_configuration(network, hass, config): setup_ecobee(hass, network, config) _CONFIGURING['ecobee'] = configurator.request_config( - hass, "Ecobee", ecobee_configuration_callback, + "Ecobee", ecobee_configuration_callback, description=( 'Please authorize this app at https://www.ecobee.com/consumer' 'portal/index.html with pin code: ' + network.pin), @@ -73,7 +72,7 @@ def setup_ecobee(hass, network, config): return if 'ecobee' in _CONFIGURING: - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(_CONFIGURING.pop('ecobee')) hold_temp = config[DOMAIN].get(CONF_HOLD_TEMP) diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py index a18c173ecca..5bdfec08427 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -13,7 +13,6 @@ from homeassistant.components.fan import ( ATTR_SPEED, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED, FanEntity) from homeassistant.helpers.entity import ToggleEntity -from homeassistant.loader import get_component import homeassistant.util as util _CONFIGURING = {} @@ -57,7 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): def request_configuration(device_id, insteonhub, model, hass, add_devices_callback): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator # We got an error if this method is called while we are configuring if device_id in _CONFIGURING: @@ -72,7 +71,7 @@ def request_configuration(device_id, insteonhub, model, hass, add_devices_callback) _CONFIGURING[device_id] = configurator.request_config( - hass, 'Insteon ' + model + ' addr: ' + device_id, + 'Insteon ' + model + ' addr: ' + device_id, insteon_fan_config_callback, description=('Enter a name for ' + model + ' Fan addr: ' + device_id), entity_picture='/static/images/config_insteon.png', @@ -85,7 +84,7 @@ def setup_fan(device_id, name, insteonhub, hass, add_devices_callback): """Set up the fan.""" if device_id in _CONFIGURING: request_id = _CONFIGURING.pop(device_id) - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(request_id) _LOGGER.info("Device configuration done!") diff --git a/homeassistant/components/frontend/www_static/images/logo_automatic.png b/homeassistant/components/frontend/www_static/images/logo_automatic.png new file mode 100644 index 0000000000000000000000000000000000000000..ab03fa93b4c6cb196be42623c5052446cb76d3e6 GIT binary patch literal 6244 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 z