diff --git a/README.rst b/README.rst index 537a286..2958d8e 100644 --- a/README.rst +++ b/README.rst @@ -36,17 +36,19 @@ If you would like to delete a password stored in your system keyring, you can cl >>> icloud --username=jappleseed@apple.com --delete-from-keyring -******************************* -Two-factor authentication (2FA) -******************************* +**Note**: Authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months. -If you have enabled two-factor authentication for the account you will have to do some extra work: +************************************************ +Two-step and two-factor authentication (2SA/2FA) +************************************************ + +If you have enabled `two-step authentication (2SA) `_ for the account you will have to do some extra work: .. code-block:: python - if api.requires_2fa: + if api.requires_2sa: import click - print "Two-factor authentication required. Your trusted devices are:" + print "Two-step authentication required. Your trusted devices are:" devices = api.trusted_devices for i, device in enumerate(devices): @@ -64,7 +66,8 @@ If you have enabled two-factor authentication for the account you will have to d print "Failed to verify verification code" sys.exit(1) -Note: Both regular login and two-factor authentication will expire after an interval set by Apple, at which point you will have to re-authenticate. This interval is currently two months. +This approach also works if the account is set up for `two-factor authentication (2FA) `_, but the authentication will time out after a few hours. Full support for two-factor authentication (2FA) is not implemented in PyiCloud yet. See issue `#102 `_. + ======= Devices diff --git a/pyicloud/base.py b/pyicloud/base.py index 2e4e86f..5c06654 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -13,7 +13,7 @@ from re import match from pyicloud.exceptions import ( PyiCloudFailedLoginException, PyiCloudAPIResponseError, - PyiCloud2FARequiredError + PyiCloud2SARequiredError ) from pyicloud.services import ( FindMyiPhoneServiceManager, @@ -100,7 +100,7 @@ class PyiCloudSession(requests.Session): def _raise_error(self, code, reason): if self.service.requires_2fa and \ reason == 'Missing X-APPLE-WEBAUTH-TOKEN cookie': - raise PyiCloud2FARequiredError(response.url) + raise PyiCloud2SARequiredError(response.url) api_error = PyiCloudAPIResponseError(reason, code) logger.error(api_error) @@ -217,13 +217,15 @@ class PyiCloudService(object): ) @property - def requires_2fa(self): - """ Returns True if two-factor authentication is required.""" - return self.data.get('hsaChallengeRequired', False) + def requires_2sa(self): + """ Returns True if two-step authentication is required.""" + return self.data.get('hsaChallengeRequired', False) \ + and self.data['dsInfo'].get('hsaVersion', 0) >= 1 + # FIXME: Implement 2FA for hsaVersion == 2 @property def trusted_devices(self): - """ Returns devices trusted for two-factor authentication.""" + """ Returns devices trusted for two-step authentication.""" request = self.session.get( '%s/listDevices' % self._setup_endpoint, params=self.params @@ -241,7 +243,7 @@ class PyiCloudService(object): return request.json().get('success', False) def validate_verification_code(self, device, code): - """ Verifies a verification code received on a two-factor device""" + """ Verifies a verification code received on a trusted device""" device.update({ 'verificationCode': code, 'trustBrowser': True @@ -260,11 +262,11 @@ class PyiCloudService(object): return False raise - # Re-authenticate, which will both update the 2FA data, and + # Re-authenticate, which will both update the HSA data, and # ensure that we save the X-APPLE-WEBAUTH-HSA-TRUST cookie. self.authenticate() - return not self.requires_2fa + return not self.requires_2sa @property def devices(self): diff --git a/pyicloud/cmdline.py b/pyicloud/cmdline.py index 91c7953..6e9c155 100644 --- a/pyicloud/cmdline.py +++ b/pyicloud/cmdline.py @@ -207,6 +207,31 @@ def main(args=None): confirm("Save password in keyring? ") ): utils.store_password_in_keyring(username, password) + + if api.requires_2sa: + import click + print("Two-step authentication required.", + "Your trusted devices are:") + + devices = api.trusted_devices + for i, device in enumerate(devices): + print(" %s: %s" % ( + i, device.get( + 'deviceName', + "SMS to %s" % device.get('phoneNumber')))) + + device = click.prompt('Which device would you like to use?', + default=0) + device = devices[device] + if not api.send_verification_code(device): + print("Failed to send verification code") + sys.exit(1) + + code = click.prompt('Please enter validation code') + if not api.validate_verification_code(device, code): + print("Failed to verify verification code") + sys.exit(1) + break except pyicloud.exceptions.PyiCloudFailedLoginException: # If they have a stored password; we just used it and diff --git a/pyicloud/exceptions.py b/pyicloud/exceptions.py index d33d5a3..b0eeef6 100644 --- a/pyicloud/exceptions.py +++ b/pyicloud/exceptions.py @@ -22,10 +22,10 @@ class PyiCloudFailedLoginException(PyiCloudException): pass -class PyiCloud2FARequiredError(PyiCloudException): +class PyiCloud2SARequiredError(PyiCloudException): def __init__(self, url): - message = "Two-factor authentication required for %s" % url - super(PyiCloud2FARequiredError, self).__init__(message) + message = "Two-step authentication required for %s" % url + super(PyiCloud2SARequiredError, self).__init__(message) class PyiCloudNoDevicesException(Exception):