Skip to content

Commit e77d6fe

Browse files
webui handles auth errors #1195 (#1210)
1 parent 82d7fb1 commit e77d6fe

File tree

11 files changed

+225
-89
lines changed

11 files changed

+225
-89
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- fix: failed auth terminate webui [#1195](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/1195)
56
- fix: disable raw password auth fallback [#1176](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/1176)
67

78
## 1.29.3 (2025-08-09)

src/icloudpd/authentication.py

Lines changed: 36 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from icloudpd.mfa_provider import MFAProvider
1212
from icloudpd.status import Status, StatusExchange
1313
from pyicloud_ipd.base import PyiCloudService
14+
from pyicloud_ipd.exceptions import PyiCloudFailedMFAException
1415
from pyicloud_ipd.file_match import FileMatchPolicy
1516
from pyicloud_ipd.raw_policy import RawTreatmentPolicy
1617

@@ -123,8 +124,7 @@ def request_2fa(icloud: PyiCloudService, logger: logging.Logger) -> None:
123124
device_index_alphabet = "abcdefghijklmnopqrstuvwxyz"
124125
if devices_count > 0:
125126
if devices_count > len(device_index_alphabet):
126-
logger.error("Too many trusted devices for authentication")
127-
sys.exit(1)
127+
raise PyiCloudFailedMFAException("Too many trusted devices for authentication")
128128

129129
for i, device in enumerate(devices):
130130
click.echo(f" {device_index_alphabet[i]}: {device.obfuscated_number}")
@@ -175,8 +175,7 @@ def request_2fa(icloud: PyiCloudService, logger: logging.Logger) -> None:
175175
device_index = device_index_alphabet.index(index_or_code)
176176
device = devices[device_index]
177177
if not icloud.send_2fa_code_sms(device.id):
178-
logger.error("Failed to send two-factor authentication code")
179-
sys.exit(1)
178+
raise PyiCloudFailedMFAException("Failed to send two-factor authentication code")
180179
while True:
181180
code: str = click.prompt(
182181
"Please enter two-factor authentication code that you received over SMS",
@@ -186,12 +185,10 @@ def request_2fa(icloud: PyiCloudService, logger: logging.Logger) -> None:
186185
click.echo("Invalid code, should be six digits. Try again")
187186

188187
if not icloud.validate_2fa_code_sms(device.id, code):
189-
logger.error("Failed to verify two-factor authentication code")
190-
sys.exit(1)
188+
raise PyiCloudFailedMFAException("Failed to verify two-factor authentication code")
191189
else:
192190
if not icloud.validate_2fa_code(index_or_code):
193-
logger.error("Failed to verify two-factor authentication code")
194-
sys.exit(1)
191+
raise PyiCloudFailedMFAException("Failed to verify two-factor authentication code")
195192
else:
196193
while True:
197194
code = click.prompt(
@@ -201,8 +198,7 @@ def request_2fa(icloud: PyiCloudService, logger: logging.Logger) -> None:
201198
break
202199
click.echo("Invalid code, should be six digits. Try again")
203200
if not icloud.validate_2fa_code(code):
204-
logger.error("Failed to verify two-factor authentication code")
205-
sys.exit(1)
201+
raise PyiCloudFailedMFAException("Failed to verify two-factor authentication code")
206202
logger.info(
207203
"Great, you're all set up. The script can now be run without "
208204
"user interaction until 2FA expires.\n"
@@ -217,40 +213,42 @@ def request_2fa_web(
217213
) -> None:
218214
"""Request two-factor authentication through Webui."""
219215
if not status_exchange.replace_status(Status.NO_INPUT_NEEDED, Status.NEED_MFA):
220-
logger.error("Expected NO_INPUT_NEEDED, but got something else")
221-
return
216+
raise PyiCloudFailedMFAException(
217+
f"Expected NO_INPUT_NEEDED, but got {status_exchange.get_status()}"
218+
)
222219

223220
# wait for input
224221
while True:
225222
status = status_exchange.get_status()
226223
if status == Status.NEED_MFA:
227224
time.sleep(1)
225+
continue
228226
else:
229-
break
227+
pass
230228

231-
if status_exchange.replace_status(Status.SUPPLIED_MFA, Status.CHECKING_MFA):
232-
code = status_exchange.get_payload()
233-
if not code:
234-
logger.error("Internal error: did not get code for SUPPLIED_MFA status")
235-
status_exchange.replace_status(
236-
Status.CHECKING_MFA, Status.NO_INPUT_NEEDED
237-
) # TODO Error
238-
return
229+
if status_exchange.replace_status(Status.SUPPLIED_MFA, Status.CHECKING_MFA):
230+
code = status_exchange.get_payload()
231+
if not code:
232+
raise PyiCloudFailedMFAException(
233+
"Internal error: did not get code for SUPPLIED_MFA status"
234+
)
239235

240-
if not icloud.validate_2fa_code(code):
241-
logger.error("Failed to verify two-factor authentication code")
242-
status_exchange.replace_status(
243-
Status.CHECKING_MFA, Status.NO_INPUT_NEEDED
244-
) # TODO Error
245-
return
246-
status_exchange.replace_status(Status.CHECKING_MFA, Status.NO_INPUT_NEEDED) # done
247-
248-
logger.info(
249-
"Great, you're all set up. The script can now be run without "
250-
"user interaction until 2FA expires.\n"
251-
"You can set up email notifications for when "
252-
"the two-factor authentication expires.\n"
253-
"(Use --help to view information about SMTP options.)"
254-
)
255-
else:
256-
logger.error("Failed to change status")
236+
if not icloud.validate_2fa_code(code):
237+
if status_exchange.set_error("Failed to verify two-factor authentication code"):
238+
# that will loop forever
239+
# TODO give user an option to restart auth in case they missed code
240+
continue
241+
else:
242+
raise PyiCloudFailedMFAException("Failed to chage status of invalid code")
243+
else:
244+
status_exchange.replace_status(Status.CHECKING_MFA, Status.NO_INPUT_NEEDED) # done
245+
246+
logger.info(
247+
"Great, you're all set up. The script can now be run without "
248+
"user interaction until 2FA expires.\n"
249+
"You can set up email notifications for when "
250+
"the two-factor authentication expires.\n"
251+
"(Use --help to view information about SMTP options.)"
252+
)
253+
else:
254+
raise PyiCloudFailedMFAException("Failed to change status")

src/icloudpd/base.py

Lines changed: 62 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from pyicloud_ipd.base import PyiCloudService
6767
from pyicloud_ipd.exceptions import (
6868
PyiCloudFailedLoginException,
69+
PyiCloudFailedMFAException,
6970
PyiCloudServiceNotActivatedException,
7071
PyiCloudServiceUnavailableException,
7172
)
@@ -169,43 +170,39 @@ def ask_password_in_console(_user: str) -> str | None:
169170

170171

171172
def get_password_from_webui(
172-
logger: Logger, status_exchange: StatusExchange
173-
) -> Callable[[str], str | None]:
174-
def _intern(_user: str) -> str | None:
175-
"""Request two-factor authentication through Webui."""
176-
if not status_exchange.replace_status(Status.NO_INPUT_NEEDED, Status.NEED_PASSWORD):
177-
logger.error("Expected NO_INPUT_NEEDED, but got something else")
173+
logger: Logger, status_exchange: StatusExchange, _user: str
174+
) -> str | None:
175+
"""Request two-factor authentication through Webui."""
176+
if not status_exchange.replace_status(Status.NO_INPUT_NEEDED, Status.NEED_PASSWORD):
177+
logger.error("Expected NO_INPUT_NEEDED, but got something else")
178+
return None
179+
180+
# wait for input
181+
while True:
182+
status = status_exchange.get_status()
183+
if status == Status.NEED_PASSWORD:
184+
time.sleep(1)
185+
else:
186+
break
187+
if status_exchange.replace_status(Status.SUPPLIED_PASSWORD, Status.CHECKING_PASSWORD):
188+
password = status_exchange.get_payload()
189+
if not password:
190+
logger.error("Internal error: did not get password for SUPPLIED_PASSWORD status")
191+
status_exchange.replace_status(
192+
Status.CHECKING_PASSWORD, Status.NO_INPUT_NEEDED
193+
) # TODO Error
178194
return None
195+
return password
179196

180-
# wait for input
181-
while True:
182-
status = status_exchange.get_status()
183-
if status == Status.NEED_PASSWORD:
184-
time.sleep(1)
185-
else:
186-
break
187-
if status_exchange.replace_status(Status.SUPPLIED_PASSWORD, Status.CHECKING_PASSWORD):
188-
password = status_exchange.get_payload()
189-
if not password:
190-
logger.error("Internal error: did not get password for SUPPLIED_PASSWORD status")
191-
status_exchange.replace_status(
192-
Status.CHECKING_PASSWORD, Status.NO_INPUT_NEEDED
193-
) # TODO Error
194-
return None
195-
return password
196-
197-
return None # TODO
197+
return None # TODO
198198

199-
return _intern
200199

200+
def update_password_status_in_webui(status_exchange: StatusExchange, _u: str, _p: str) -> None:
201+
status_exchange.replace_status(Status.CHECKING_PASSWORD, Status.NO_INPUT_NEEDED)
201202

202-
def update_password_status_in_webui(status_exchange: StatusExchange) -> Callable[[str, str], None]:
203-
def _intern(_u: str, _p: str) -> None:
204-
# TODO we are not handling wrong passwords...
205-
status_exchange.replace_status(Status.CHECKING_PASSWORD, Status.NO_INPUT_NEEDED)
206-
return None
207203

208-
return _intern
204+
def update_auth_error_in_webui(status_exchange: StatusExchange, error: str) -> bool:
205+
return status_exchange.set_error(error)
209206

210207

211208
# def get_click_param_by_name(_name: str, _params: List[Parameter]) -> Optional[Parameter]:
@@ -234,6 +231,7 @@ def password_provider_generator(
234231
) -> Dict[str, Tuple[Callable[[str], str | None], Callable[[str, str], None]]]:
235232
def _map(provider: str) -> Tuple[Callable[[str], str | None], Callable[[str, str], None]]:
236233
if provider == "webui":
234+
# ask_password_in_console will be replaced once we setup web
237235
return (ask_password_in_console, dummy_password_writter)
238236
if provider == "console":
239237
return (ask_password_in_console, dummy_password_writter)
@@ -805,8 +803,8 @@ def main(
805803
if "webui" in password_providers:
806804
# replace
807805
password_providers["webui"] = (
808-
get_password_from_webui(logger, status_exchange),
809-
update_password_status_in_webui(status_exchange),
806+
partial(get_password_from_webui, logger, status_exchange),
807+
partial(update_password_status_in_webui, status_exchange),
810808
)
811809

812810
# hacky way to inject logger
@@ -1559,13 +1557,31 @@ def should_break(counter: Counter) -> bool:
15591557
except PyiCloudFailedLoginException as _error:
15601558
logger.info("Invalid email/password combination.")
15611559
dump_responses(logger.debug, captured_responses)
1562-
if "webui" in password_providers and mfa_provider == MFAProvider.WEBUI:
1563-
pass
1560+
if "webui" in password_providers:
1561+
update_auth_error_in_webui(status_exchange, "Invalid email/password combination.")
1562+
continue
1563+
else:
1564+
return 1
1565+
except PyiCloudFailedMFAException as error:
1566+
logger.info(str(error))
1567+
dump_responses(logger.debug, captured_responses)
1568+
if mfa_provider == MFAProvider.WEBUI:
1569+
update_auth_error_in_webui(status_exchange, str(error))
1570+
continue
15641571
else:
15651572
return 1
15661573
except (PyiCloudServiceNotActivatedException, PyiCloudServiceUnavailableException) as error:
15671574
logger.info(error)
15681575
dump_responses(logger.debug, captured_responses)
1576+
# webui will display error and wait for password again
1577+
if "webui" in password_providers or mfa_provider == MFAProvider.WEBUI:
1578+
if update_auth_error_in_webui(status_exchange, str(error)):
1579+
# retry if it was during auth
1580+
continue
1581+
else:
1582+
pass
1583+
else:
1584+
pass
15691585
# it not watching then return error
15701586
if not watch_interval:
15711587
return 1
@@ -1575,6 +1591,17 @@ def should_break(counter: Counter) -> bool:
15751591
logger.info("Cannot connect to Apple iCloud service")
15761592
dump_responses(logger.debug, captured_responses)
15771593
# logger.debug(error)
1594+
# webui will display error and wait for password again
1595+
if "webui" in password_providers or mfa_provider == MFAProvider.WEBUI:
1596+
if update_auth_error_in_webui(
1597+
status_exchange, "Cannot connect to Apple iCloud service"
1598+
):
1599+
# retry if it was during auth
1600+
continue
1601+
else:
1602+
pass
1603+
else:
1604+
pass
15781605
# it not watching then return error
15791606
if not watch_interval:
15801607
return 1

src/icloudpd/server/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,19 @@ def get_status() -> Response | str:
2626
_status = _status_exchange.get_status()
2727
_config = _status_exchange.get_config()
2828
_progress = _status_exchange.get_progress()
29+
_error = _status_exchange.get_error()
2930
if _status == Status.NO_INPUT_NEEDED:
3031
return render_template(
31-
"no_input.html", status=_status, progress=_progress, config=vars(_config)
32+
"no_input.html",
33+
status=_status,
34+
error=_error,
35+
progress=_progress,
36+
config=vars(_config),
3237
)
3338
if _status == Status.NEED_MFA:
34-
return render_template("code.html")
39+
return render_template("code.html", error=_error)
3540
if _status == Status.NEED_PASSWORD:
36-
return render_template("password.html", config=_config)
41+
return render_template("password.html", error=_error, config=_config)
3742
return render_template("status.html", status=_status)
3843

3944
@app.route("/code", methods=["POST"])

src/icloudpd/server/templates/code.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
<form hx-post="/code" hx-swap="outerHTML" class="row align-items-center" hx-target-error="#toast-content">
22
<fieldset>
33
<legend>Authentication</legend>
4+
{% if error %}
5+
<ul class="list-group list-group-flush">
6+
<li class="list-group-item d-flex justify-content-between align-items-center">
7+
<div class="fw-bold">{{ error }}</div>
8+
</li>
9+
</ul>
10+
{% endif %}
411
<div class="col-12 mb-3">
512
<label for="code" class="form-label">Two-Factor code of the user {{ config.username }}</label>
613
<input type="text" class="form-control" id="code" name="code" placeholder="Enter Two-Factor Code">

src/icloudpd/server/templates/no_input.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
<div class="card-header text-bg-primary">
88
Status
99
</div>
10+
{% if error %}
11+
<ul class="list-group list-group-flush">
12+
<li class="list-group-item d-flex justify-content-between align-items-center">
13+
<div class="fw-bold">{{ error }}</div>
14+
</li>
15+
</ul>
16+
{% endif %}
1017
<ul class="list-group list-group-flush">
1118
<li class="list-group-item d-flex justify-content-between align-items-center">
1219
<div class="fw-bold">No input is needed</div>

src/icloudpd/server/templates/password.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
<form hx-post="/password" hx-swap="outerHTML" class="row align-items-center" hx-target-error="#toast-content">
22
<fieldset>
33
<legend>Authentication</legend>
4+
{% if error %}
5+
<ul class="list-group list-group-flush">
6+
<li class="list-group-item d-flex justify-content-between align-items-center">
7+
<div class="fw-bold">{{ error }}</div>
8+
</li>
9+
</ul>
10+
{% endif %}
411
<div class="col-12 mb-3">
512
<label for="password" class="form-label">Password of the user {{ config.username }}</label>
613
<input type="password" class="form-control" id="password" name="password" placeholder="Enter password">

src/icloudpd/status.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def __init__(self) -> None:
2323
self.lock = Lock()
2424
self._status = Status.NO_INPUT_NEEDED
2525
self._payload: str | None = None
26+
self._error: str | None = None
2627
self._config: Config | None = None
2728
self._progress = Progress()
2829

@@ -47,6 +48,7 @@ def set_payload(self, payload: str) -> bool:
4748
self._status = (
4849
Status.SUPPLIED_MFA if self._status == Status.NEED_MFA else Status.SUPPLIED_PASSWORD
4950
)
51+
self._error = None
5052
return True
5153

5254
def get_payload(self) -> str | None:
@@ -61,6 +63,30 @@ def get_payload(self) -> str | None:
6163

6264
return self._payload
6365

66+
def set_error(self, error: str) -> bool:
67+
with self.lock:
68+
if self._status != Status.CHECKING_MFA and self._status != Status.CHECKING_PASSWORD:
69+
return False
70+
71+
self._error = error
72+
self._status = (
73+
Status.NO_INPUT_NEEDED
74+
if self._status == Status.CHECKING_PASSWORD
75+
else Status.NEED_MFA
76+
)
77+
return True
78+
79+
def get_error(self) -> str | None:
80+
with self.lock:
81+
if self._status not in [
82+
Status.NO_INPUT_NEEDED,
83+
Status.NEED_PASSWORD,
84+
Status.NEED_MFA,
85+
]:
86+
return None
87+
88+
return self._error
89+
6490
def set_config(self, config: Config) -> None:
6591
with self.lock:
6692
self._config = config

0 commit comments

Comments
 (0)