Skip to content

Commit 1a1f3d6

Browse files
KiraPCjoostlek
andauthored
Handle new Blink login flow (home-assistant#154632)
Co-authored-by: Joostlek <[email protected]>
1 parent 71589d2 commit 1a1f3d6

File tree

15 files changed

+320
-285
lines changed

15 files changed

+320
-285
lines changed

homeassistant/components/blink/__init__.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import logging
55
from typing import Any
66

7-
from aiohttp import ClientError
87
from blinkpy.auth import Auth
98
from blinkpy.blinkpy import Blink
109
import voluptuous as vol
@@ -18,7 +17,6 @@
1817
CONF_SCAN_INTERVAL,
1918
)
2019
from homeassistant.core import HomeAssistant, callback
21-
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
2220
from homeassistant.helpers import config_validation as cv
2321
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2422
from homeassistant.helpers.typing import ConfigType
@@ -83,22 +81,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BlinkConfigEntry) -> boo
8381
session = async_get_clientsession(hass)
8482
blink = Blink(session=session)
8583
auth_data = deepcopy(dict(entry.data))
86-
blink.auth = Auth(auth_data, no_prompt=True, session=session)
84+
blink.auth = Auth(
85+
auth_data,
86+
no_prompt=True,
87+
session=session,
88+
callback=lambda: _async_update_entry_data(hass, entry, blink),
89+
)
8790
blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
8891
coordinator = BlinkUpdateCoordinator(hass, entry, blink)
8992

90-
try:
91-
await blink.start()
92-
except (ClientError, TimeoutError) as ex:
93-
raise ConfigEntryNotReady("Can not connect to host") from ex
94-
95-
if blink.auth.check_key_required():
96-
_LOGGER.debug("Attempting a reauth flow")
97-
raise ConfigEntryAuthFailed("Need 2FA for Blink")
98-
99-
if not blink.available:
100-
raise ConfigEntryNotReady
101-
10293
await coordinator.async_config_entry_first_refresh()
10394

10495
entry.runtime_data = coordinator
@@ -108,6 +99,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: BlinkConfigEntry) -> boo
10899
return True
109100

110101

102+
@callback
103+
def _async_update_entry_data(
104+
hass: HomeAssistant, entry: BlinkConfigEntry, blink: Blink
105+
) -> None:
106+
"""Update the config entry data after token refresh."""
107+
hass.config_entries.async_update_entry(entry, data=blink.auth.login_attributes)
108+
109+
111110
@callback
112111
def _async_import_options_from_data_if_missing(
113112
hass: HomeAssistant, entry: BlinkConfigEntry

homeassistant/components/blink/alarm_control_panel.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import logging
66

7+
from blinkpy.auth import UnauthorizedError
78
from blinkpy.blinkpy import Blink, BlinkSyncModule
89

910
from homeassistant.components.alarm_control_panel import (
@@ -13,7 +14,7 @@
1314
)
1415
from homeassistant.const import ATTR_ATTRIBUTION
1516
from homeassistant.core import HomeAssistant, callback
16-
from homeassistant.exceptions import HomeAssistantError
17+
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
1718
from homeassistant.helpers.device_registry import DeviceInfo
1819
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
1920
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -91,6 +92,9 @@ async def async_alarm_disarm(self, code: str | None = None) -> None:
9192

9293
except TimeoutError as er:
9394
raise HomeAssistantError("Blink failed to disarm camera") from er
95+
except UnauthorizedError as er:
96+
self.coordinator.config_entry.async_start_reauth(self.hass)
97+
raise ConfigEntryAuthFailed("Blink authorization failed") from er
9498

9599
await self.coordinator.async_refresh()
96100

@@ -101,5 +105,8 @@ async def async_alarm_arm_away(self, code: str | None = None) -> None:
101105

102106
except TimeoutError as er:
103107
raise HomeAssistantError("Blink failed to arm camera away") from er
108+
except UnauthorizedError as er:
109+
self.coordinator.config_entry.async_start_reauth(self.hass)
110+
raise ConfigEntryAuthFailed("Blink authorization failed") from er
104111

105112
await self.coordinator.async_refresh()

homeassistant/components/blink/camera.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@
66
import logging
77
from typing import Any
88

9+
from blinkpy.auth import UnauthorizedError
10+
from blinkpy.camera import BlinkCamera as BlinkCameraAPI
911
from requests.exceptions import ChunkedEncodingError
1012
import voluptuous as vol
1113

1214
from homeassistant.components.camera import Camera
1315
from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME
1416
from homeassistant.core import HomeAssistant
15-
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
17+
from homeassistant.exceptions import (
18+
ConfigEntryAuthFailed,
19+
HomeAssistantError,
20+
ServiceValidationError,
21+
)
1622
from homeassistant.helpers import config_validation as cv, entity_platform
1723
from homeassistant.helpers.device_registry import DeviceInfo
1824
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -71,7 +77,9 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
7177
_attr_has_entity_name = True
7278
_attr_name = None
7379

74-
def __init__(self, coordinator: BlinkUpdateCoordinator, name, camera) -> None:
80+
def __init__(
81+
self, coordinator: BlinkUpdateCoordinator, name, camera: BlinkCameraAPI
82+
) -> None:
7583
"""Initialize a camera."""
7684
super().__init__(coordinator)
7785
Camera.__init__(self)
@@ -101,6 +109,9 @@ async def async_enable_motion_detection(self) -> None:
101109
translation_domain=DOMAIN,
102110
translation_key="failed_arm",
103111
) from er
112+
except UnauthorizedError as er:
113+
self.coordinator.config_entry.async_start_reauth(self.hass)
114+
raise ConfigEntryAuthFailed("Blink authorization failed") from er
104115

105116
self._camera.motion_enabled = True
106117
await self.coordinator.async_refresh()
@@ -114,6 +125,9 @@ async def async_disable_motion_detection(self) -> None:
114125
translation_domain=DOMAIN,
115126
translation_key="failed_disarm",
116127
) from er
128+
except UnauthorizedError as er:
129+
self.coordinator.config_entry.async_start_reauth(self.hass)
130+
raise ConfigEntryAuthFailed("Blink authorization failed") from er
117131

118132
self._camera.motion_enabled = False
119133
await self.coordinator.async_refresh()
@@ -137,6 +151,9 @@ async def record(self) -> None:
137151
translation_domain=DOMAIN,
138152
translation_key="failed_clip",
139153
) from er
154+
except UnauthorizedError as er:
155+
self.coordinator.config_entry.async_start_reauth(self.hass)
156+
raise ConfigEntryAuthFailed("Blink authorization failed") from er
140157

141158
self.async_write_ha_state()
142159

@@ -149,6 +166,9 @@ async def trigger_camera(self) -> None:
149166
translation_domain=DOMAIN,
150167
translation_key="failed_snap",
151168
) from er
169+
except UnauthorizedError as er:
170+
self.coordinator.config_entry.async_start_reauth(self.hass)
171+
raise ConfigEntryAuthFailed("Blink authorization failed") from er
152172

153173
self.async_write_ha_state()
154174

@@ -182,6 +202,9 @@ async def save_recent_clips(self, file_path) -> None:
182202
translation_domain=DOMAIN,
183203
translation_key="cant_write",
184204
) from err
205+
except UnauthorizedError as er:
206+
self.coordinator.config_entry.async_start_reauth(self.hass)
207+
raise ConfigEntryAuthFailed("Blink authorization failed") from er
185208

186209
async def save_video(self, filename) -> None:
187210
"""Handle save video service calls."""
@@ -200,3 +223,6 @@ async def save_video(self, filename) -> None:
200223
translation_domain=DOMAIN,
201224
translation_key="cant_write",
202225
) from err
226+
except UnauthorizedError as er:
227+
self.coordinator.config_entry.async_start_reauth(self.hass)
228+
raise ConfigEntryAuthFailed("Blink authorization failed") from er

homeassistant/components/blink/config_flow.py

Lines changed: 111 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@
66
import logging
77
from typing import Any
88

9-
from blinkpy.auth import Auth, LoginError, TokenRefreshFailed
9+
from blinkpy.auth import Auth, BlinkTwoFARequiredError, LoginError, TokenRefreshFailed
1010
from blinkpy.blinkpy import Blink, BlinkSetupError
1111
import voluptuous as vol
1212

13-
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
13+
from homeassistant.config_entries import (
14+
SOURCE_REAUTH,
15+
SOURCE_RECONFIGURE,
16+
ConfigFlow,
17+
ConfigFlowResult,
18+
)
1419
from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME
15-
from homeassistant.core import HomeAssistant, callback
20+
from homeassistant.core import callback
1621
from homeassistant.exceptions import HomeAssistantError
1722
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1823

@@ -21,23 +26,18 @@
2126
_LOGGER = logging.getLogger(__name__)
2227

2328

24-
async def validate_input(auth: Auth) -> None:
29+
async def validate_input(blink: Blink) -> None:
2530
"""Validate the user input allows us to connect."""
2631
try:
27-
await auth.startup()
32+
await blink.start()
2833
except (LoginError, TokenRefreshFailed) as err:
2934
raise InvalidAuth from err
30-
if auth.check_key_required():
31-
raise Require2FA
3235

3336

34-
async def _send_blink_2fa_pin(hass: HomeAssistant, auth: Auth, pin: str | None) -> bool:
37+
async def _send_blink_2fa_pin(blink: Blink, pin: str | None) -> bool:
3538
"""Send 2FA pin to blink servers."""
36-
blink = Blink(session=async_get_clientsession(hass))
37-
blink.auth = auth
38-
blink.setup_login_ids()
39-
blink.setup_urls()
40-
return await auth.send_auth_key(blink, pin)
39+
await blink.send_2fa_code(pin)
40+
return True
4141

4242

4343
class BlinkConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -48,26 +48,33 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN):
4848
def __init__(self) -> None:
4949
"""Initialize the blink flow."""
5050
self.auth: Auth | None = None
51+
self.blink: Blink | None = None
52+
53+
async def _handle_user_input(self, user_input: dict[str, Any]):
54+
"""Handle user input."""
55+
self.auth = Auth(
56+
{**user_input, "device_id": DEVICE_ID},
57+
no_prompt=True,
58+
session=async_get_clientsession(self.hass),
59+
)
60+
self.blink = Blink(session=async_get_clientsession(self.hass))
61+
self.blink.auth = self.auth
62+
await self.async_set_unique_id(user_input[CONF_USERNAME])
63+
if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
64+
self._abort_if_unique_id_configured()
65+
66+
await validate_input(self.blink)
67+
return self._async_finish_flow()
5168

5269
async def async_step_user(
5370
self, user_input: dict[str, Any] | None = None
5471
) -> ConfigFlowResult:
5572
"""Handle a flow initiated by the user."""
5673
errors = {}
5774
if user_input is not None:
58-
self.auth = Auth(
59-
{**user_input, "device_id": DEVICE_ID},
60-
no_prompt=True,
61-
session=async_get_clientsession(self.hass),
62-
)
63-
await self.async_set_unique_id(user_input[CONF_USERNAME])
64-
if self.source != SOURCE_REAUTH:
65-
self._abort_if_unique_id_configured()
66-
6775
try:
68-
await validate_input(self.auth)
69-
return self._async_finish_flow()
70-
except Require2FA:
76+
return await self._handle_user_input(user_input)
77+
except BlinkTwoFARequiredError:
7178
return await self.async_step_2fa()
7279
except InvalidAuth:
7380
errors["base"] = "invalid_auth"
@@ -93,19 +100,16 @@ async def async_step_2fa(
93100
errors = {}
94101
if user_input is not None:
95102
try:
96-
valid_token = await _send_blink_2fa_pin(
97-
self.hass, self.auth, user_input.get(CONF_PIN)
98-
)
103+
await _send_blink_2fa_pin(self.blink, user_input.get(CONF_PIN))
99104
except BlinkSetupError:
100105
errors["base"] = "cannot_connect"
106+
except TokenRefreshFailed:
107+
errors["base"] = "invalid_access_token"
101108
except Exception:
102109
_LOGGER.exception("Unexpected exception")
103110
errors["base"] = "unknown"
104-
105111
else:
106-
if valid_token:
107-
return self._async_finish_flow()
108-
errors["base"] = "invalid_access_token"
112+
return self._async_finish_flow()
109113

110114
return self.async_show_form(
111115
step_id="2fa",
@@ -118,18 +122,88 @@ async def async_step_2fa(
118122
async def async_step_reauth(
119123
self, entry_data: Mapping[str, Any]
120124
) -> ConfigFlowResult:
121-
"""Perform reauth upon migration of old entries."""
122-
return await self.async_step_user(dict(entry_data))
125+
"""Perform reauth after an authentication error."""
126+
return await self.async_step_reauth_confirm(None)
127+
128+
async def async_step_reauth_confirm(
129+
self, user_input: dict[str, Any] | None = None
130+
) -> ConfigFlowResult:
131+
"""Handle reauth confirmation."""
132+
errors = {}
133+
if user_input is not None:
134+
try:
135+
return await self._handle_user_input(user_input)
136+
except BlinkTwoFARequiredError:
137+
return await self.async_step_2fa()
138+
except InvalidAuth:
139+
errors["base"] = "invalid_auth"
140+
except Exception:
141+
_LOGGER.exception("Unexpected exception")
142+
errors["base"] = "unknown"
143+
144+
config_entry = self._get_reauth_entry()
145+
return self.async_show_form(
146+
step_id="reauth_confirm",
147+
data_schema=vol.Schema(
148+
{
149+
vol.Required(
150+
CONF_USERNAME, default=config_entry.data[CONF_USERNAME]
151+
): str,
152+
vol.Required(
153+
CONF_PASSWORD, default=config_entry.data[CONF_PASSWORD]
154+
): str,
155+
}
156+
),
157+
errors=errors,
158+
description_placeholders={"username": config_entry.data[CONF_USERNAME]},
159+
)
160+
161+
async def async_step_reconfigure(
162+
self, user_input: dict[str, Any] | None = None
163+
) -> ConfigFlowResult:
164+
"""Handle reconfiguration initiated by the user."""
165+
errors = {}
166+
if user_input is not None:
167+
try:
168+
return await self._handle_user_input(user_input)
169+
except BlinkTwoFARequiredError:
170+
return await self.async_step_2fa()
171+
except InvalidAuth:
172+
errors["base"] = "invalid_auth"
173+
except Exception:
174+
_LOGGER.exception("Unexpected exception")
175+
errors["base"] = "unknown"
176+
177+
config_entry = self._get_reconfigure_entry()
178+
return self.async_show_form(
179+
step_id="reconfigure",
180+
data_schema=vol.Schema(
181+
{
182+
vol.Required(
183+
CONF_USERNAME, default=config_entry.data[CONF_USERNAME]
184+
): str,
185+
vol.Required(
186+
CONF_PASSWORD, default=config_entry.data[CONF_PASSWORD]
187+
): str,
188+
}
189+
),
190+
errors=errors,
191+
)
123192

124193
@callback
125194
def _async_finish_flow(self) -> ConfigFlowResult:
126195
"""Finish with setup."""
127196
assert self.auth
128-
return self.async_create_entry(title=DOMAIN, data=self.auth.login_attributes)
129197

198+
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
199+
return self.async_update_reload_and_abort(
200+
self._get_reauth_entry()
201+
if self.source == SOURCE_REAUTH
202+
else self._get_reconfigure_entry(),
203+
data_updates=self.auth.login_attributes,
204+
)
130205

131-
class Require2FA(HomeAssistantError):
132-
"""Error to indicate we require 2FA."""
206+
return self.async_create_entry(title=DOMAIN, data=self.auth.login_attributes)
133207

134208

135209
class InvalidAuth(HomeAssistantError):

0 commit comments

Comments
 (0)