Skip to content

Commit 0d2558c

Browse files
authored
Implement cync reauth flow (home-assistant#154257)
1 parent 9efbcb2 commit 0d2558c

File tree

5 files changed

+227
-38
lines changed

5 files changed

+227
-38
lines changed

homeassistant/components/cync/config_flow.py

Lines changed: 78 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Mapping
56
import logging
67
from typing import Any
78

89
from pycync import Auth
910
from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError
1011
import voluptuous as vol
1112

12-
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
13+
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
1314
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
1415
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1516

@@ -39,37 +40,22 @@ class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
3940

4041
VERSION = 1
4142

42-
cync_auth: Auth
43+
cync_auth: Auth = None
4344

4445
async def async_step_user(
4546
self, user_input: dict[str, Any] | None = None
4647
) -> ConfigFlowResult:
4748
"""Attempt login with user credentials."""
4849
errors: dict[str, str] = {}
4950

50-
if user_input is None:
51-
return self.async_show_form(
52-
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
53-
)
51+
if user_input:
52+
try:
53+
errors = await self._validate_credentials(user_input)
54+
except TwoFactorRequiredError:
55+
return await self.async_step_two_factor()
5456

55-
self.cync_auth = Auth(
56-
async_get_clientsession(self.hass),
57-
username=user_input[CONF_EMAIL],
58-
password=user_input[CONF_PASSWORD],
59-
)
60-
try:
61-
await self.cync_auth.login()
62-
except AuthFailedError:
63-
errors["base"] = "invalid_auth"
64-
except TwoFactorRequiredError:
65-
return await self.async_step_two_factor()
66-
except CyncError:
67-
errors["base"] = "cannot_connect"
68-
except Exception:
69-
_LOGGER.exception("Unexpected exception")
70-
errors["base"] = "unknown"
71-
else:
72-
return await self._create_config_entry(self.cync_auth.username)
57+
if not errors:
58+
return await self._create_config_entry(self.cync_auth.username)
7359

7460
return self.async_show_form(
7561
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
@@ -81,38 +67,95 @@ async def async_step_two_factor(
8167
"""Attempt login with the two factor auth code sent to the user."""
8268
errors: dict[str, str] = {}
8369

84-
if user_input is None:
70+
if user_input:
71+
errors = await self._validate_credentials(user_input)
72+
73+
if not errors:
74+
return await self._create_config_entry(self.cync_auth.username)
75+
8576
return self.async_show_form(
86-
step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
77+
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
78+
)
79+
80+
return self.async_show_form(
81+
step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
82+
)
83+
84+
async def async_step_reauth(
85+
self, entry_data: Mapping[str, Any]
86+
) -> ConfigFlowResult:
87+
"""Perform reauth upon an API authentication error."""
88+
return await self.async_step_reauth_confirm()
89+
90+
async def async_step_reauth_confirm(
91+
self, user_input: dict[str, Any] | None = None
92+
) -> ConfigFlowResult:
93+
"""Dialog that informs the user that reauth is required and prompts for their Cync credentials."""
94+
errors: dict[str, str] = {}
95+
96+
reauth_entry = self._get_reauth_entry()
97+
98+
if user_input:
99+
try:
100+
errors = await self._validate_credentials(user_input)
101+
except TwoFactorRequiredError:
102+
return await self.async_step_two_factor()
103+
104+
if not errors:
105+
return await self._create_config_entry(self.cync_auth.username)
106+
107+
return self.async_show_form(
108+
step_id="reauth_confirm",
109+
data_schema=STEP_USER_DATA_SCHEMA,
110+
errors=errors,
111+
description_placeholders={CONF_EMAIL: reauth_entry.title},
112+
)
113+
114+
async def _validate_credentials(self, user_input: dict[str, Any]) -> dict[str, str]:
115+
"""Attempt to log in with user email and password, and return the error dict."""
116+
errors: dict[str, str] = {}
117+
118+
if not self.cync_auth:
119+
self.cync_auth = Auth(
120+
async_get_clientsession(self.hass),
121+
username=user_input[CONF_EMAIL],
122+
password=user_input[CONF_PASSWORD],
87123
)
124+
88125
try:
89-
await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE])
126+
await self.cync_auth.login(user_input.get(CONF_TWO_FACTOR_CODE))
127+
except TwoFactorRequiredError:
128+
raise
90129
except AuthFailedError:
91130
errors["base"] = "invalid_auth"
92131
except CyncError:
93132
errors["base"] = "cannot_connect"
94133
except Exception:
95134
_LOGGER.exception("Unexpected exception")
96135
errors["base"] = "unknown"
97-
else:
98-
return await self._create_config_entry(self.cync_auth.username)
99136

100-
return self.async_show_form(
101-
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
102-
)
137+
return errors
103138

104139
async def _create_config_entry(self, user_email: str) -> ConfigFlowResult:
105140
"""Create the Cync config entry using input user data."""
106141

107142
cync_user = self.cync_auth.user
108143
await self.async_set_unique_id(str(cync_user.user_id))
109-
self._abort_if_unique_id_configured()
110144

111-
config = {
145+
config_data = {
112146
CONF_USER_ID: cync_user.user_id,
113147
CONF_AUTHORIZE_STRING: cync_user.authorize,
114148
CONF_EXPIRES_AT: cync_user.expires_at,
115149
CONF_ACCESS_TOKEN: cync_user.access_token,
116150
CONF_REFRESH_TOKEN: cync_user.refresh_token,
117151
}
118-
return self.async_create_entry(title=user_email, data=config)
152+
153+
if self.source == SOURCE_REAUTH:
154+
self._abort_if_unique_id_mismatch()
155+
return self.async_update_reload_and_abort(
156+
entry=self._get_reauth_entry(), title=user_email, data=config_data
157+
)
158+
159+
self._abort_if_unique_id_configured()
160+
161+
return self.async_create_entry(title=user_email, data=config_data)

homeassistant/components/cync/quality_scale.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ rules:
3737
integration-owner: done
3838
log-when-unavailable: todo
3939
parallel-updates: todo
40-
reauthentication-flow: todo
40+
reauthentication-flow: done
4141
test-coverage: todo
4242

4343
# Gold

homeassistant/components/cync/strings.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@
1818
"data_description": {
1919
"two_factor_code": "The two-factor code sent to your Cync account's email"
2020
}
21+
},
22+
"reauth_confirm": {
23+
"title": "[%key:common::config_flow::title::reauth%]",
24+
"description": "The Cync integration needs to re-authenticate for {email}",
25+
"data": {
26+
"email": "[%key:common::config_flow::data::email%]",
27+
"password": "[%key:common::config_flow::data::password%]"
28+
},
29+
"data_description": {
30+
"email": "[%key:component::cync::config::step::user::data_description::email%]",
31+
"password": "[%key:component::cync::config::step::user::data_description::password%]"
32+
}
2133
}
2234
},
2335
"error": {
@@ -26,7 +38,9 @@
2638
"unknown": "[%key:common::config_flow::error::unknown%]"
2739
},
2840
"abort": {
29-
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
41+
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
42+
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
43+
"unique_id_mismatch": "An incorrect user was provided by Cync for your email address, please consult your Cync app"
3044
}
3145
}
3246
}

tests/components/cync/const.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,11 @@
1111
123456789,
1212
expires_at=(time.time() * 1000) + 3600000,
1313
)
14+
SECOND_MOCKED_USER = pycync.User(
15+
"test_token_2",
16+
"test_refresh_token_2",
17+
"test_authorize_string_2",
18+
987654321,
19+
expires_at=(time.time() * 1000) + 3600000,
20+
)
1421
MOCKED_EMAIL = "[email protected]"

tests/components/cync/test_config_flow.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from homeassistant.core import HomeAssistant
1919
from homeassistant.data_entry_flow import FlowResultType
2020

21-
from .const import MOCKED_EMAIL, MOCKED_USER
21+
from .const import MOCKED_EMAIL, MOCKED_USER, SECOND_MOCKED_USER
2222

2323
from tests.common import MockConfigEntry
2424

@@ -98,6 +98,74 @@ async def test_form_two_factor_success(
9898
assert len(mock_setup_entry.mock_calls) == 1
9999

100100

101+
async def test_form_reauth_success(
102+
hass: HomeAssistant,
103+
mock_config_entry: MockConfigEntry,
104+
mock_setup_entry: AsyncMock,
105+
auth_client: MagicMock,
106+
) -> None:
107+
"""Test we handle re-authentication with two-factor."""
108+
mock_config_entry.add_to_hass(hass)
109+
result = await mock_config_entry.start_reauth_flow(hass)
110+
assert result["step_id"] == "reauth_confirm"
111+
112+
auth_client.login.side_effect = TwoFactorRequiredError
113+
result = await hass.config_entries.flow.async_configure(
114+
result["flow_id"],
115+
{
116+
CONF_EMAIL: MOCKED_EMAIL,
117+
CONF_PASSWORD: "test-password",
118+
},
119+
)
120+
121+
assert result["type"] is FlowResultType.FORM
122+
assert result["errors"] == {}
123+
assert result["step_id"] == "two_factor"
124+
125+
# Enter two factor code
126+
auth_client.login.side_effect = None
127+
result = await hass.config_entries.flow.async_configure(
128+
result["flow_id"],
129+
{
130+
CONF_TWO_FACTOR_CODE: "123456",
131+
},
132+
)
133+
134+
assert result["type"] is FlowResultType.ABORT
135+
assert result["reason"] == "reauth_successful"
136+
assert mock_config_entry.data == {
137+
CONF_USER_ID: MOCKED_USER.user_id,
138+
CONF_AUTHORIZE_STRING: "test_authorize_string",
139+
CONF_EXPIRES_AT: ANY,
140+
CONF_ACCESS_TOKEN: "test_token",
141+
CONF_REFRESH_TOKEN: "test_refresh_token",
142+
}
143+
assert len(mock_setup_entry.mock_calls) == 1
144+
145+
146+
async def test_form_reauth_unique_id_mismatch(
147+
hass: HomeAssistant,
148+
mock_config_entry: MockConfigEntry,
149+
auth_client: MagicMock,
150+
) -> None:
151+
"""Test we handle a unique ID mismatch when re-authenticating."""
152+
mock_config_entry.add_to_hass(hass)
153+
result = await mock_config_entry.start_reauth_flow(hass)
154+
assert result["step_id"] == "reauth_confirm"
155+
156+
auth_client.user = SECOND_MOCKED_USER
157+
result = await hass.config_entries.flow.async_configure(
158+
result["flow_id"],
159+
{
160+
CONF_EMAIL: MOCKED_EMAIL,
161+
CONF_PASSWORD: "test-password",
162+
},
163+
)
164+
165+
assert result["type"] is FlowResultType.ABORT
166+
assert result["reason"] == "unique_id_mismatch"
167+
168+
101169
async def test_form_unique_id_already_exists(
102170
hass: HomeAssistant, mock_config_entry: MockConfigEntry
103171
) -> None:
@@ -258,3 +326,60 @@ async def test_form_errors(
258326
}
259327
assert result["result"].unique_id == str(MOCKED_USER.user_id)
260328
assert len(mock_setup_entry.mock_calls) == 1
329+
330+
331+
@pytest.mark.parametrize(
332+
("error_type", "error_string"),
333+
[
334+
(AuthFailedError, "invalid_auth"),
335+
(CyncError, "cannot_connect"),
336+
(Exception, "unknown"),
337+
],
338+
)
339+
async def test_form_reauth_errors(
340+
hass: HomeAssistant,
341+
mock_config_entry: MockConfigEntry,
342+
mock_setup_entry: AsyncMock,
343+
auth_client: MagicMock,
344+
error_type: Exception,
345+
error_string: str,
346+
) -> None:
347+
"""Test we handle errors in the reauth flow."""
348+
mock_config_entry.add_to_hass(hass)
349+
result = await mock_config_entry.start_reauth_flow(hass)
350+
assert result["step_id"] == "reauth_confirm"
351+
352+
auth_client.login.side_effect = error_type
353+
result = await hass.config_entries.flow.async_configure(
354+
result["flow_id"],
355+
{
356+
CONF_EMAIL: MOCKED_EMAIL,
357+
CONF_PASSWORD: "test-password",
358+
},
359+
)
360+
361+
assert result["type"] is FlowResultType.FORM
362+
assert result["errors"] == {"base": error_string}
363+
assert result["step_id"] == "reauth_confirm"
364+
365+
# Make sure the config flow tests finish with FlowResultType.ABORT so
366+
# we can show the config flow is able to recover from an error.
367+
auth_client.login.side_effect = None
368+
result = await hass.config_entries.flow.async_configure(
369+
result["flow_id"],
370+
{
371+
CONF_EMAIL: MOCKED_EMAIL,
372+
CONF_PASSWORD: "test-password",
373+
},
374+
)
375+
376+
assert result["type"] is FlowResultType.ABORT
377+
assert result["reason"] == "reauth_successful"
378+
assert mock_config_entry.data == {
379+
CONF_USER_ID: MOCKED_USER.user_id,
380+
CONF_AUTHORIZE_STRING: "test_authorize_string",
381+
CONF_EXPIRES_AT: ANY,
382+
CONF_ACCESS_TOKEN: "test_token",
383+
CONF_REFRESH_TOKEN: "test_refresh_token",
384+
}
385+
assert len(mock_setup_entry.mock_calls) == 1

0 commit comments

Comments
 (0)