diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 3879d15cda8..df06bc9f47b 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -131,6 +131,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool with_family, max_interval, gps_accuracy_threshold, + entry, ) await hass.async_add_executor_job(account.setup) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 683d59e9281..e6337085e04 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging import operator -from typing import Dict +from typing import Dict, Optional from pyicloud import PyiCloudService from pyicloud.exceptions import ( @@ -13,7 +13,8 @@ from pyicloud.exceptions import ( from pyicloud.services.findmyiphone import AppleDevice from homeassistant.components.zone import async_active_zone -from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, CONF_USERNAME from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_point_in_utc_time @@ -81,6 +82,7 @@ class IcloudAccount: with_family: bool, max_interval: int, gps_accuracy_threshold: int, + config_entry: ConfigEntry, ): """Initialize an iCloud account.""" self.hass = hass @@ -93,11 +95,12 @@ class IcloudAccount: self._icloud_dir = icloud_dir - self.api: PyiCloudService = None + self.api: Optional[PyiCloudService] = None self._owner_fullname = None self._family_members_fullname = {} self._devices = {} self._retried_fetch = False + self._config_entry = config_entry self.listeners = [] @@ -110,9 +113,28 @@ class IcloudAccount: self._icloud_dir.path, with_family=self._with_family, ) - except PyiCloudFailedLoginException as error: + except PyiCloudFailedLoginException: self.api = None - _LOGGER.error("Error logging into iCloud Service: %s", error) + # Login failed which means credentials need to be updated. + _LOGGER.error( + ( + "Your password for '%s' is no longer working. Go to the " + "Integrations menu and click on Configure on the discovered Apple " + "iCloud card to login again." + ), + self._config_entry.data[CONF_USERNAME], + ) + + self.hass.add_job( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={ + **self._config_entry.data, + "unique_id": self._config_entry.unique_id, + }, + ) + ) return try: diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py index a26226a9e18..d447790e432 100644 --- a/homeassistant/components/icloud/config_flow.py +++ b/homeassistant/components/icloud/config_flow.py @@ -50,48 +50,57 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._trusted_device = None self._verification_code = None - async def _show_setup_form(self, user_input=None, errors=None): + self._existing_entry = None + self._description_placeholders = None + + def _show_setup_form(self, user_input=None, errors=None, step_id="user"): """Show the setup form to the user.""" if user_input is None: user_input = {} + if step_id == "user": + schema = { + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, + vol.Optional( + CONF_WITH_FAMILY, + default=user_input.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY), + ): bool, + } + else: + schema = { + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, + } + return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") - ): str, - vol.Required( - CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") - ): str, - vol.Optional( - CONF_WITH_FAMILY, - default=user_input.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY), - ): bool, - } - ), + step_id=step_id, + data_schema=vol.Schema(schema), errors=errors or {}, + description_placeholders=self._description_placeholders, ) - async def async_step_user(self, user_input=None): - """Handle a flow initiated by the user.""" - errors = {} - - icloud_dir = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - - if not os.path.exists(icloud_dir.path): - await self.hass.async_add_executor_job(os.makedirs, icloud_dir.path) - - if user_input is None: - return await self._show_setup_form(user_input, errors) - - self._username = user_input[CONF_USERNAME] + async def _validate_and_create_entry(self, user_input, step_id): + """Check if config is valid and create entry if so.""" self._password = user_input[CONF_PASSWORD] - self._with_family = user_input.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY) - self._max_interval = user_input.get(CONF_MAX_INTERVAL, DEFAULT_MAX_INTERVAL) - self._gps_accuracy_threshold = user_input.get( + + extra_inputs = user_input + + # If an existing entry was found, meaning this is a password update attempt, + # use those to get config values that aren't changing + if self._existing_entry: + extra_inputs = self._existing_entry + + self._username = extra_inputs[CONF_USERNAME] + self._with_family = extra_inputs.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY) + self._max_interval = extra_inputs.get(CONF_MAX_INTERVAL, DEFAULT_MAX_INTERVAL) + self._gps_accuracy_threshold = extra_inputs.get( CONF_GPS_ACCURACY_THRESHOLD, DEFAULT_GPS_ACCURACY_THRESHOLD ) @@ -105,7 +114,7 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): PyiCloudService, self._username, self._password, - icloud_dir.path, + self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY).path, True, None, self._with_family, @@ -113,8 +122,8 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except PyiCloudFailedLoginException as error: _LOGGER.error("Error logging into iCloud service: %s", error) self.api = None - errors[CONF_USERNAME] = "invalid_auth" - return await self._show_setup_form(user_input, errors) + errors = {CONF_PASSWORD: "invalid_auth"} + return self._show_setup_form(user_input, errors, step_id) if self.api.requires_2sa: return await self.async_step_trusted_device() @@ -130,21 +139,59 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.api = None return self.async_abort(reason="no_device") - return self.async_create_entry( - title=self._username, - data={ - CONF_USERNAME: self._username, - CONF_PASSWORD: self._password, - CONF_WITH_FAMILY: self._with_family, - CONF_MAX_INTERVAL: self._max_interval, - CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold, - }, - ) + data = { + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_WITH_FAMILY: self._with_family, + CONF_MAX_INTERVAL: self._max_interval, + CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold, + } + + # If this is a password update attempt, update the entry instead of creating one + if step_id == "user": + return self.async_create_entry(title=self._username, data=data) + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == self.unique_id: + self.hass.config_entries.async_update_entry(entry, data=data) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + icloud_dir = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + + if not os.path.exists(icloud_dir.path): + await self.hass.async_add_executor_job(os.makedirs, icloud_dir.path) + + if user_input is None: + return self._show_setup_form(user_input, errors) + + return await self._validate_and_create_entry(user_input, "user") async def async_step_import(self, user_input): """Import a config entry.""" return await self.async_step_user(user_input) + async def async_step_reauth(self, user_input=None): + """Update password for a config entry that can't authenticate.""" + # Store existing entry data so it can be used later and set unique ID + # so existing config entry can be updated + if not self._existing_entry: + await self.async_set_unique_id(user_input.pop("unique_id")) + self._existing_entry = user_input.copy() + self._description_placeholders = {"username": user_input[CONF_USERNAME]} + user_input = None + + if user_input is None: + return self._show_setup_form(step_id=config_entries.SOURCE_REAUTH) + + return await self._validate_and_create_entry( + user_input, config_entries.SOURCE_REAUTH + ) + async def async_step_trusted_device(self, user_input=None, errors=None): """We need a trusted device.""" if errors is None: diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index 43bc204f451..1627dd8661e 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -10,6 +10,13 @@ "with_family": "With family" } }, + "reauth": { + "title": "iCloud credentials", + "description": "Your previously entered password for {username} is no longer working. Update your password to keep using this integration.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "trusted_device": { "title": "iCloud trusted device", "description": "Select your trusted device", @@ -32,7 +39,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "no_device": "None of your devices have \"Find my iPhone\" activated" + "no_device": "None of your devices have \"Find my iPhone\" activated", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/tests/components/icloud/test_config_flow.py b/tests/components/icloud/test_config_flow.py index 4f70083a14a..5ed46a6136f 100644 --- a/tests/components/icloud/test_config_flow.py +++ b/tests/components/icloud/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant.components.icloud.const import ( DEFAULT_WITH_FAMILY, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.typing import HomeAssistantType @@ -26,10 +26,19 @@ from tests.common import MockConfigEntry USERNAME = "username@me.com" USERNAME_2 = "second_username@icloud.com" PASSWORD = "password" +PASSWORD_2 = "second_password" WITH_FAMILY = True MAX_INTERVAL = 15 GPS_ACCURACY_THRESHOLD = 250 +MOCK_CONFIG = { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_WITH_FAMILY: DEFAULT_WITH_FAMILY, + CONF_MAX_INTERVAL: DEFAULT_MAX_INTERVAL, + CONF_GPS_ACCURACY_THRESHOLD: DEFAULT_GPS_ACCURACY_THRESHOLD, +} + TRUSTED_DEVICES = [ {"deviceType": "SMS", "areaCode": "", "phoneNumber": "*******58", "deviceId": "1"} ] @@ -275,7 +284,7 @@ async def test_login_failed(hass: HomeAssistantType): data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_USERNAME: "invalid_auth"} + assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} async def test_no_device( @@ -397,3 +406,56 @@ async def test_validate_verification_code_failed( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == CONF_TRUSTED_DEVICE assert result["errors"] == {"base": "validate_verification_code"} + + +async def test_password_update( + hass: HomeAssistantType, service_authenticated: MagicMock +): + """Test that password reauthentication works successfully.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=USERNAME + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={**MOCK_CONFIG, "unique_id": USERNAME}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: PASSWORD_2} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_PASSWORD] == PASSWORD_2 + + +async def test_password_update_wrong_password(hass: HomeAssistantType): + """Test that during password reauthentication wrong password returns correct error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=USERNAME + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={**MOCK_CONFIG, "unique_id": USERNAME}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + with patch( + "homeassistant.components.icloud.config_flow.PyiCloudService.authenticate", + side_effect=PyiCloudFailedLoginException(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: PASSWORD_2} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_PASSWORD: "invalid_auth"}