Add reauth for Netatmo when token or token scope is invalid (#57487)

This commit is contained in:
Tobias Sauerwein 2021-10-26 16:09:10 +02:00 committed by GitHub
parent c9966a3b04
commit 3970a50553
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 313 additions and 106 deletions

View File

@ -1,9 +1,11 @@
"""The Netatmo integration.""" """The Netatmo integration."""
from __future__ import annotations from __future__ import annotations
from http import HTTPStatus
import logging import logging
import secrets import secrets
import aiohttp
import pyatmo import pyatmo
import voluptuous as vol import voluptuous as vol
@ -21,6 +23,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
) )
from homeassistant.core import CoreState, HomeAssistant from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import ( from homeassistant.helpers import (
aiohttp_client, aiohttp_client,
config_entry_oauth2_flow, config_entry_oauth2_flow,
@ -45,6 +48,7 @@ from .const import (
DATA_PERSONS, DATA_PERSONS,
DATA_SCHEDULES, DATA_SCHEDULES,
DOMAIN, DOMAIN,
NETATMO_SCOPES,
OAUTH2_AUTHORIZE, OAUTH2_AUTHORIZE,
OAUTH2_TOKEN, OAUTH2_TOKEN,
PLATFORMS, PLATFORMS,
@ -112,6 +116,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN) hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as ex:
_LOGGER.debug("API error: %s (%s)", ex.code, ex.message)
if ex.code in (
HTTPStatus.BAD_REQUEST,
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
):
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
raise ConfigEntryNotReady from ex
if sorted(session.token["scope"]) != sorted(NETATMO_SCOPES):
_LOGGER.debug(
"Scope is invalid: %s != %s", session.token["scope"], NETATMO_SCOPES
)
raise ConfigEntryAuthFailed("Token scope not valid, trigger renewal")
hass.data[DOMAIN][entry.entry_id] = { hass.data[DOMAIN][entry.entry_id] = {
AUTH: api.AsyncConfigEntryNetatmoAuth( AUTH: api.AsyncConfigEntryNetatmoAuth(
aiohttp_client.async_get_clientsession(hass), session aiohttp_client.async_get_clientsession(hass), session
@ -224,15 +246,17 @@ async def async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
data = hass.data[DOMAIN]
if CONF_WEBHOOK_ID in entry.data: if CONF_WEBHOOK_ID in entry.data:
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
await hass.data[DOMAIN][entry.entry_id][AUTH].async_dropwebhook() await data[entry.entry_id][AUTH].async_dropwebhook()
_LOGGER.info("Unregister Netatmo webhook") _LOGGER.info("Unregister Netatmo webhook")
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok and entry.entry_id in data:
hass.data[DOMAIN].pop(entry.entry_id) data.pop(entry.entry_id)
return unload_ok return unload_ok

View File

@ -23,8 +23,11 @@ from .const import (
CONF_UUID, CONF_UUID,
CONF_WEATHER_AREAS, CONF_WEATHER_AREAS,
DOMAIN, DOMAIN,
NETATMO_SCOPES,
) )
_LOGGER = logging.getLogger(__name__)
class NetatmoFlowHandler( class NetatmoFlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
@ -49,31 +52,46 @@ class NetatmoFlowHandler(
@property @property
def extra_authorize_data(self) -> dict: def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url.""" """Extra data that needs to be appended to the authorize url."""
scopes = [ return {"scope": " ".join(NETATMO_SCOPES)}
"access_camera",
"access_presence",
"read_camera",
"read_homecoach",
"read_presence",
"read_smokedetector",
"read_station",
"read_thermostat",
"write_camera",
"write_presence",
"write_thermostat",
]
return {"scope": " ".join(scopes)}
async def async_step_user(self, user_input: dict | None = None) -> FlowResult: async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
"""Handle a flow start.""" """Handle a flow start."""
await self.async_set_unique_id(DOMAIN) await self.async_set_unique_id(DOMAIN)
if self._async_current_entries(): if (
self.source != config_entries.SOURCE_REAUTH
and self._async_current_entries()
):
return self.async_abort(reason="single_instance_allowed") return self.async_abort(reason="single_instance_allowed")
return await super().async_step_user(user_input) return await super().async_step_user(user_input)
async def async_step_reauth(self, user_input: dict | None = None) -> FlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict | None = None
) -> FlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> FlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
existing_entry = await self.async_set_unique_id(DOMAIN)
if existing_entry:
self.hass.config_entries.async_update_entry(existing_entry, data=data)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return await super().async_oauth_create_entry(data)
class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): class NetatmoOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle Netatmo options.""" """Handle Netatmo options."""

View File

@ -13,6 +13,20 @@ DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}"
PLATFORMS = [CAMERA_DOMAIN, CLIMATE_DOMAIN, LIGHT_DOMAIN, SELECT_DOMAIN, SENSOR_DOMAIN] PLATFORMS = [CAMERA_DOMAIN, CLIMATE_DOMAIN, LIGHT_DOMAIN, SELECT_DOMAIN, SENSOR_DOMAIN]
NETATMO_SCOPES = [
"access_camera",
"access_presence",
"read_camera",
"read_homecoach",
"read_presence",
"read_smokedetector",
"read_station",
"read_thermostat",
"write_camera",
"write_presence",
"write_thermostat",
]
MODEL_NAPLUG = "Relay" MODEL_NAPLUG = "Relay"
MODEL_NATHERM1 = "Smart Thermostat" MODEL_NATHERM1 = "Smart Thermostat"
MODEL_NRV = "Smart Radiator Valves" MODEL_NRV = "Smart Radiator Valves"

View File

@ -33,12 +33,6 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the Netatmo camera light platform.""" """Set up the Netatmo camera light platform."""
if "access_camera" not in entry.data["token"]["scope"]:
_LOGGER.info(
"Cameras are currently not supported with this authentication method"
)
return
data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER]
await data_handler.register_data_class( await data_handler.register_data_class(

View File

@ -3,13 +3,18 @@
"step": { "step": {
"pick_implementation": { "pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Netatmo integration needs to re-authenticate your account"
} }
}, },
"abort": { "abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}, },
"create_entry": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -1,18 +1,23 @@
{ {
"config": { "config": {
"abort": {
"authorize_url_timeout": "Timeout generating authorize URL.",
"missing_configuration": "The component is not configured. Please follow the documentation.",
"no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"create_entry": {
"default": "Successfully authenticated"
},
"step": { "step": {
"pick_implementation": { "pick_implementation": {
"title": "Pick Authentication Method" "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Netatmo integration needs to re-authenticate your account"
} }
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
} }
}, },
"device_automation": { "device_automation": {

View File

@ -22,7 +22,7 @@ def mock_config_entry_fixture(hass):
"type": "Bearer", "type": "Bearer",
"expires_in": 60, "expires_in": 60,
"expires_at": time() + 1000, "expires_at": time() + 1000,
"scope": " ".join(ALL_SCOPES), "scope": ALL_SCOPES,
}, },
}, },
options={ options={
@ -53,7 +53,7 @@ def mock_config_entry_fixture(hass):
return mock_entry return mock_entry
@pytest.fixture @pytest.fixture(name="netatmo_auth")
def netatmo_auth(): def netatmo_auth():
"""Restrict loaded platforms to list given.""" """Restrict loaded platforms to list given."""
with patch( with patch(

View File

@ -13,6 +13,8 @@ from homeassistant.components.netatmo.const import (
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from .common import ALL_SCOPES
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
CLIENT_ID = "1234" CLIENT_ID = "1234"
@ -67,21 +69,7 @@ async def test_full_flow(
}, },
) )
scope = "+".join( scope = "+".join(sorted(ALL_SCOPES))
[
"access_camera",
"access_presence",
"read_camera",
"read_homecoach",
"read_presence",
"read_smokedetector",
"read_station",
"read_thermostat",
"write_camera",
"write_presence",
"write_thermostat",
]
)
assert result["url"] == ( assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
@ -227,3 +215,110 @@ async def test_option_flow_wrong_coordinates(hass):
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
for k, v in expected_result.items(): for k, v in expected_result.items():
assert config_entry.options[CONF_WEATHER_AREAS]["Home"][k] == v assert config_entry.options[CONF_WEATHER_AREAS]["Home"][k] == v
async def test_reauth(
hass, hass_client_no_auth, aioclient_mock, current_request_with_host
):
"""Test initialization of the reauth flow."""
assert await setup.async_setup_component(
hass,
"netatmo",
{
"netatmo": {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET},
"http": {"base_url": "https://example.com"},
},
)
result = await hass.config_entries.flow.async_init(
"netatmo", context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
scope = "+".join(sorted(ALL_SCOPES))
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope={scope}"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.netatmo.async_setup_entry", return_value=True
) as mock_setup:
await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()
new_entry = hass.config_entries.async_entries(DOMAIN)[0]
assert new_entry.state == config_entries.ConfigEntryState.LOADED
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
# Should show form
result = await hass.config_entries.flow.async_init(
"netatmo", context={"source": config_entries.SOURCE_REAUTH}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth_confirm"
# Confirm reauth flow
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
# Update entry
with patch(
"homeassistant.components.netatmo.async_setup_entry", return_value=True
) as mock_setup:
result3 = await hass.config_entries.flow.async_configure(result2["flow_id"])
await hass.async_block_till_done()
new_entry2 = hass.config_entries.async_entries(DOMAIN)[0]
assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result3["reason"] == "reauth_successful"
assert new_entry2.state == config_entries.ConfigEntryState.LOADED
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1

View File

@ -4,6 +4,7 @@ from datetime import timedelta
from time import time from time import time
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import aiohttp
import pyatmo import pyatmo
from homeassistant import config_entries from homeassistant import config_entries
@ -14,6 +15,7 @@ from homeassistant.setup import async_setup_component
from homeassistant.util import dt from homeassistant.util import dt
from .common import ( from .common import (
ALL_SCOPES,
FAKE_WEBHOOK_ACTIVATION, FAKE_WEBHOOK_ACTIVATION,
fake_post_request, fake_post_request,
selected_platforms, selected_platforms,
@ -49,24 +51,8 @@ FAKE_WEBHOOK = {
} }
async def test_setup_component(hass): async def test_setup_component(hass, config_entry):
"""Test setup and teardown of the netatmo component.""" """Test setup and teardown of the netatmo component."""
config_entry = MockConfigEntry(
domain="netatmo",
data={
"auth_implementation": "cloud",
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"expires_at": time() + 1000,
"scope": "read_station",
},
},
)
config_entry.add_to_hass(hass)
with patch( with patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth, patch( ) as mock_auth, patch(
@ -248,7 +234,7 @@ async def test_setup_with_cloudhook(hass):
"type": "Bearer", "type": "Bearer",
"expires_in": 60, "expires_in": 60,
"expires_at": time() + 1000, "expires_at": time() + 1000,
"scope": "read_station", "scope": ALL_SCOPES,
}, },
}, },
) )
@ -298,24 +284,8 @@ async def test_setup_with_cloudhook(hass):
assert not hass.config_entries.async_entries(DOMAIN) assert not hass.config_entries.async_entries(DOMAIN)
async def test_setup_component_api_error(hass): async def test_setup_component_api_error(hass, config_entry):
"""Test error on setup of the netatmo component.""" """Test error on setup of the netatmo component."""
config_entry = MockConfigEntry(
domain="netatmo",
data={
"auth_implementation": "cloud",
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"expires_at": time() + 1000,
"scope": "read_station",
},
},
)
config_entry.add_to_hass(hass)
with patch( with patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth, patch( ) as mock_auth, patch(
@ -337,24 +307,8 @@ async def test_setup_component_api_error(hass):
mock_impl.assert_called_once() mock_impl.assert_called_once()
async def test_setup_component_api_timeout(hass): async def test_setup_component_api_timeout(hass, config_entry):
"""Test timeout on setup of the netatmo component.""" """Test timeout on setup of the netatmo component."""
config_entry = MockConfigEntry(
domain="netatmo",
data={
"auth_implementation": "cloud",
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"expires_at": time() + 1000,
"scope": "read_station",
},
},
)
config_entry.add_to_hass(hass)
with patch( with patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth, patch( ) as mock_auth, patch(
@ -429,3 +383,101 @@ async def test_setup_component_with_delay(hass, config_entry):
await hass.async_stop() await hass.async_stop()
mock_dropwebhook.assert_called_once() mock_dropwebhook.assert_called_once()
async def test_setup_component_invalid_token_scope(hass):
"""Test handling of invalid token scope."""
config_entry = MockConfigEntry(
domain="netatmo",
data={
"auth_implementation": "cloud",
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"expires_at": time() + 1000,
"scope": " ".join(
[
"read_smokedetector",
"read_thermostat",
"write_thermostat",
]
),
},
},
options={},
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth, patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
) as mock_impl, patch(
"homeassistant.components.webhook.async_generate_url"
) as mock_webhook:
mock_auth.return_value.async_post_request.side_effect = fake_post_request
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await async_setup_component(hass, "netatmo", {})
await hass.async_block_till_done()
mock_auth.assert_not_called()
mock_impl.assert_called_once()
mock_webhook.assert_not_called()
assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR
assert hass.config_entries.async_entries(DOMAIN)
assert len(hass.states.async_all()) > 0
for config_entry in hass.config_entries.async_entries("netatmo"):
await hass.config_entries.async_remove(config_entry.entry_id)
async def test_setup_component_invalid_token(hass, config_entry):
"""Test handling of invalid token."""
async def fake_ensure_valid_token(*args, **kwargs):
print("fake_ensure_valid_token")
raise aiohttp.ClientResponseError(
request_info=aiohttp.client.RequestInfo(
url="http://example.com",
method="GET",
headers={},
real_url="http://example.com",
),
code=400,
history=(),
)
with patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth, patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
) as mock_impl, patch(
"homeassistant.components.webhook.async_generate_url"
) as mock_webhook, patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session"
) as mock_session:
mock_auth.return_value.async_post_request.side_effect = fake_post_request
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
mock_session.return_value.async_ensure_token_valid.side_effect = (
fake_ensure_valid_token
)
assert await async_setup_component(hass, "netatmo", {})
await hass.async_block_till_done()
mock_auth.assert_not_called()
mock_impl.assert_called_once()
mock_webhook.assert_not_called()
assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR
assert hass.config_entries.async_entries(DOMAIN)
assert len(hass.states.async_all()) > 0
for config_entry in hass.config_entries.async_entries("netatmo"):
await hass.config_entries.async_remove(config_entry.entry_id)