From 7ec72a16259e6e810eaa847e91c110d901221159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tor=20Arne=20Vestb=C3=B8?= Date: Wed, 24 Feb 2016 00:18:56 +0100 Subject: [PATCH] Add support for two-factor authentication When 2FA is enabled in iCloud most iCloud services are unavailable without first going through the 2FA handshake. We now have API to initiate the 2FA, which can be used by more advanced API clients. The built in command line 'icloud' application has not been updated, as listing and managing devices though Find my iPhone is one of the services that do not require 2FA. Fixes issue #66. --- README.rst | 30 +++++++++++++++++++ pyicloud/base.py | 68 ++++++++++++++++++++++++++++++++++++++---- pyicloud/exceptions.py | 10 +++++++ 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 327a703..13c82a1 100644 --- a/README.rst +++ b/README.rst @@ -36,6 +36,36 @@ 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) +******************************* + +If you have enabled two-factor authentication for the account you will have to do some extra work: + +.. code-block:: python + + if icloud.requires_2fa: + print "Two-factor authentication required. Your trusted devices are:" + + devices = icloud.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 icloud.send_verification_code(device): + print "Failed to send verification code" + sys.exit(1) + + code = click.prompt('Please enter validation code') + if not icloud.validate_verification_code(device, code): + 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. +>>>>>>> Add support for two-factor authentication + ======= Devices ======= diff --git a/pyicloud/base.py b/pyicloud/base.py index 2630ef7..8bf1e5c 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -12,7 +12,8 @@ from re import match from pyicloud.exceptions import ( PyiCloudFailedLoginException, - PyiCloudAPIResponseError + PyiCloudAPIResponseError, + PyiCloud2FARequiredError ) from pyicloud.services import ( FindMyiPhoneServiceManager, @@ -46,7 +47,8 @@ class PyiCloudPasswordFilter(logging.Filter): class PyiCloudSession(requests.Session): - def __init__(self): + def __init__(self, service): + self.service = service super(PyiCloudSession, self).__init__() def request(self, *args, **kwargs): @@ -76,6 +78,10 @@ class PyiCloudSession(requests.Session): code = json.get('errorCode') if reason: + if self.service.requires_2fa and \ + reason == 'Missing X-APPLE-WEBAUTH-TOKEN cookie': + raise PyiCloud2FARequiredError(response.url) + api_error = PyiCloudAPIResponseError(reason, code) logger.error(api_error) raise api_error @@ -99,7 +105,7 @@ class PyiCloudService(object): if password is None: password = get_password_from_keyring(apple_id) - self.discovery = None + self.data = {} self.client_id = str(uuid.uuid1()).upper() self.user = {'apple_id': apple_id, 'password': password} logger.addFilter(PyiCloudPasswordFilter(password)) @@ -119,7 +125,7 @@ class PyiCloudService(object): 'pyicloud', ) - self.session = PyiCloudSession() + self.session = PyiCloudSession(self) self.session.verify = verify self.session.headers.update({ 'Origin': self._home_endpoint, @@ -173,8 +179,8 @@ class PyiCloudService(object): os.mkdir(self._cookie_directory) self.session.cookies.save() - self.discovery = resp - self.webservices = self.discovery['webservices'] + self.data = resp + self.webservices = self.data['webservices'] logger.info("Authentication completed successfully") logger.debug(self.params) @@ -186,6 +192,56 @@ class PyiCloudService(object): ''.join([c for c in self.user.get('apple_id') if match(r'\w', c)]) ) + @property + def requires_2fa(self): + """ Returns True if two-factor authentication is required.""" + return self.data.get('hsaChallengeRequired', False) + + @property + def trusted_devices(self): + """ Returns devices trusted for two-factor authentication.""" + request = self.session.get( + '%s/listDevices' % self._setup_endpoint, + params=self.params + ) + return request.json().get('devices') + + def send_verification_code(self, device): + """ Requests that a verification code is sent to the given device""" + data = json.dumps(device) + request = self.session.post( + '%s/sendVerificationCode' % self._setup_endpoint, + params=self.params, + data=data + ) + return request.json().get('success', False) + + def validate_verification_code(self, device, code): + """ Verifies a verification code received on a two-factor device""" + device.update({ + 'verificationCode': code, + 'trustBrowser': True + }) + data = json.dumps(device) + + try: + request = self.session.post( + '%s/validateVerificationCode' % self._setup_endpoint, + params=self.params, + data=data + ) + except PyiCloudAPIResponseError, error: + if error.code == -21669: + # Wrong verification code + return False + raise + + # Re-authenticate, which will both update the 2FA data, and + # ensure that we save the X-APPLE-WEBAUTH-HSA-TRUST cookie. + self.authenticate() + + return not self.requires_2fa + @property def devices(self): """ Return all devices.""" diff --git a/pyicloud/exceptions.py b/pyicloud/exceptions.py index 098b344..487def4 100644 --- a/pyicloud/exceptions.py +++ b/pyicloud/exceptions.py @@ -22,5 +22,15 @@ class PyiCloudFailedLoginException(PyiCloudException): pass +class PyiCloud2FARequiredError(PyiCloudException): + def __init__(self, url): + message = "Two-factor authentication required for %s" % url + super(PyiCloud2FARequiredError, self).__init__(message) + + +class PyiCloudNoDevicesException(Exception): + pass + + class NoStoredPasswordAvailable(PyiCloudException): pass