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.
This commit is contained in:
Tor Arne Vestbø 2016-02-24 00:18:56 +01:00
parent c3da6220ef
commit 7ec72a1625
3 changed files with 102 additions and 6 deletions

View file

@ -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 >>> 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 Devices
======= =======

View file

@ -12,7 +12,8 @@ from re import match
from pyicloud.exceptions import ( from pyicloud.exceptions import (
PyiCloudFailedLoginException, PyiCloudFailedLoginException,
PyiCloudAPIResponseError PyiCloudAPIResponseError,
PyiCloud2FARequiredError
) )
from pyicloud.services import ( from pyicloud.services import (
FindMyiPhoneServiceManager, FindMyiPhoneServiceManager,
@ -46,7 +47,8 @@ class PyiCloudPasswordFilter(logging.Filter):
class PyiCloudSession(requests.Session): class PyiCloudSession(requests.Session):
def __init__(self): def __init__(self, service):
self.service = service
super(PyiCloudSession, self).__init__() super(PyiCloudSession, self).__init__()
def request(self, *args, **kwargs): def request(self, *args, **kwargs):
@ -76,6 +78,10 @@ class PyiCloudSession(requests.Session):
code = json.get('errorCode') code = json.get('errorCode')
if reason: if reason:
if self.service.requires_2fa and \
reason == 'Missing X-APPLE-WEBAUTH-TOKEN cookie':
raise PyiCloud2FARequiredError(response.url)
api_error = PyiCloudAPIResponseError(reason, code) api_error = PyiCloudAPIResponseError(reason, code)
logger.error(api_error) logger.error(api_error)
raise api_error raise api_error
@ -99,7 +105,7 @@ class PyiCloudService(object):
if password is None: if password is None:
password = get_password_from_keyring(apple_id) password = get_password_from_keyring(apple_id)
self.discovery = None self.data = {}
self.client_id = str(uuid.uuid1()).upper() self.client_id = str(uuid.uuid1()).upper()
self.user = {'apple_id': apple_id, 'password': password} self.user = {'apple_id': apple_id, 'password': password}
logger.addFilter(PyiCloudPasswordFilter(password)) logger.addFilter(PyiCloudPasswordFilter(password))
@ -119,7 +125,7 @@ class PyiCloudService(object):
'pyicloud', 'pyicloud',
) )
self.session = PyiCloudSession() self.session = PyiCloudSession(self)
self.session.verify = verify self.session.verify = verify
self.session.headers.update({ self.session.headers.update({
'Origin': self._home_endpoint, 'Origin': self._home_endpoint,
@ -173,8 +179,8 @@ class PyiCloudService(object):
os.mkdir(self._cookie_directory) os.mkdir(self._cookie_directory)
self.session.cookies.save() self.session.cookies.save()
self.discovery = resp self.data = resp
self.webservices = self.discovery['webservices'] self.webservices = self.data['webservices']
logger.info("Authentication completed successfully") logger.info("Authentication completed successfully")
logger.debug(self.params) 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)]) ''.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 @property
def devices(self): def devices(self):
""" Return all devices.""" """ Return all devices."""

View file

@ -22,5 +22,15 @@ class PyiCloudFailedLoginException(PyiCloudException):
pass 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): class NoStoredPasswordAvailable(PyiCloudException):
pass pass