Skip to content

Commit 87d3394

Browse files
authored
Authentication Fix: Merge updated auth flow from pyicloud (#734)
* Merged updated auth flow from pyicloud - including 2FA. Retain Library and Domain support. * Update authentication.py to use 2FA * Fix AttributeError trying to use 'get' on Response object * Cleanup unused imports. Updated a couple incorrect references from 'apple_id' to 'accountName' * Tests: Updated 2 VCR's for better underatanding of further necessary updates. * icloudpd: Fix: No password provided or in keyring now prompts correctly icloudpd: Refactor: 2SA prompt requires device selection to send code icloudpd: Feat: New --auth-only flag to trigger log in, 2SA/2FA, and set session/cookie file. Future log in will validate the tokens without running through full signin flow. Can be used to validate the session tokens are still good without having to ping the photo endpoints. pyicloud_ipd: Clean: Removed unused imports pyicloud_ipd: Fix: Capture additional header data pyicloud_ipd: Fix: Invalid Username/Password correctly caught now pyicloud_ipd: Fix: Changes in certain error responses now captured pyicloud_ipd: Fix: Bypass 2sv/trust when using 2SA Tests: Refactored authentication tests Tests: Refactored two_step_auth tests (TODO: Add 2FA tests) Tests: Updated/Created additional VCRs for auth tests * Tests: authentication and two_step_auth tests now pass * icloudpd: Fix: Correct exception reference for API error pyicloud_ipd: Fix: Correct exception reference for API and NoStoredPassword errors Tests: Refactor: All remaining tests now pass Tests: Refactor: Update corresponding VCRs for new auth flow Tests: Cookie/Session files stored in individual test fixtures for running tests independently * icloudpd: Fix: Update exception reference icloudpd: Style: Format update (scripts/format) * fix: Update pyicloud_ipd/cmdline.py to use 2FA (in addition to 2SA) docs: Update CHANGELOG.md and README.md
1 parent a3d351f commit 87d3394

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+5045
-2652
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
## Unreleased
44

55
- fix: macos binary failing [#668](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/668) [#700](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/700)
6+
- fix: 'Invalid email/password combination' exception due to recent iCloud changes [#729](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/729)
7+
- feature: `--auth-only` parameter to independently create/validate session tokens without listing/downloading photos
8+
- feature: 2FA validation merged from `pyicloud`
69

710
## 1.16.3 (2023-12-04)
811

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ There are three ways to run `icloudpd`:
2525
- One time download and an option to monitor for iCloud changes continuously (`--watch-with-interval` option)
2626
- Optimizations for incremental runs (`--until-found` and `--recent` options)
2727
- Photo meta data (EXIF) updates (`--set-exif-datetime` option)
28-
- ... and many (use `--help` option to get full list)
28+
- ... and many more (use `--help` option to get full list)
2929

3030
## Experimental Mode
3131

@@ -39,7 +39,15 @@ To keep your iCloud photo collection synchronized to your local system:
3939
icloudpd --directory /data --username my@email.address --watch-with-interval 3600
4040
```
4141

42-
Synchronization logic can be adjusted with command-line parameters. Run `icloudpd --help` to get full list.
42+
- Synchronization logic can be adjusted with command-line parameters. Run `icloudpd --help` to get full list.
43+
44+
To independently create and authorize a session (and complete 2SA/2FA validation if needed) on your local system:
45+
46+
```
47+
icloudpd --username my@email.address --password my_password --auth-only
48+
```
49+
50+
- This feature can also be used to check and verify that the session is still authenticated.
4351

4452
## FAQ
4553

src/icloudpd/authentication.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,26 @@ def authenticate_(
3535
client_id=client_id,
3636
)
3737
break
38-
except pyicloud_ipd.exceptions.NoStoredPasswordAvailable:
38+
except pyicloud_ipd.exceptions.PyiCloudNoStoredPasswordAvailableException:
3939
# Prompt for password if not stored in PyiCloud's keyring
4040
password = click.prompt("iCloud Password", hide_input=True)
4141

42-
if icloud.requires_2sa:
42+
if icloud.requires_2fa:
4343
if raise_error_on_2sa:
4444
raise TwoStepAuthRequiredError(
4545
"Two-step/two-factor authentication is required"
4646
)
47-
logger.info("Two-step/two-factor authentication is required")
47+
logger.info("Two-step/two-factor authentication is required (2fa)")
48+
request_2fa(icloud, logger)
49+
50+
elif icloud.requires_2sa:
51+
if raise_error_on_2sa:
52+
raise TwoStepAuthRequiredError(
53+
"Two-step/two-factor authentication is required"
54+
)
55+
logger.info("Two-step/two-factor authentication is required (2sa)")
4856
request_2sa(icloud, logger)
57+
4958
return icloud
5059
return authenticate_
5160

@@ -65,25 +74,17 @@ def request_2sa(icloud: pyicloud_ipd.PyiCloudService, logger: logging.Logger):
6574
device.get("phoneNumber"))))
6675
# pylint: enable-msg=consider-using-f-string
6776

68-
# pylint: disable-msg=superfluous-parens
69-
print(f" {devices_count}: Enter two-factor authentication code")
70-
# pylint: enable-msg=superfluous-parens
7177
device_index = click.prompt(
7278
"Please choose an option:",
7379
default=0,
7480
type=click.IntRange(
7581
0,
76-
devices_count))
82+
devices_count - 1))
7783

78-
if device_index == devices_count:
79-
# We're using the 2FA code that was automatically sent to the user's device,
80-
# so can just use an empty dict()
81-
device = {}
82-
else:
83-
device = devices[device_index]
84-
if not icloud.send_verification_code(device):
85-
logger.error("Failed to send two-factor authentication code")
86-
sys.exit(1)
84+
device = devices[device_index]
85+
if not icloud.send_verification_code(device):
86+
logger.error("Failed to send two-factor authentication code")
87+
sys.exit(1)
8788

8889
code = click.prompt("Please enter two-factor authentication code")
8990
if not icloud.validate_verification_code(device, code):
@@ -96,3 +97,18 @@ def request_2sa(icloud: pyicloud_ipd.PyiCloudService, logger: logging.Logger):
9697
"the two-step authentication expires.\n"
9798
"(Use --help to view information about SMTP options.)"
9899
)
100+
101+
102+
def request_2fa(icloud: pyicloud_ipd.PyiCloudService, logger: logging.Logger):
103+
"""Request two-factor authentication."""
104+
code = click.prompt("Please enter two-factor authentication code")
105+
if not icloud.validate_2fa_code(code):
106+
logger.error("Failed to verify two-factor authentication code")
107+
sys.exit(1)
108+
logger.info(
109+
"Great, you're all set up. The script can now be run without "
110+
"user interaction until 2SA expires.\n"
111+
"You can set up email notifications for when "
112+
"the two-step authentication expires.\n"
113+
"(Use --help to view information about SMTP options.)"
114+
)

src/icloudpd/base.py

Lines changed: 52 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from tzlocal import get_localzone
2020
from pyicloud_ipd import PyiCloudService
2121

22-
from pyicloud_ipd.exceptions import PyiCloudAPIResponseError
22+
from pyicloud_ipd.exceptions import PyiCloudAPIResponseException
2323
from pyicloud_ipd.services.photos import PhotoAsset
2424

2525
from icloudpd.authentication import authenticator, TwoStepAuthRequiredError
@@ -55,6 +55,11 @@
5555
"(default: use PyiCloud keyring or prompt for password)",
5656
metavar="<password>",
5757
)
58+
@click.option(
59+
"--auth-only",
60+
help="Create/Update cookie and session tokens only.",
61+
is_flag=True,
62+
)
5863
@click.option(
5964
"--cookie-directory",
6065
help="Directory to store cookies for authentication "
@@ -136,13 +141,12 @@
136141
+ "(Does not download or delete any files.)",
137142
is_flag=True,
138143
)
139-
@click.option(
140-
"--folder-structure",
141-
help="Folder structure (default: {:%Y/%m/%d}). "
142-
"If set to 'none' all photos will just be placed into the download directory",
143-
metavar="<folder_structure>",
144-
default="{:%Y/%m/%d}",
145-
)
144+
@click.option("--folder-structure",
145+
help="Folder structure (default: {:%Y/%m/%d}). "
146+
"If set to 'none' all photos will just be placed into the download directory",
147+
metavar="<folder_structure>",
148+
default="{:%Y/%m/%d}",
149+
)
146150
@click.option(
147151
"--set-exif-datetime",
148152
help="Write the DateTimeOriginal exif tag from file creation date, " +
@@ -235,14 +239,16 @@
235239
is_flag=True,
236240
default=False,
237241
)
238-
# a hacky way to get proper version because automatic detection does not work for some reason
242+
# a hacky way to get proper version because automatic detection does not
243+
# work for some reason
239244
@click.version_option(version="1.16.3")
240245
# pylint: disable-msg=too-many-arguments,too-many-statements
241246
# pylint: disable-msg=too-many-branches,too-many-locals
242247
def main(
243248
directory: Optional[str],
244249
username: Optional[str],
245250
password: Optional[str],
251+
auth_only: bool,
246252
cookie_directory: str,
247253
size: str,
248254
live_photo_size: str,
@@ -299,15 +305,17 @@ def main(
299305
with logging_redirect_tqdm():
300306

301307
# check required directory param only if not list albums
302-
if not list_albums and not list_libraries and not directory:
303-
print('--directory, --list-libraries or --list-albums are required')
308+
if not list_albums and not list_libraries and not directory and not auth_only:
309+
print(
310+
'--auth-only, --directory, --list-libraries or --list-albums are required')
304311
sys.exit(2)
305312

306313
if auto_delete and delete_after_download:
307314
print('--auto-delete and --delete-after-download are mutually exclusive')
308315
sys.exit(2)
309316

310-
if watch_with_interval and (list_albums or only_print_filenames): # pragma: no cover
317+
if watch_with_interval and (
318+
list_albums or only_print_filenames): # pragma: no cover
311319
print(
312320
'--watch_with_interval is not compatible with --list_albums, --only_print_filenames'
313321
)
@@ -326,10 +334,13 @@ def main(
326334
set_exif_datetime,
327335
skip_live_photos,
328336
live_photo_size,
329-
dry_run) if directory is not None else (lambda _s: lambda _c, _p: False),
337+
dry_run) if directory is not None else (
338+
lambda _s: lambda _c,
339+
_p: False),
330340
directory,
331341
username,
332342
password,
343+
auth_only,
333344
cookie_directory,
334345
size,
335346
recent,
@@ -355,9 +366,7 @@ def main(
355366
domain,
356367
logger,
357368
watch_with_interval,
358-
dry_run
359-
)
360-
)
369+
dry_run))
361370

362371

363372
# pylint: disable-msg=too-many-arguments,too-many-statements
@@ -377,7 +386,8 @@ def download_builder(
377386
live_photo_size: str,
378387
dry_run: bool) -> Callable[[PyiCloudService], Callable[[Counter, PhotoAsset], bool]]:
379388
"""factory for downloader"""
380-
def state_(icloud: PyiCloudService) -> Callable[[Counter, PhotoAsset], bool]:
389+
def state_(
390+
icloud: PyiCloudService) -> Callable[[Counter, PhotoAsset], bool]:
381391
def download_photo_(counter: Counter, photo: PhotoAsset) -> bool:
382392
"""internal function for actually downloading the photos"""
383393
filename = clean_filename(photo.filename)
@@ -507,17 +517,14 @@ def download_photo_(counter: Counter, photo: PhotoAsset) -> bool:
507517
)
508518

509519
download_result = download.download_media(
510-
logger, dry_run, icloud, photo, download_path, download_size
511-
)
520+
logger, dry_run, icloud, photo, download_path, download_size)
512521
success = download_result
513522

514523
if download_result:
515-
if not dry_run and \
516-
set_exif_datetime and \
517-
clean_filename(photo.filename) \
518-
.lower() \
519-
.endswith((".jpg", ".jpeg")) and \
520-
not exif_datetime.get_photo_exif(logger, download_path):
524+
if not dry_run and set_exif_datetime and clean_filename(
525+
photo.filename) .lower() .endswith(
526+
(".jpg", ".jpeg")) and not exif_datetime.get_photo_exif(
527+
logger, download_path):
521528
# %Y:%m:%d looks wrong, but it's the correct format
522529
date_str = created_date.strftime(
523530
"%Y-%m-%d %H:%M:%S%z")
@@ -582,8 +589,7 @@ def download_photo_(counter: Counter, photo: PhotoAsset) -> bool:
582589
truncated_path
583590
)
584591
download_result = download.download_media(
585-
logger, dry_run, icloud, photo, lp_download_path, lp_size
586-
)
592+
logger, dry_run, icloud, photo, lp_download_path, lp_size)
587593
success = download_result and success
588594
if download_result:
589595
logger.info(
@@ -595,7 +601,10 @@ def download_photo_(counter: Counter, photo: PhotoAsset) -> bool:
595601
return state_
596602

597603

598-
def delete_photo(logger: logging.Logger, icloud: PyiCloudService, photo: PhotoAsset):
604+
def delete_photo(
605+
logger: logging.Logger,
606+
icloud: PyiCloudService,
607+
photo: PhotoAsset):
599608
"""Delete a photo from the iCloud account."""
600609
clean_filename_local = clean_filename(photo.filename)
601610
logger.debug(
@@ -626,7 +635,10 @@ def delete_photo(logger: logging.Logger, icloud: PyiCloudService, photo: PhotoAs
626635
"Deleted %s in iCloud", clean_filename_local)
627636

628637

629-
def delete_photo_dry_run(logger: logging.Logger, _icloud: PyiCloudService, photo: PhotoAsset):
638+
def delete_photo_dry_run(
639+
logger: logging.Logger,
640+
_icloud: PyiCloudService,
641+
photo: PhotoAsset):
630642
"""Dry run for deleting a photo from the iCloud"""
631643
logger.info(
632644
"[DRY RUN] Would delete %s in iCloud",
@@ -706,6 +718,7 @@ def core(
706718
directory: Optional[str],
707719
username: Optional[str],
708720
password: Optional[str],
721+
auth_only: bool,
709722
cookie_directory: str,
710723
size: str,
711724
recent: Optional[int],
@@ -764,6 +777,10 @@ def core(
764777
)
765778
return 1
766779

780+
if auth_only:
781+
logger.info("Authentication completed successfully")
782+
return 0
783+
767784
download_photo = downloader(icloud)
768785

769786
# Access to the selected library. Defaults to the primary photos object.
@@ -788,7 +805,7 @@ def core(
788805
logger.error("Unknown library: %s", library)
789806
return 1
790807
photos = library_object.albums[album]
791-
except PyiCloudAPIResponseError as err:
808+
except PyiCloudAPIResponseException as err:
792809
# For later: come up with a nicer message to the user. For now take the
793810
# exception text
794811
logger.error("error?? %s", err)
@@ -816,8 +833,8 @@ def core(
816833
logger, icloud)
817834
internal_error_handler = internal_error_handle_builder(logger)
818835

819-
error_handler = compose_handlers([session_exception_handler, internal_error_handler
820-
])
836+
error_handler = compose_handlers(
837+
[session_exception_handler, internal_error_handler])
821838

822839
photos.exception_handler = error_handler
823840

@@ -863,7 +880,7 @@ def core(
863880
video_suffix = " and videos" if not skip_videos else ""
864881
logger.info(
865882
("Downloading %s %s" +
866-
" photo%s%s to %s ..."),
883+
" photo%s%s to %s ..."),
867884
photos_count_str,
868885
size,
869886
plural_suffix,
@@ -883,8 +900,7 @@ def should_break(counter: Counter) -> bool:
883900
if should_break(consecutive_files_found):
884901
logger.info(
885902
"Found %s consecutive previously downloaded photos. Exiting",
886-
until_found
887-
)
903+
until_found)
888904
break
889905
item = next(photos_iterator)
890906
if download_photo(
@@ -907,7 +923,7 @@ def delete_cmd():
907923

908924
if auto_delete:
909925
autodelete_photos(logger, dry_run, library_object,
910-
folder_structure, directory)
926+
folder_structure, directory)
911927

912928
if watch_interval: # pragma: no cover
913929
logger.info(f"Waiting for {watch_interval} sec...")

src/icloudpd/download.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
import time
77
import datetime
88
from tzlocal import get_localzone
9-
from requests.exceptions import ConnectionError # pylint: disable=redefined-builtin
9+
from requests.exceptions import ConnectionError # pylint: disable=redefined-builtin
1010
import pyicloud_ipd # pylint: disable=redefined-builtin
11-
from pyicloud_ipd.exceptions import PyiCloudAPIResponseError
11+
from pyicloud_ipd.exceptions import PyiCloudAPIResponseException
1212
from pyicloud_ipd.services.photos import PhotoAsset
1313

1414
# Import the constants object so that we can mock WAIT_SECONDS in tests
@@ -52,7 +52,9 @@ def mkdirs_for_path(logger: logging.Logger, download_path: str) -> bool:
5252
return False
5353

5454

55-
def mkdirs_for_path_dry_run(logger: logging.Logger, download_path: str) -> bool:
55+
def mkdirs_for_path_dry_run(
56+
logger: logging.Logger,
57+
download_path: str) -> bool:
5658
""" DRY Run for Creating hierarchy of folders for file path """
5759
download_dir = os.path.dirname(download_path)
5860
if not os.path.exists(download_dir):
@@ -123,7 +125,7 @@ def download_media(
123125
)
124126
break
125127

126-
except (ConnectionError, socket.timeout, PyiCloudAPIResponseError) as ex:
128+
except (ConnectionError, socket.timeout, PyiCloudAPIResponseException) as ex:
127129
if "Invalid global session" in str(ex):
128130
logger.error(
129131
"Session error, re-authenticating...")

src/icloudpd/email_notifications.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def send_2sa_notification(
1616
smtp_port: int,
1717
smtp_no_tls: bool,
1818
to_addr: Optional[str],
19-
from_addr: Optional[str]=None):
19+
from_addr: Optional[str] = None):
2020
"""Send an email notification when 2SA is expired"""
2121
to_addr = cast(str, to_addr if to_addr is not None else smtp_email)
2222
from_addr = from_addr if from_addr is not None else (

0 commit comments

Comments
 (0)