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:
parent
c3da6220ef
commit
7ec72a1625
3 changed files with 102 additions and 6 deletions
30
README.rst
30
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
|
>>> 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
|
||||||
=======
|
=======
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue