Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add reauth flow to ring integration #103758

Merged
merged 3 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 21 additions & 16 deletions homeassistant/components/ring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform, __version__
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.async_ import run_callback_threadsafe
Expand Down Expand Up @@ -58,36 +59,36 @@ def token_updater(token):

try:
await hass.async_add_executor_job(ring.update_data)
except ring_doorbell.AuthenticationError:
_LOGGER.error("Access token is no longer valid. Please set up Ring again")
return False
except ring_doorbell.AuthenticationError as err:
_LOGGER.warning("Ring access token is no longer valid, need to re-authenticate")
raise ConfigEntryAuthFailed(err) from err

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"api": ring,
"devices": ring.devices(),
"device_data": GlobalDataUpdater(
hass, "device", entry.entry_id, ring, "update_devices", timedelta(minutes=1)
hass, "device", entry, ring, "update_devices", timedelta(minutes=1)
),
"dings_data": GlobalDataUpdater(
hass,
"active dings",
entry.entry_id,
entry,
ring,
"update_dings",
timedelta(seconds=5),
),
"history_data": DeviceDataUpdater(
hass,
"history",
entry.entry_id,
entry,
ring,
lambda device: device.history(limit=10),
timedelta(minutes=1),
),
"health_data": DeviceDataUpdater(
hass,
"health",
entry.entry_id,
entry,
ring,
lambda device: device.update_health_data(),
timedelta(minutes=1),
Expand Down Expand Up @@ -143,15 +144,15 @@ def __init__(
self,
hass: HomeAssistant,
data_type: str,
config_entry_id: str,
config_entry: ConfigEntry,
ring: ring_doorbell.Ring,
update_method: str,
update_interval: timedelta,
) -> None:
"""Initialize global data updater."""
self.hass = hass
self.data_type = data_type
self.config_entry_id = config_entry_id
self.config_entry = config_entry
self.ring = ring
self.update_method = update_method
self.update_interval = update_interval
Expand Down Expand Up @@ -188,8 +189,10 @@ async def async_refresh_all(self, _now: int | None = None) -> None:
getattr(self.ring, self.update_method)
)
except ring_doorbell.AuthenticationError:
_LOGGER.error("Ring access token is no longer valid. Set up Ring again")
await self.hass.config_entries.async_unload(self.config_entry_id)
_LOGGER.warning(
"Ring access token is no longer valid, need to re-authenticate"
)
self.config_entry.async_start_reauth(self.hass)
return
except ring_doorbell.RingTimeout:
_LOGGER.warning(
Expand All @@ -216,15 +219,15 @@ def __init__(
self,
hass: HomeAssistant,
data_type: str,
config_entry_id: str,
config_entry: ConfigEntry,
ring: ring_doorbell.Ring,
update_method: Callable[[ring_doorbell.Ring], Any],
update_interval: timedelta,
) -> None:
"""Initialize device data updater."""
self.data_type = data_type
self.hass = hass
self.config_entry_id = config_entry_id
self.config_entry = config_entry
self.ring = ring
self.update_method = update_method
self.update_interval = update_interval
Expand Down Expand Up @@ -277,9 +280,11 @@ def refresh_all(self, _=None):
try:
data = info["data"] = self.update_method(info["device"])
except ring_doorbell.AuthenticationError:
_LOGGER.error("Ring access token is no longer valid. Set up Ring again")
self.hass.add_job(
self.hass.config_entries.async_unload(self.config_entry_id)
_LOGGER.warning(
"Ring access token is no longer valid, need to re-authenticate"
)
self.hass.loop.call_soon_threadsafe(
self.config_entry.async_start_reauth, self.hass
)
return
except ring_doorbell.RingTimeout:
Expand Down
81 changes: 68 additions & 13 deletions homeassistant/components/ring/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
"""Config flow for Ring integration."""
from collections.abc import Mapping
import logging
from typing import Any

import ring_doorbell
import voluptuous as vol

from homeassistant import config_entries, core, exceptions
from homeassistant.const import __version__ as ha_version
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, __version__ as ha_version
from homeassistant.data_entry_flow import FlowResult

from . import DOMAIN

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})


async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect."""
Expand Down Expand Up @@ -39,48 +47,95 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1

user_pass: dict[str, Any] = {}
reauth_entry: ConfigEntry | None = None

async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
token = await validate_input(self.hass, user_input)
await self.async_set_unique_id(user_input["username"])

return self.async_create_entry(
title=user_input["username"],
data={"username": user_input["username"], "token": token},
)
except Require2FA:
self.user_pass = user_input

return await self.async_step_2fa()

except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input["username"])
return self.async_create_entry(
title=user_input["username"],
data={"username": user_input["username"], "token": token},
)

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required("username"): str, vol.Required("password"): str}
),
errors=errors,
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

async def async_step_2fa(self, user_input=None):
"""Handle 2fa step."""
if user_input:
if self.reauth_entry:
return await self.async_step_reauth_confirm(
{**self.user_pass, **user_input}
)

return await self.async_step_user({**self.user_pass, **user_input})

return self.async_show_form(
step_id="2fa",
data_schema=vol.Schema({vol.Required("2fa"): str}),
)

async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle reauth upon an API authentication error."""
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Dialog that informs the user that reauth is required."""
errors = {}
assert self.reauth_entry is not None

if user_input:
user_input[CONF_USERNAME] = self.reauth_entry.data[CONF_USERNAME]
try:
token = await validate_input(self.hass, user_input)
except Require2FA:
self.user_pass = user_input
return await self.async_step_2fa()
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
data = {
allenporter marked this conversation as resolved.
Show resolved Hide resolved
CONF_USERNAME: user_input[CONF_USERNAME],
"token": token,
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved
}
self.hass.config_entries.async_update_entry(
self.reauth_entry, data=data
)
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")

return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
description_placeholders={
CONF_USERNAME: self.reauth_entry.data[CONF_USERNAME]
},
)


class Require2FA(exceptions.HomeAssistantError):
"""Error to indicate we require 2FA."""
Expand Down
10 changes: 9 additions & 1 deletion homeassistant/components/ring/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,22 @@
"data": {
"2fa": "Two-factor code"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Ring integration needs to re-authenticate your account {username}",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"entity": {
Expand Down
111 changes: 111 additions & 0 deletions tests/components/ring/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType

from tests.common import MockConfigEntry


async def test_form(
hass: HomeAssistant,
Expand Down Expand Up @@ -108,3 +110,112 @@ async def test_form_2fa(
"token": "new-foobar",
}
assert len(mock_setup_entry.mock_calls) == 1


async def test_reauth(
hass: HomeAssistant,
mock_added_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
mock_ring_auth: Mock,
) -> None:
"""Test reauth flow."""
mock_added_config_entry.async_start_reauth(hass)
await hass.async_block_till_done()

flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
[result] = flows
assert result["step_id"] == "reauth_confirm"

mock_ring_auth.fetch_token.side_effect = ring_doorbell.Requires2FAError
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_PASSWORD: "other_fake_password",
},
)

mock_ring_auth.fetch_token.assert_called_once_with(
"foo@bar.com", "other_fake_password", None
)
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "2fa"
mock_ring_auth.fetch_token.reset_mock(side_effect=True)
mock_ring_auth.fetch_token.return_value = "new-foobar"
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
user_input={"2fa": "123456"},
)

mock_ring_auth.fetch_token.assert_called_once_with(
"foo@bar.com", "other_fake_password", "123456"
)
assert result3["type"] == FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"
assert mock_added_config_entry.data == {
"username": "foo@bar.com",
"token": "new-foobar",
}
assert len(mock_setup_entry.mock_calls) == 1


@pytest.mark.parametrize(
("error_type", "errors_msg"),
[
(ring_doorbell.AuthenticationError, "invalid_auth"),
(Exception, "unknown"),
],
ids=["invalid-auth", "unknown-error"],
)
async def test_reauth_error(
hass: HomeAssistant,
mock_added_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
mock_ring_auth: Mock,
error_type,
errors_msg,
) -> None:
"""Test reauth flow."""
mock_added_config_entry.async_start_reauth(hass)
await hass.async_block_till_done()

flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
[result] = flows
assert result["step_id"] == "reauth_confirm"

mock_ring_auth.fetch_token.side_effect = error_type
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_PASSWORD: "error_fake_password",
},
)
await hass.async_block_till_done()

mock_ring_auth.fetch_token.assert_called_once_with(
"foo@bar.com", "error_fake_password", None
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": errors_msg}
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved

# Now test reauth can go on to succeed
mock_ring_auth.fetch_token.reset_mock(side_effect=True)
mock_ring_auth.fetch_token.return_value = "new-foobar"
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
user_input={
CONF_PASSWORD: "other_fake_password",
},
)

mock_ring_auth.fetch_token.assert_called_once_with(
"foo@bar.com", "other_fake_password", None
)
assert result3["type"] == FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"
assert mock_added_config_entry.data == {
"username": "foo@bar.com",
"token": "new-foobar",
}
assert len(mock_setup_entry.mock_calls) == 1