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:
parent
6ae12c3faf
commit
025bdd74a1
5 changed files with 193 additions and 53 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"}
|
||||
|
|
Loading…
Add table
Reference in a new issue