From 92a50689775eeb04af7dbcd91578d8718b9eb221 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Tue, 25 Sep 2018 23:57:55 -0700 Subject: [PATCH] Use HA native OAuth2 flow for google assistant components (#16848) * Use HA native OAuth2 flow for google assistant components * Lint * Force breaking changes * Fix CONFIG_SCHEMA --- .../components/google_assistant/__init__.py | 44 ++++------ .../components/google_assistant/auth.py | 83 ------------------- .../components/google_assistant/const.py | 3 - .../components/google_assistant/http.py | 25 ++---- .../google_assistant/test_google_assistant.py | 46 ++++------ .../components/google_assistant/test_init.py | 4 - 6 files changed, 43 insertions(+), 162 deletions(-) delete mode 100644 homeassistant/components/google_assistant/auth.py diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index b0b580dc6ac..8d4ac9f01c9 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -14,20 +14,18 @@ import async_timeout import voluptuous as vol # Typing imports -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.const import CONF_NAME from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( - DOMAIN, CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN, - CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, - DEFAULT_EXPOSED_DOMAINS, CONF_AGENT_USER_ID, CONF_API_KEY, + DOMAIN, CONF_PROJECT_ID, CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_API_KEY, SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG, CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT ) -from .auth import GoogleAssistantAuthView from .http import async_register_http _LOGGER = logging.getLogger(__name__) @@ -43,34 +41,28 @@ ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_ROOM_HINT): cv.string }) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: { - vol.Required(CONF_PROJECT_ID): cv.string, - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_EXPOSE_BY_DEFAULT, - default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean, - vol.Optional(CONF_EXPOSED_DOMAINS, - default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, - vol.Optional(CONF_AGENT_USER_ID, - default=DEFAULT_AGENT_USER_ID): cv.string, - vol.Optional(CONF_API_KEY): cv.string, - vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA} - } - }, - extra=vol.ALLOW_EXTRA) +GOOGLE_ASSISTANT_SCHEMA = vol.Schema({ + vol.Required(CONF_PROJECT_ID): cv.string, + vol.Optional(CONF_EXPOSE_BY_DEFAULT, + default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean, + vol.Optional(CONF_EXPOSED_DOMAINS, + default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, + vol.Optional(CONF_API_KEY): cv.string, + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA} +}, extra=vol.PREVENT_EXTRA) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: GOOGLE_ASSISTANT_SCHEMA +}, extra=vol.ALLOW_EXTRA) async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): """Activate Google Actions component.""" config = yaml_config.get(DOMAIN, {}) - agent_user_id = config.get(CONF_AGENT_USER_ID) api_key = config.get(CONF_API_KEY) - hass.http.register_view(GoogleAssistantAuthView(hass, config)) async_register_http(hass, config) - async def request_sync_service_handler(call): + async def request_sync_service_handler(call: ServiceCall): """Handle request sync service calls.""" websession = async_get_clientsession(hass) try: @@ -78,7 +70,7 @@ async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): res = await websession.post( REQUEST_SYNC_BASE_URL, params={'key': api_key}, - json={'agent_user_id': agent_user_id}) + json={'agent_user_id': call.context.user_id}) _LOGGER.info("Submitted request_sync request to Google") res.raise_for_status() except aiohttp.ClientResponseError: diff --git a/homeassistant/components/google_assistant/auth.py b/homeassistant/components/google_assistant/auth.py deleted file mode 100644 index 5b98e25014d..00000000000 --- a/homeassistant/components/google_assistant/auth.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Google Assistant OAuth View.""" - -import logging -from typing import Dict, Any - -# Typing imports -# if False: -from aiohttp.web import Request, Response - -from homeassistant.core import HomeAssistant -from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - HTTP_BAD_REQUEST, - HTTP_UNAUTHORIZED, - HTTP_MOVED_PERMANENTLY, -) - -from .const import ( - GOOGLE_ASSISTANT_API_ENDPOINT, - CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN -) - -BASE_OAUTH_URL = 'https://oauth-redirect.googleusercontent.com' -REDIRECT_TEMPLATE_URL = \ - '{}/r/{}#access_token={}&token_type=bearer&state={}' - -_LOGGER = logging.getLogger(__name__) - - -class GoogleAssistantAuthView(HomeAssistantView): - """Handle Google Actions auth requests.""" - - url = GOOGLE_ASSISTANT_API_ENDPOINT + '/auth' - name = 'api:google_assistant:auth' - requires_auth = False - - def __init__(self, hass: HomeAssistant, cfg: Dict[str, Any]) -> None: - """Initialize instance of the view.""" - super().__init__() - - self.project_id = cfg.get(CONF_PROJECT_ID) - self.client_id = cfg.get(CONF_CLIENT_ID) - self.access_token = cfg.get(CONF_ACCESS_TOKEN) - - async def get(self, request: Request) -> Response: - """Handle oauth token request.""" - query = request.query - redirect_uri = query.get('redirect_uri') - if not redirect_uri: - msg = 'missing redirect_uri field' - _LOGGER.warning(msg) - return self.json_message(msg, status_code=HTTP_BAD_REQUEST) - - if self.project_id not in redirect_uri: - msg = 'missing project_id in redirect_uri' - _LOGGER.warning(msg) - return self.json_message(msg, status_code=HTTP_BAD_REQUEST) - - state = query.get('state') - if not state: - msg = 'oauth request missing state' - _LOGGER.warning(msg) - return self.json_message(msg, status_code=HTTP_BAD_REQUEST) - - client_id = query.get('client_id') - if self.client_id != client_id: - msg = 'invalid client id' - _LOGGER.warning(msg) - return self.json_message(msg, status_code=HTTP_UNAUTHORIZED) - - generated_url = redirect_url(self.project_id, self.access_token, state) - - _LOGGER.info('user login in from Google Assistant') - return self.json_message( - 'redirect success', - status_code=HTTP_MOVED_PERMANENTLY, - headers={'Location': generated_url}) - - -def redirect_url(project_id: str, access_token: str, state: str) -> str: - """Generate the redirect format for the oauth request.""" - return REDIRECT_TEMPLATE_URL.format(BASE_OAUTH_URL, project_id, - access_token, state) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 12888ea2cf6..485b98e8e22 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -8,10 +8,7 @@ CONF_ENTITY_CONFIG = 'entity_config' CONF_EXPOSE_BY_DEFAULT = 'expose_by_default' CONF_EXPOSED_DOMAINS = 'exposed_domains' CONF_PROJECT_ID = 'project_id' -CONF_ACCESS_TOKEN = 'access_token' -CONF_CLIENT_ID = 'client_id' CONF_ALIASES = 'aliases' -CONF_AGENT_USER_ID = 'agent_user_id' CONF_API_KEY = 'api_key' CONF_ROOM_HINT = 'room' diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 05bc3cbd01c..65af7b932b0 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/google_assistant/ """ import logging -from aiohttp.hdrs import AUTHORIZATION from aiohttp.web import Request, Response # Typing imports @@ -15,10 +14,8 @@ from homeassistant.core import callback from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, - CONF_ACCESS_TOKEN, CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, - CONF_AGENT_USER_ID, CONF_ENTITY_CONFIG, CONF_EXPOSE, ) @@ -31,10 +28,8 @@ _LOGGER = logging.getLogger(__name__) @callback def async_register_http(hass, cfg): """Register HTTP views for Google Assistant.""" - access_token = cfg.get(CONF_ACCESS_TOKEN) expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) - agent_user_id = cfg.get(CONF_AGENT_USER_ID) entity_config = cfg.get(CONF_ENTITY_CONFIG) or {} def is_exposed(entity) -> bool: @@ -57,9 +52,8 @@ def async_register_http(hass, cfg): return is_default_exposed or explicit_expose - gass_config = Config(is_exposed, agent_user_id, entity_config) hass.http.register_view( - GoogleAssistantView(access_token, gass_config)) + GoogleAssistantView(is_exposed, entity_config)) class GoogleAssistantView(HomeAssistantView): @@ -67,20 +61,19 @@ class GoogleAssistantView(HomeAssistantView): url = GOOGLE_ASSISTANT_API_ENDPOINT name = 'api:google_assistant' - requires_auth = False # Uses access token from oauth flow + requires_auth = True - def __init__(self, access_token, gass_config): + def __init__(self, is_exposed, entity_config): """Initialize the Google Assistant request handler.""" - self.access_token = access_token - self.gass_config = gass_config + self.is_exposed = is_exposed + self.entity_config = entity_config async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" - auth = request.headers.get(AUTHORIZATION, None) - if 'Bearer {}'.format(self.access_token) != auth: - return self.json_message("missing authorization", status_code=401) - message = await request.json() # type: dict + config = Config(self.is_exposed, + request['hass_user'].id, + self.entity_config) result = await async_handle_message( - request.app['hass'], self.gass_config, message) + request.app['hass'], config, message) return self.json(result) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index d9682940bdc..2ebfa5cc9ed 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -23,7 +23,12 @@ HA_HEADERS = { PROJECT_ID = 'hasstest-1234' CLIENT_ID = 'helloworld' ACCESS_TOKEN = 'superdoublesecret' -AUTH_HEADER = {AUTHORIZATION: 'Bearer {}'.format(ACCESS_TOKEN)} + + +@pytest.fixture +def auth_header(hass_access_token): + """Generate an HTTP header with bearer token authorization.""" + return {AUTHORIZATION: 'Bearer {}'.format(hass_access_token)} @pytest.fixture @@ -33,8 +38,6 @@ def assistant_client(loop, hass, aiohttp_client): setup.async_setup_component(hass, 'google_assistant', { 'google_assistant': { 'project_id': PROJECT_ID, - 'client_id': CLIENT_ID, - 'access_token': ACCESS_TOKEN, 'entity_config': { 'light.ceiling_lights': { 'aliases': ['top lights', 'ceiling lights'], @@ -97,31 +100,14 @@ def hass_fixture(loop, hass): @asyncio.coroutine -def test_auth(assistant_client): - """Test the auth process.""" - result = yield from assistant_client.get( - ga.const.GOOGLE_ASSISTANT_API_ENDPOINT + '/auth', - params={ - 'redirect_uri': - 'http://testurl/r/{}'.format(PROJECT_ID), - 'client_id': CLIENT_ID, - 'state': 'random1234', - }, - allow_redirects=False) - assert result.status == 301 - loc = result.headers.get('Location') - assert ACCESS_TOKEN in loc - - -@asyncio.coroutine -def test_sync_request(hass_fixture, assistant_client): +def test_sync_request(hass_fixture, assistant_client, auth_header): """Test a sync request.""" reqid = '5711642932632160983' data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} result = yield from assistant_client.post( ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, data=json.dumps(data), - headers=AUTH_HEADER) + headers=auth_header) assert result.status == 200 body = yield from result.json() assert body.get('requestId') == reqid @@ -141,7 +127,7 @@ def test_sync_request(hass_fixture, assistant_client): @asyncio.coroutine -def test_query_request(hass_fixture, assistant_client): +def test_query_request(hass_fixture, assistant_client, auth_header): """Test a query request.""" reqid = '5711642932632160984' data = { @@ -165,7 +151,7 @@ def test_query_request(hass_fixture, assistant_client): result = yield from assistant_client.post( ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, data=json.dumps(data), - headers=AUTH_HEADER) + headers=auth_header) assert result.status == 200 body = yield from result.json() assert body.get('requestId') == reqid @@ -180,7 +166,7 @@ def test_query_request(hass_fixture, assistant_client): @asyncio.coroutine -def test_query_climate_request(hass_fixture, assistant_client): +def test_query_climate_request(hass_fixture, assistant_client, auth_header): """Test a query request.""" reqid = '5711642932632160984' data = { @@ -200,7 +186,7 @@ def test_query_climate_request(hass_fixture, assistant_client): result = yield from assistant_client.post( ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, data=json.dumps(data), - headers=AUTH_HEADER) + headers=auth_header) assert result.status == 200 body = yield from result.json() assert body.get('requestId') == reqid @@ -229,7 +215,7 @@ def test_query_climate_request(hass_fixture, assistant_client): @asyncio.coroutine -def test_query_climate_request_f(hass_fixture, assistant_client): +def test_query_climate_request_f(hass_fixture, assistant_client, auth_header): """Test a query request.""" # Mock demo devices as fahrenheit to see if we convert to celsius hass_fixture.config.units.temperature_unit = const.TEMP_FAHRENHEIT @@ -256,7 +242,7 @@ def test_query_climate_request_f(hass_fixture, assistant_client): result = yield from assistant_client.post( ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, data=json.dumps(data), - headers=AUTH_HEADER) + headers=auth_header) assert result.status == 200 body = yield from result.json() assert body.get('requestId') == reqid @@ -286,7 +272,7 @@ def test_query_climate_request_f(hass_fixture, assistant_client): @asyncio.coroutine -def test_execute_request(hass_fixture, assistant_client): +def test_execute_request(hass_fixture, assistant_client, auth_header): """Test an execute request.""" reqid = '5711642932632160985' data = { @@ -358,7 +344,7 @@ def test_execute_request(hass_fixture, assistant_client): result = yield from assistant_client.post( ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, data=json.dumps(data), - headers=AUTH_HEADER) + headers=auth_header) assert result.status == 200 body = yield from result.json() assert body.get('requestId') == reqid diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py index 9ced9fc329d..3f6a799b423 100644 --- a/tests/components/google_assistant/test_init.py +++ b/tests/components/google_assistant/test_init.py @@ -5,7 +5,6 @@ from homeassistant.setup import async_setup_component from homeassistant.components import google_assistant as ga GA_API_KEY = "Agdgjsj399sdfkosd932ksd" -GA_AGENT_USER_ID = "testid" @asyncio.coroutine @@ -17,9 +16,6 @@ def test_request_sync_service(aioclient_mock, hass): yield from async_setup_component(hass, 'google_assistant', { 'google_assistant': { 'project_id': 'test_project', - 'client_id': 'r7328kwdsdfsdf03223409', - 'access_token': '8wdsfjsf932492342349234', - 'agent_user_id': GA_AGENT_USER_ID, 'api_key': GA_API_KEY }})