Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restore Support for SMS MFA #807

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- fix: restore support for SMS MFA [#803](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/803)

## 1.17.3 (2024-01-03)

- improve compatibility for diffeent platforms [#748](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/748)
Expand Down
273 changes: 159 additions & 114 deletions src/icloudpd/authentication.py
Original file line number Diff line number Diff line change
@@ -1,114 +1,159 @@
"""Handles username/password authentication and two-step authentication"""

import logging
import sys
import click
import pyicloud_ipd


class TwoStepAuthRequiredError(Exception):
"""
Raised when 2SA is required. base.py catches this exception
and sends an email notification.
"""


def authenticator(logger: logging.Logger, domain: str):
"""Wraping authentication with domain context"""
def authenticate_(
username,
password,
cookie_directory=None,
raise_error_on_2sa=False,
client_id=None,
) -> pyicloud_ipd.PyiCloudService:
"""Authenticate with iCloud username and password"""
logger.debug("Authenticating...")
while True:
try:
# If password not provided on command line variable will be set to None
# and PyiCloud will attempt to retrieve from its keyring
icloud = pyicloud_ipd.PyiCloudService(
domain,
username, password,
cookie_directory=cookie_directory,
client_id=client_id,
)
break
except pyicloud_ipd.exceptions.PyiCloudNoStoredPasswordAvailableException:
# Prompt for password if not stored in PyiCloud's keyring
password = click.prompt("iCloud Password", hide_input=True)

if icloud.requires_2fa:
if raise_error_on_2sa:
raise TwoStepAuthRequiredError(
"Two-step/two-factor authentication is required"
)
logger.info("Two-step/two-factor authentication is required (2fa)")
request_2fa(icloud, logger)

elif icloud.requires_2sa:
if raise_error_on_2sa:
raise TwoStepAuthRequiredError(
"Two-step/two-factor authentication is required"
)
logger.info("Two-step/two-factor authentication is required (2sa)")
request_2sa(icloud, logger)

return icloud
return authenticate_


def request_2sa(icloud: pyicloud_ipd.PyiCloudService, logger: logging.Logger):
"""Request two-step authentication. Prompts for SMS or device"""
devices = icloud.trusted_devices
devices_count = len(devices)
device_index = 0
if devices_count > 0:
for i, device in enumerate(devices):
# pylint: disable-msg=consider-using-f-string
print(
" %s: %s" %
(i, device.get(
"deviceName", "SMS to %s" %
device.get("phoneNumber"))))
# pylint: enable-msg=consider-using-f-string

device_index = click.prompt(
"Please choose an option:",
default=0,
type=click.IntRange(
0,
devices_count - 1))

device = devices[device_index]
if not icloud.send_verification_code(device):
logger.error("Failed to send two-factor authentication code")
sys.exit(1)

code = click.prompt("Please enter two-factor authentication code")
if not icloud.validate_verification_code(device, code):
logger.error("Failed to verify two-factor authentication code")
sys.exit(1)
logger.info(
"Great, you're all set up. The script can now be run without "
"user interaction until 2SA expires.\n"
"You can set up email notifications for when "
"the two-step authentication expires.\n"
"(Use --help to view information about SMTP options.)"
)


def request_2fa(icloud: pyicloud_ipd.PyiCloudService, logger: logging.Logger):
"""Request two-factor authentication."""
code = click.prompt("Please enter two-factor authentication code")
if not icloud.validate_2fa_code(code):
logger.error("Failed to verify two-factor authentication code")
sys.exit(1)
logger.info(
"Great, you're all set up. The script can now be run without "
"user interaction until 2SA expires.\n"
"You can set up email notifications for when "
"the two-step authentication expires.\n"
"(Use --help to view information about SMTP options.)"
)
"""Handles username/password authentication and two-step authentication"""

import logging
import sys
import click
import pyicloud_ipd


class TwoStepAuthRequiredError(Exception):
"""
Raised when 2SA is required. base.py catches this exception
and sends an email notification.
"""


def authenticator(logger: logging.Logger, domain: str):
"""Wraping authentication with domain context"""
def authenticate_(
username,
password,
cookie_directory=None,
raise_error_on_2sa=False,
client_id=None,
) -> pyicloud_ipd.PyiCloudService:
"""Authenticate with iCloud username and password"""
logger.debug("Authenticating...")
while True:
try:
# If password not provided on command line variable will be set to None
# and PyiCloud will attempt to retrieve from its keyring
icloud = pyicloud_ipd.PyiCloudService(
domain,
username, password,
cookie_directory=cookie_directory,
client_id=client_id,
)
break
except pyicloud_ipd.exceptions.PyiCloudNoStoredPasswordAvailableException:
# Prompt for password if not stored in PyiCloud's keyring
password = click.prompt("iCloud Password", hide_input=True)

if icloud.requires_2fa:
if raise_error_on_2sa:
raise TwoStepAuthRequiredError(
"Two-step/two-factor authentication is required"
)
logger.info("Two-step/two-factor authentication is required (2fa)")
request_2fa(icloud, logger)

elif icloud.requires_2sa:
if raise_error_on_2sa:
raise TwoStepAuthRequiredError(
"Two-step/two-factor authentication is required"
)
logger.info("Two-step/two-factor authentication is required (2sa)")
request_2sa(icloud, logger)

return icloud
return authenticate_


def request_2sa(icloud: pyicloud_ipd.PyiCloudService, logger: logging.Logger):
"""Request two-step authentication. Prompts for SMS or device"""
devices = icloud.trusted_devices
devices_count = len(devices)
device_index = 0
if devices_count > 0:
for i, device in enumerate(devices):
# pylint: disable-msg=consider-using-f-string
print(
" %s: %s" %
(i, device.get(
"deviceName", "SMS to %s" %
device.get("phoneNumber"))))
# pylint: enable-msg=consider-using-f-string

device_index = click.prompt(
"Please choose an option:",
default=0,
type=click.IntRange(
0,
devices_count - 1))

device = devices[device_index]
if not icloud.send_verification_code(device):
logger.error("Failed to send two-factor authentication code")
sys.exit(1)

code = click.prompt("Please enter two-factor authentication code")
if not icloud.validate_verification_code(device, code):
logger.error("Failed to verify two-factor authentication code")
sys.exit(1)
logger.info(
"Great, you're all set up. The script can now be run without "
"user interaction until 2SA expires.\n"
"You can set up email notifications for when "
"the two-step authentication expires.\n"
"(Use --help to view information about SMTP options.)"
)


def request_2fa(icloud: pyicloud_ipd.PyiCloudService, logger: logging.Logger):
"""Request two-factor authentication."""
devices = icloud.trusted_devices
if len(devices) > 0:
if len(devices) > 99:
logger.error("Too many trusted devices for authentication")
sys.exit(1)

for i, device in enumerate(devices):
# pylint: disable-msg=consider-using-f-string
print(
" %s: %s" %
(i, device.get(
"deviceName", "SMS to %s" %
device.get("phoneNumber"))))
# pylint: enable-msg=consider-using-f-string

index_str = f"..{len(devices) - 1}" if len(devices) > 1 else ""
code = click.prompt(
f"Please enter two-factor authentication code or device index (0{index_str}) to send SMS with a code",
type=click.IntRange(
0,
999999))

if code < 100:
# need to send code
device = devices[code]
if not icloud.send_verification_code(device):
logger.error("Failed to send two-factor authentication code")
sys.exit(1)
code = click.prompt(
"Please enter two-factor authentication code that you received over SMS",
type=click.IntRange(
0,
999999))
if not icloud.validate_verification_code(device, code):
logger.error("Failed to verify two-factor authentication code")
sys.exit(1)
else:
if not icloud.validate_2fa_code(code):
logger.error("Failed to verify two-factor authentication code")
sys.exit(1)
else:
code = click.prompt(
"Please enter two-factor authentication code",
type=click.IntRange(
0,
999999))
if not icloud.validate_2fa_code(code):
logger.error("Failed to verify two-factor authentication code")
sys.exit(1)
logger.info(
"Great, you're all set up. The script can now be run without "
"user interaction until 2SA expires.\n"
"You can set up email notifications for when "
"the two-step authentication expires.\n"
"(Use --help to view information about SMTP options.)"
)
61 changes: 29 additions & 32 deletions src/icloudpd/base.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,38 @@
#!/usr/bin/env python
"""Main script that uses Click to parse command-line arguments"""
from __future__ import print_function
from multiprocessing import freeze_support
freeze_support() # fixing tqdm on macos

import os
import sys
import time
import datetime
import logging
from logging import Logger
import itertools
import subprocess
import json
from typing import Callable, Optional, TypeVar, cast
import urllib
import click

from tqdm import tqdm
from tqdm.contrib.logging import logging_redirect_tqdm
from tzlocal import get_localzone
from icloudpd.counter import Counter
from icloudpd import constants
from icloudpd import exif_datetime
from icloudpd.paths import clean_filename, local_download_path
from icloudpd.autodelete import autodelete_photos
from icloudpd.string_helpers import truncate_middle
from icloudpd.email_notifications import send_2sa_notification
from icloudpd import download
from icloudpd.authentication import authenticator, TwoStepAuthRequiredError
from pyicloud_ipd.services.photos import PhotoAsset
from pyicloud_ipd.exceptions import PyiCloudAPIResponseException
from pyicloud_ipd import PyiCloudService
from tzlocal import get_localzone
from tqdm.contrib.logging import logging_redirect_tqdm
from tqdm import tqdm
import click
import urllib
from typing import Callable, Optional, TypeVar, cast
import json
import subprocess
import itertools
from logging import Logger
import logging
import datetime
import time
import sys
import os
from multiprocessing import freeze_support
freeze_support() # fixing tqdm on macos

from pyicloud_ipd.exceptions import PyiCloudAPIResponseException
from pyicloud_ipd.services.photos import PhotoAsset

from icloudpd.authentication import authenticator, TwoStepAuthRequiredError
from icloudpd import download
from icloudpd.email_notifications import send_2sa_notification
from icloudpd.string_helpers import truncate_middle
from icloudpd.autodelete import autodelete_photos
from icloudpd.paths import clean_filename, local_download_path
from icloudpd import exif_datetime
# Must import the constants object so that we can mock values in tests.
from icloudpd import constants
from icloudpd.counter import Counter

CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}

Expand Down Expand Up @@ -441,8 +439,7 @@ def download_photo_(counter: Counter, photo: PhotoAsset) -> bool:
versions = photo.versions
except KeyError as ex:
print(
f"KeyError: {ex} attribute was not found in the photo fields."
)
f"KeyError: {ex} attribute was not found in the photo fields.")
with open(file='icloudpd-photo-error.json', mode='w', encoding='utf8') as outfile:
# pylint: disable=protected-access
json.dump({
Expand Down
Loading
Loading