Allow users to update their iCloud password when auth fails (#39138)

* Allow users to update their iCloud password when auth fails after successful setup

* remove trailing comma

* use new reauth source instead of custom one so frontend can do the right thing

* add quentames text suggestion with a slight tweak

* fix test

* use common string for successful reauth and remove creation and dismissal of persistent notification

* Update homeassistant/components/icloud/account.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Raman Gupta 2020-10-10 02:02:28 -04:00 committed by GitHub
parent 6ae12c3faf
commit 025bdd74a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 193 additions and 53 deletions

View File

@ -131,6 +131,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool
with_family, with_family,
max_interval, max_interval,
gps_accuracy_threshold, gps_accuracy_threshold,
entry,
) )
await hass.async_add_executor_job(account.setup) await hass.async_add_executor_job(account.setup)

View File

@ -2,7 +2,7 @@
from datetime import timedelta from datetime import timedelta
import logging import logging
import operator import operator
from typing import Dict from typing import Dict, Optional
from pyicloud import PyiCloudService from pyicloud import PyiCloudService
from pyicloud.exceptions import ( from pyicloud.exceptions import (
@ -13,7 +13,8 @@ from pyicloud.exceptions import (
from pyicloud.services.findmyiphone import AppleDevice from pyicloud.services.findmyiphone import AppleDevice
from homeassistant.components.zone import async_active_zone 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.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.event import track_point_in_utc_time
@ -81,6 +82,7 @@ class IcloudAccount:
with_family: bool, with_family: bool,
max_interval: int, max_interval: int,
gps_accuracy_threshold: int, gps_accuracy_threshold: int,
config_entry: ConfigEntry,
): ):
"""Initialize an iCloud account.""" """Initialize an iCloud account."""
self.hass = hass self.hass = hass
@ -93,11 +95,12 @@ class IcloudAccount:
self._icloud_dir = icloud_dir self._icloud_dir = icloud_dir
self.api: PyiCloudService = None self.api: Optional[PyiCloudService] = None
self._owner_fullname = None self._owner_fullname = None
self._family_members_fullname = {} self._family_members_fullname = {}
self._devices = {} self._devices = {}
self._retried_fetch = False self._retried_fetch = False
self._config_entry = config_entry
self.listeners = [] self.listeners = []
@ -110,9 +113,28 @@ class IcloudAccount:
self._icloud_dir.path, self._icloud_dir.path,
with_family=self._with_family, with_family=self._with_family,
) )
except PyiCloudFailedLoginException as error: except PyiCloudFailedLoginException:
self.api = None 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 return
try: try:

View File

@ -50,16 +50,17 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self._trusted_device = None self._trusted_device = None
self._verification_code = 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.""" """Show the setup form to the user."""
if user_input is None: if user_input is None:
user_input = {} user_input = {}
return self.async_show_form( if step_id == "user":
step_id="user", schema = {
data_schema=vol.Schema(
{
vol.Required( vol.Required(
CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")
): str, ): str,
@ -71,27 +72,35 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
default=user_input.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY), default=user_input.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY),
): bool, ): bool,
} }
), else:
schema = {
vol.Required(
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
): str,
}
return self.async_show_form(
step_id=step_id,
data_schema=vol.Schema(schema),
errors=errors or {}, errors=errors or {},
description_placeholders=self._description_placeholders,
) )
async def async_step_user(self, user_input=None): async def _validate_and_create_entry(self, user_input, step_id):
"""Handle a flow initiated by the user.""" """Check if config is valid and create entry if so."""
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]
self._password = user_input[CONF_PASSWORD] 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) extra_inputs = user_input
self._gps_accuracy_threshold = user_input.get(
# 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 CONF_GPS_ACCURACY_THRESHOLD, DEFAULT_GPS_ACCURACY_THRESHOLD
) )
@ -105,7 +114,7 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
PyiCloudService, PyiCloudService,
self._username, self._username,
self._password, self._password,
icloud_dir.path, self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY).path,
True, True,
None, None,
self._with_family, self._with_family,
@ -113,8 +122,8 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
except PyiCloudFailedLoginException as error: except PyiCloudFailedLoginException as error:
_LOGGER.error("Error logging into iCloud service: %s", error) _LOGGER.error("Error logging into iCloud service: %s", error)
self.api = None self.api = None
errors[CONF_USERNAME] = "invalid_auth" errors = {CONF_PASSWORD: "invalid_auth"}
return await self._show_setup_form(user_input, errors) return self._show_setup_form(user_input, errors, step_id)
if self.api.requires_2sa: if self.api.requires_2sa:
return await self.async_step_trusted_device() return await self.async_step_trusted_device()
@ -130,21 +139,59 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self.api = None self.api = None
return self.async_abort(reason="no_device") return self.async_abort(reason="no_device")
return self.async_create_entry(
title=self._username,
data = { data = {
CONF_USERNAME: self._username, CONF_USERNAME: self._username,
CONF_PASSWORD: self._password, CONF_PASSWORD: self._password,
CONF_WITH_FAMILY: self._with_family, CONF_WITH_FAMILY: self._with_family,
CONF_MAX_INTERVAL: self._max_interval, CONF_MAX_INTERVAL: self._max_interval,
CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold, 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): async def async_step_import(self, user_input):
"""Import a config entry.""" """Import a config entry."""
return await self.async_step_user(user_input) 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): async def async_step_trusted_device(self, user_input=None, errors=None):
"""We need a trusted device.""" """We need a trusted device."""
if errors is None: if errors is None:

View File

@ -10,6 +10,13 @@
"with_family": "With family" "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": { "trusted_device": {
"title": "iCloud trusted device", "title": "iCloud trusted device",
"description": "Select your trusted device", "description": "Select your trusted device",
@ -32,7 +39,8 @@
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "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%]"
} }
} }
} }

View File

@ -16,7 +16,7 @@ from homeassistant.components.icloud.const import (
DEFAULT_WITH_FAMILY, DEFAULT_WITH_FAMILY,
DOMAIN, 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.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
@ -26,10 +26,19 @@ from tests.common import MockConfigEntry
USERNAME = "username@me.com" USERNAME = "username@me.com"
USERNAME_2 = "second_username@icloud.com" USERNAME_2 = "second_username@icloud.com"
PASSWORD = "password" PASSWORD = "password"
PASSWORD_2 = "second_password"
WITH_FAMILY = True WITH_FAMILY = True
MAX_INTERVAL = 15 MAX_INTERVAL = 15
GPS_ACCURACY_THRESHOLD = 250 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 = [ TRUSTED_DEVICES = [
{"deviceType": "SMS", "areaCode": "", "phoneNumber": "*******58", "deviceId": "1"} {"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}, data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 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( 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["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == CONF_TRUSTED_DEVICE assert result["step_id"] == CONF_TRUSTED_DEVICE
assert result["errors"] == {"base": "validate_verification_code"} 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"}