From 2bd9f5680dcfc38e11a77884af36f76ed72ea9bf Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 19 Oct 2019 07:37:44 +0200 Subject: [PATCH] Report state (#27759) * Add report state config * Add initial steps for local report state * Use owner of system as user_id * First working prototype * Only report state if requested * Add some good logging and adjust constant name * Move jwt generation out to non member * Move cache out to caller * Remove todo about cache * Move getting token out of class * Add timeout on calls * Validate config dependency * Support using service key to do sync call when api_key is not set * Make sure timezone is fixed for datetime dummy * Use exact expire_in time * Support renewing token on 401 * Test retry on 401 * No need to declare dummy key twice * Correct some docs on functions * Add test for token expiry --- .../components/google_assistant/__init__.py | 25 +++ .../components/google_assistant/const.py | 7 + .../components/google_assistant/http.py | 141 +++++++++++++++- .../components/google_assistant/test_http.py | 157 ++++++++++++++++++ 4 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 tests/components/google_assistant/test_http.py diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index a1252d67fff..ebf906b6f2a 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -28,9 +28,13 @@ from .const import ( CONF_ENTITY_CONFIG, CONF_EXPOSE, CONF_ALIASES, + CONF_REPORT_STATE, CONF_ROOM_HINT, CONF_ALLOW_UNLOCK, CONF_SECURE_DEVICES_PIN, + CONF_SERVICE_ACCOUNT, + CONF_CLIENT_EMAIL, + CONF_PRIVATE_KEY, ) from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401 from .const import EVENT_QUERY_RECEIVED # noqa: F401 @@ -47,6 +51,24 @@ ENTITY_SCHEMA = vol.Schema( } ) +GOOGLE_SERVICE_ACCOUNT = vol.Schema( + { + vol.Required(CONF_PRIVATE_KEY): cv.string, + vol.Required(CONF_CLIENT_EMAIL): cv.string, + }, + extra=vol.ALLOW_EXTRA, +) + + +def _check_report_state(data): + if data[CONF_REPORT_STATE]: + if CONF_SERVICE_ACCOUNT not in data: + raise vol.Invalid( + "If report state is enabled, a service account must exist" + ) + return data + + GOOGLE_ASSISTANT_SCHEMA = vol.All( cv.deprecated(CONF_ALLOW_UNLOCK, invalidation_version="0.95"), vol.Schema( @@ -63,9 +85,12 @@ GOOGLE_ASSISTANT_SCHEMA = vol.All( vol.Optional(CONF_ALLOW_UNLOCK): cv.boolean, # str on purpose, makes sure it is configured correctly. vol.Optional(CONF_SECURE_DEVICES_PIN): str, + vol.Optional(CONF_REPORT_STATE, default=False): cv.boolean, + vol.Optional(CONF_SERVICE_ACCOUNT): GOOGLE_SERVICE_ACCOUNT, }, extra=vol.PREVENT_EXTRA, ), + _check_report_state, ) CONFIG_SCHEMA = vol.Schema({DOMAIN: GOOGLE_ASSISTANT_SCHEMA}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 54abd54caaf..03253e244fe 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -32,6 +32,10 @@ CONF_API_KEY = "api_key" CONF_ROOM_HINT = "room" CONF_ALLOW_UNLOCK = "allow_unlock" CONF_SECURE_DEVICES_PIN = "secure_devices_pin" +CONF_REPORT_STATE = "report_state" +CONF_SERVICE_ACCOUNT = "service_account" +CONF_CLIENT_EMAIL = "client_email" +CONF_PRIVATE_KEY = "private_key" DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ @@ -72,7 +76,10 @@ TYPE_ALARM = PREFIX_TYPES + "SECURITYSYSTEM" SERVICE_REQUEST_SYNC = "request_sync" HOMEGRAPH_URL = "https://homegraph.googleapis.com/" +HOMEGRAPH_SCOPE = "https://www.googleapis.com/auth/homegraph" +HOMEGRAPH_TOKEN_URL = "https://accounts.google.com/o/oauth2/token" REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + "v1/devices:requestSync" +REPORT_STATE_BASE_URL = HOMEGRAPH_URL + "v1/devices:reportStateAndNotification" # Error codes used for SmartHomeError class # https://developers.google.com/actions/reference/smarthome/errors-exceptions diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index aea226348b8..90fa1ced157 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -1,20 +1,38 @@ """Support for Google Actions Smart Home Control.""" +import asyncio +from datetime import timedelta import logging +from uuid import uuid4 +import jwt +from aiohttp import ClientResponseError, ClientError from aiohttp.web import Request, Response # Typing imports from homeassistant.components.http import HomeAssistantView -from homeassistant.core import callback +from homeassistant.core import callback, ServiceCall from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, + CONF_API_KEY, CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, CONF_ENTITY_CONFIG, CONF_EXPOSE, + CONF_REPORT_STATE, CONF_SECURE_DEVICES_PIN, + CONF_SERVICE_ACCOUNT, + CONF_CLIENT_EMAIL, + CONF_PRIVATE_KEY, + DOMAIN, + HOMEGRAPH_TOKEN_URL, + HOMEGRAPH_SCOPE, + REPORT_STATE_BASE_URL, + REQUEST_SYNC_BASE_URL, + SERVICE_REQUEST_SYNC, ) from .smart_home import async_handle_message from .helpers import AbstractConfig @@ -22,6 +40,35 @@ from .helpers import AbstractConfig _LOGGER = logging.getLogger(__name__) +def _get_homegraph_jwt(time, iss, key): + now = int(time.timestamp()) + + jwt_raw = { + "iss": iss, + "scope": HOMEGRAPH_SCOPE, + "aud": HOMEGRAPH_TOKEN_URL, + "iat": now, + "exp": now + 3600, + } + return jwt.encode(jwt_raw, key, algorithm="RS256").decode("utf-8") + + +async def _get_homegraph_token(hass, jwt_signed): + headers = { + "Authorization": "Bearer {}".format(jwt_signed), + "Content-Type": "application/x-www-form-urlencoded", + } + data = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": jwt_signed, + } + + session = async_get_clientsession(hass) + async with session.post(HOMEGRAPH_TOKEN_URL, headers=headers, data=data) as res: + res.raise_for_status() + return await res.json() + + class GoogleConfig(AbstractConfig): """Config for manual setup of Google.""" @@ -29,6 +76,8 @@ class GoogleConfig(AbstractConfig): """Initialize the config.""" super().__init__(hass) self._config = config + self._access_token = None + self._access_token_renew = None @property def enabled(self): @@ -50,6 +99,12 @@ class GoogleConfig(AbstractConfig): """Return entity config.""" return self._config.get(CONF_SECURE_DEVICES_PIN) + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + # pylint: disable=no-self-use + return self._config.get(CONF_REPORT_STATE) + def should_expose(self, state) -> bool: """Return if entity should be exposed.""" expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT) @@ -79,11 +134,93 @@ class GoogleConfig(AbstractConfig): """If an entity should have 2FA checked.""" return True + async def _async_update_token(self, force=False): + if CONF_SERVICE_ACCOUNT not in self._config: + _LOGGER.error("Trying to get homegraph api token without service account") + return + + now = dt_util.utcnow() + if not self._access_token or now > self._access_token_renew or force: + token = await _get_homegraph_token( + self.hass, + _get_homegraph_jwt( + now, + self._config[CONF_SERVICE_ACCOUNT][CONF_CLIENT_EMAIL], + self._config[CONF_SERVICE_ACCOUNT][CONF_PRIVATE_KEY], + ), + ) + self._access_token = token["access_token"] + self._access_token_renew = now + timedelta(seconds=token["expires_in"]) + + async def async_call_homegraph_api(self, url, data): + """Call a homegraph api with authenticaiton.""" + session = async_get_clientsession(self.hass) + + async def _call(): + headers = { + "Authorization": "Bearer {}".format(self._access_token), + "X-GFE-SSL": "yes", + } + async with session.post(url, headers=headers, json=data) as res: + _LOGGER.debug( + "Response on %s with data %s was %s", url, data, await res.text() + ) + res.raise_for_status() + + try: + await self._async_update_token() + try: + await _call() + except ClientResponseError as error: + if error.status == 401: + _LOGGER.warning( + "Request for %s unauthorized, renewing token and retrying", url + ) + await self._async_update_token(True) + await _call() + else: + raise + except ClientResponseError as error: + _LOGGER.error("Request for %s failed: %d", url, error.status) + except (asyncio.TimeoutError, ClientError): + _LOGGER.error("Could not contact %s", url) + + async def async_report_state(self, message): + """Send a state report to Google.""" + data = { + "requestId": uuid4().hex, + "agentUserId": (await self.hass.auth.async_get_owner()).id, + "payload": message, + } + await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) + @callback def async_register_http(hass, cfg): """Register HTTP views for Google Assistant.""" - hass.http.register_view(GoogleAssistantView(GoogleConfig(hass, cfg))) + config = GoogleConfig(hass, cfg) + hass.http.register_view(GoogleAssistantView(config)) + if config.should_report_state: + config.async_enable_report_state() + + async def request_sync_service_handler(call: ServiceCall): + """Handle request sync service calls.""" + agent_user_id = call.data.get("agent_user_id") or call.context.user_id + + if agent_user_id is None: + _LOGGER.warning( + "No agent_user_id supplied for request_sync. Call as a user or pass in user id as agent_user_id." + ) + return + await config.async_call_homegraph_api( + REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id} + ) + + # Register service only if api key is provided + if CONF_API_KEY not in cfg and CONF_SERVICE_ACCOUNT in cfg: + hass.services.async_register( + DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler + ) class GoogleAssistantView(HomeAssistantView): diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py new file mode 100644 index 00000000000..4b26bbeba7f --- /dev/null +++ b/tests/components/google_assistant/test_http.py @@ -0,0 +1,157 @@ +"""Test Google http services.""" +from datetime import datetime, timezone, timedelta +from asynctest import patch, ANY + +from homeassistant.components.google_assistant.http import ( + GoogleConfig, + _get_homegraph_jwt, + _get_homegraph_token, +) +from homeassistant.components.google_assistant import GOOGLE_ASSISTANT_SCHEMA +from homeassistant.components.google_assistant.const import ( + REPORT_STATE_BASE_URL, + HOMEGRAPH_TOKEN_URL, +) +from homeassistant.auth.models import User + +DUMMY_CONFIG = GOOGLE_ASSISTANT_SCHEMA( + { + "project_id": "1234", + "service_account": { + "private_key": "-----BEGIN PRIVATE KEY-----\nMIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKYscIlwm7soDsHAz6L6YvUkCvkrX19rS6yeYOmovvhoK5WeYGWUsd8V72zmsyHB7XO94YgJVjvxfzn5K8bLePjFzwoSJjZvhBJ/ZQ05d8VmbvgyWUoPdG9oEa4fZ/lCYrXoaFdTot2xcJvrb/ZuiRl4s4eZpNeFYvVK/Am7UeFPAgMBAAECgYAUetOfzLYUudofvPCaKHu7tKZ5kQPfEa0w6BAPnBF1Mfl1JiDBRDMryFtKs6AOIAVwx00dY/Ex0BCbB3+Cr58H7t4NaPTJxCpmR09pK7o17B7xAdQv8+SynFNud9/5vQ5AEXMOLNwKiU7wpXT6Z7ZIibUBOR7ewsWgsHCDpN1iqQJBAOMODPTPSiQMwRAUHIc6GPleFSJnIz2PAoG3JOG9KFAL6RtIc19lob2ZXdbQdzKtjSkWo+O5W20WDNAl1k32h6MCQQC7W4ZCIY67mPbL6CxXfHjpSGF4Dr9VWJ7ZrKHr6XUoOIcEvsn/pHvWonjMdy93rQMSfOE8BKd/I1+GHRmNVgplAkAnSo4paxmsZVyfeKt7Jy2dMY+8tVZe17maUuQaAE7Sk00SgJYegwrbMYgQnWCTL39HBfj0dmYA2Zj8CCAuu6O7AkEAryFiYjaUAO9+4iNoL27+ZrFtypeeadyov7gKs0ZKaQpNyzW8A+Zwi7TbTeSqzic/E+z/bOa82q7p/6b7141xsQJBANCAcIwMcVb6KVCHlQbOtKspo5Eh4ZQi8bGl+IcwbQ6JSxeTx915IfAldgbuU047wOB04dYCFB2yLDiUGVXTifU=\n-----END PRIVATE KEY-----\n", + "client_email": "dummy@dummy.iam.gserviceaccount.com", + }, + } +) +MOCK_TOKEN = {"access_token": "dummtoken", "expires_in": 3600} +MOCK_JSON = {"devices": {}} +MOCK_URL = "https://dummy" +MOCK_HEADER = { + "Authorization": "Bearer {}".format(MOCK_TOKEN["access_token"]), + "X-GFE-SSL": "yes", +} + + +async def test_get_jwt(hass): + """Test signing of key.""" + + jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJkdW1teUBkdW1teS5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsInNjb3BlIjoiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9ob21lZ3JhcGgiLCJhdWQiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20vby9vYXV0aDIvdG9rZW4iLCJpYXQiOjE1NzEwMTEyMDAsImV4cCI6MTU3MTAxNDgwMH0.gG06SmY-zSvFwSrdFfqIdC6AnC22rwz-d2F2UDeWbywjdmFL_1zceL-OOLBwjD8MJr6nR0kmN_Osu7ml9-EzzZjJqsRUxMjGn2G8nSYHbv16R4FYIp62Ibvt6Jj_wdFobEPoy_5OJ28P5Hdu0giGMlFBJMy0Tc6MgEDZA-cwOBw" + res = _get_homegraph_jwt( + datetime(2019, 10, 14, tzinfo=timezone.utc), + DUMMY_CONFIG["service_account"]["client_email"], + DUMMY_CONFIG["service_account"]["private_key"], + ) + assert res == jwt + + +async def test_get_access_token(hass, aioclient_mock): + """Test the function to get access token.""" + jwt = "dummyjwt" + + aioclient_mock.post( + HOMEGRAPH_TOKEN_URL, + status=200, + json={"access_token": "1234", "expires_in": 3600}, + ) + + await _get_homegraph_token(hass, jwt) + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][3] == { + "Authorization": "Bearer {}".format(jwt), + "Content-Type": "application/x-www-form-urlencoded", + } + + +async def test_update_access_token(hass): + """Test the function to update access token when expired.""" + jwt = "dummyjwt" + + config = GoogleConfig(hass, DUMMY_CONFIG) + + base_time = datetime(2019, 10, 14, tzinfo=timezone.utc) + with patch( + "homeassistant.components.google_assistant.http._get_homegraph_token" + ) as mock_get_token, patch( + "homeassistant.components.google_assistant.http._get_homegraph_jwt" + ) as mock_get_jwt, patch( + "homeassistant.core.dt_util.utcnow" + ) as mock_utcnow: + mock_utcnow.return_value = base_time + mock_get_jwt.return_value = jwt + mock_get_token.return_value = MOCK_TOKEN + + await config._async_update_token() + mock_get_token.assert_called_once() + + mock_get_token.reset_mock() + + mock_utcnow.return_value = base_time + timedelta(seconds=3600) + await config._async_update_token() + mock_get_token.assert_not_called() + + mock_get_token.reset_mock() + + mock_utcnow.return_value = base_time + timedelta(seconds=3601) + await config._async_update_token() + mock_get_token.assert_called_once() + + +async def test_call_homegraph_api(hass, aioclient_mock, hass_storage): + """Test the function to call the homegraph api.""" + config = GoogleConfig(hass, DUMMY_CONFIG) + with patch( + "homeassistant.components.google_assistant.http._get_homegraph_token" + ) as mock_get_token: + mock_get_token.return_value = MOCK_TOKEN + + aioclient_mock.post(MOCK_URL, status=200, json={}) + + await config.async_call_homegraph_api(MOCK_URL, MOCK_JSON) + + assert mock_get_token.call_count == 1 + assert aioclient_mock.call_count == 1 + + call = aioclient_mock.mock_calls[0] + assert call[2] == MOCK_JSON + assert call[3] == MOCK_HEADER + + +async def test_call_homegraph_api_retry(hass, aioclient_mock, hass_storage): + """Test the that the calls get retried with new token on 401.""" + config = GoogleConfig(hass, DUMMY_CONFIG) + with patch( + "homeassistant.components.google_assistant.http._get_homegraph_token" + ) as mock_get_token: + mock_get_token.return_value = MOCK_TOKEN + + aioclient_mock.post(MOCK_URL, status=401, json={}) + + await config.async_call_homegraph_api(MOCK_URL, MOCK_JSON) + + assert mock_get_token.call_count == 2 + assert aioclient_mock.call_count == 2 + + call = aioclient_mock.mock_calls[0] + assert call[2] == MOCK_JSON + assert call[3] == MOCK_HEADER + call = aioclient_mock.mock_calls[1] + assert call[2] == MOCK_JSON + assert call[3] == MOCK_HEADER + + +async def test_report_state(hass, aioclient_mock, hass_storage): + """Test the report state function.""" + config = GoogleConfig(hass, DUMMY_CONFIG) + message = {"devices": {}} + owner = User(name="Test User", perm_lookup=None, groups=[], is_owner=True) + + with patch.object(config, "async_call_homegraph_api") as mock_call, patch.object( + hass.auth, "async_get_owner" + ) as mock_get_owner: + mock_get_owner.return_value = owner + + await config.async_report_state(message) + mock_call.assert_called_once_with( + REPORT_STATE_BASE_URL, + {"requestId": ANY, "agentUserId": owner.id, "payload": message}, + )