diff --git a/README.rst b/README.rst index 4c99eae2..9eab3cf1 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,6 @@ +Authors: +This repository is taken from https://github.com/picklepete/pyicloud and https://github.com/chumachuma/iSync. + .. image:: https://travis-ci.org/picklepete/pyicloud.svg?branch=master :alt: Check out our test status at https://travis-ci.org/picklepete/pyicloud :target: https://travis-ci.org/picklepete/pyicloud diff --git a/pyicloud/base.py b/pyicloud/base.py deleted file mode 100644 index c740d1c2..00000000 --- a/pyicloud/base.py +++ /dev/null @@ -1,362 +0,0 @@ -import six -import uuid -import hashlib -import inspect -import json -import logging -import requests -import sys -import tempfile -import os -from re import match - -from pyicloud_ipd.exceptions import ( - PyiCloudFailedLoginException, - PyiCloudAPIResponseError, - PyiCloud2SARequiredError, - PyiCloudServiceNotActivatedErrror -) -from pyicloud_ipd.services import ( - FindMyiPhoneServiceManager, - CalendarService, - UbiquityService, - ContactsService, - RemindersService, - PhotosService, - AccountService -) -from pyicloud_ipd.utils import get_password_from_keyring - -if six.PY3: - import http.cookiejar as cookielib -else: - import cookielib - - -logger = logging.getLogger(__name__) - - -class PyiCloudPasswordFilter(logging.Filter): - def __init__(self, password): - self.password = password - - def filter(self, record): - message = record.getMessage() - if self.password in message: - record.msg = message.replace(self.password, "*" * 8) - record.args = [] - - return True - - -class PyiCloudSession(requests.Session): - def __init__(self, service): - self.service = service - super(PyiCloudSession, self).__init__() - - def request(self, *args, **kwargs): - - # Charge logging to the right service endpoint - callee = inspect.stack()[2] - module = inspect.getmodule(callee[0]) - logger = logging.getLogger(module.__name__).getChild('http') - if self.service._password_filter not in logger.filters: - logger.addFilter(self.service._password_filter) - - logger.debug("%s %s %s", args[0], args[1], kwargs.get('data', '')) - - response = super(PyiCloudSession, self).request(*args, **kwargs) - - content_type = response.headers.get('Content-Type', '').split(';')[0] - json_mimetypes = ['application/json', 'text/json'] - - if not response.ok and content_type not in json_mimetypes: - self._raise_error(response.status_code, response.reason) - - if content_type not in json_mimetypes: - return response - - try: - json = response.json() - except: - logger.warning('Failed to parse response with JSON mimetype') - return response - - logger.debug(json) - - reason = json.get('errorMessage') - reason = reason or json.get('reason') - reason = reason or json.get('errorReason') - if not reason and isinstance(json.get('error'), six.string_types): - reason = json.get('error') - if not reason and json.get('error'): - reason = "Unknown reason" - - code = json.get('errorCode') - if not code and json.get('serverErrorCode'): - code = json.get('serverErrorCode') - - if reason: - self._raise_error(code, reason) - - return response - - def _raise_error(self, code, reason): - if self.service.requires_2sa and \ - reason == 'Missing X-APPLE-WEBAUTH-TOKEN cookie': - raise PyiCloud2SARequiredError(response.url) - if code == 'ZONE_NOT_FOUND' or code == 'AUTHENTICATION_FAILED': - reason = 'Please log into https://icloud.com/ to manually ' \ - 'finish setting up your iCloud service' - api_error = PyiCloudServiceNotActivatedErrror(reason, code) - logger.error(api_error) - - raise(api_error) - if code == 'ACCESS_DENIED': - reason = reason + '. Please wait a few minutes then try ' \ - 'again. The remote servers might be trying to ' \ - 'throttle requests.' - - api_error = PyiCloudAPIResponseError(reason, code) - logger.error(api_error) - raise api_error - - -class PyiCloudService(object): - """ - A base authentication class for the iCloud service. Handles the - authentication required to access iCloud services. - - Usage: - from pyicloud_ipd import PyiCloudService - pyicloud = PyiCloudService('username@apple.com', 'password') - pyicloud_ipd.iphone.location() - """ - - def __init__( - self, apple_id, password=None, cookie_directory=None, verify=True, - client_id=None - ): - if password is None: - password = get_password_from_keyring(apple_id) - - self.data = {} - self.client_id = client_id or str(uuid.uuid1()).upper() - self.user = {'apple_id': apple_id, 'password': password} - - self._password_filter = PyiCloudPasswordFilter(password) - logger.addFilter(self._password_filter) - - self._home_endpoint = 'https://www.icloud.com' - self._setup_endpoint = 'https://setup.icloud.com/setup/ws/1' - - self._base_login_url = '%s/login' % self._setup_endpoint - - if cookie_directory: - self._cookie_directory = os.path.expanduser( - os.path.normpath(cookie_directory) - ) - else: - self._cookie_directory = os.path.join( - tempfile.gettempdir(), - 'pyicloud', - ) - - self.session = PyiCloudSession(self) - self.session.verify = verify - self.session.headers.update({ - 'Origin': self._home_endpoint, - 'Referer': '%s/' % self._home_endpoint, - 'User-Agent': 'Opera/9.52 (X11; Linux i686; U; en)' - }) - - cookiejar_path = self._get_cookiejar_path() - self.session.cookies = cookielib.LWPCookieJar(filename=cookiejar_path) - if os.path.exists(cookiejar_path): - try: - self.session.cookies.load() - logger.debug("Read cookies from %s", cookiejar_path) - except: - # Most likely a pickled cookiejar from earlier versions. - # The cookiejar will get replaced with a valid one after - # successful authentication. - logger.warning("Failed to read cookiejar %s", cookiejar_path) - - self.params = { - 'clientBuildNumber': '17DHotfix5', - 'clientMasteringNumber': '17DHotfix5', - 'ckjsBuildVersion': '17DProjectDev77', - 'ckjsVersion': '2.0.5', - 'clientId': self.client_id, - } - - self.authenticate() - - def authenticate(self): - """ - Handles authentication, and persists the X-APPLE-WEB-KB cookie so that - subsequent logins will not cause additional e-mails from Apple. - """ - - logger.info("Authenticating as %s", self.user['apple_id']) - - data = dict(self.user) - - # We authenticate every time, so "remember me" is not needed - data.update({'extended_login': False}) - - try: - req = self.session.post( - self._base_login_url, - params=self.params, - data=json.dumps(data) - ) - except PyiCloudAPIResponseError as error: - msg = 'Invalid email/password combination.' - raise PyiCloudFailedLoginException(msg, error) - - resp = req.json() - self.params.update({'dsid': resp['dsInfo']['dsid']}) - - if not os.path.exists(self._cookie_directory): - os.mkdir(self._cookie_directory) - self.session.cookies.save() - logger.debug("Cookies saved to %s", self._get_cookiejar_path()) - - self.data = resp - self.webservices = self.data['webservices'] - - logger.info("Authentication completed successfully") - logger.debug(self.params) - - def _get_cookiejar_path(self): - # Get path for cookiejar file - return os.path.join( - self._cookie_directory, - ''.join([c for c in self.user.get('apple_id') if match(r'\w', c)]) - ) - - @property - 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-step 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 trusted 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 as error: - if error.code == -21669: - # Wrong verification code - return False - raise - - # 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_2sa - - @property - def devices(self): - """ Return all devices.""" - service_root = self.webservices['findme']['url'] - return FindMyiPhoneServiceManager( - service_root, - self.session, - self.params - ) - - @property - def account(self): - service_root = self.webservices['account']['url'] - return AccountService( - service_root, - self.session, - self.params - ) - - @property - def iphone(self): - return self.devices[0] - - @property - def files(self): - if not hasattr(self, '_files'): - service_root = self.webservices['ubiquity']['url'] - self._files = UbiquityService( - service_root, - self.session, - self.params - ) - return self._files - - @property - def photos(self): - if not hasattr(self, '_photos'): - service_root = self.webservices['ckdatabasews']['url'] - self._photos = PhotosService( - service_root, - self.session, - self.params - ) - return self._photos - - @property - def calendar(self): - service_root = self.webservices['calendar']['url'] - return CalendarService(service_root, self.session, self.params) - - @property - def contacts(self): - service_root = self.webservices['contacts']['url'] - return ContactsService(service_root, self.session, self.params) - - @property - def reminders(self): - service_root = self.webservices['reminders']['url'] - return RemindersService(service_root, self.session, self.params) - - def __unicode__(self): - return 'iCloud API: %s' % self.user.get('apple_id') - - def __str__(self): - as_unicode = self.__unicode__() - if sys.version_info[0] >= 3: - return as_unicode - else: - return as_unicode.encode('ascii', 'ignore') - - def __repr__(self): - return '<%s>' % str(self) diff --git a/pyicloud_ipd/base.py b/pyicloud_ipd/base.py index c740d1c2..d77db6cd 100644 --- a/pyicloud_ipd/base.py +++ b/pyicloud_ipd/base.py @@ -9,6 +9,7 @@ import tempfile import os from re import match +from uuid import uuid1 as generateClientID from pyicloud_ipd.exceptions import ( PyiCloudFailedLoginException, @@ -64,7 +65,6 @@ def request(self, *args, **kwargs): logger.addFilter(self.service._password_filter) logger.debug("%s %s %s", args[0], args[1], kwargs.get('data', '')) - response = super(PyiCloudSession, self).request(*args, **kwargs) content_type = response.headers.get('Content-Type', '').split(';')[0] @@ -97,7 +97,9 @@ def request(self, *args, **kwargs): code = json.get('serverErrorCode') if reason: - self._raise_error(code, reason) + acceptable_reason = 'Missing X-APPLE-WEBAUTH-TOKEN cookie' + if reason != acceptable_reason: + self._raise_error(code, reason) return response @@ -105,13 +107,14 @@ def _raise_error(self, code, reason): if self.service.requires_2sa and \ reason == 'Missing X-APPLE-WEBAUTH-TOKEN cookie': raise PyiCloud2SARequiredError(response.url) + if code == 'ZONE_NOT_FOUND' or code == 'AUTHENTICATION_FAILED': reason = 'Please log into https://icloud.com/ to manually ' \ 'finish setting up your iCloud service' api_error = PyiCloudServiceNotActivatedErrror(reason, code) logger.error(api_error) - raise(api_error) + if code == 'ACCESS_DENIED': reason = reason + '. Please wait a few minutes then try ' \ 'again. The remote servers might be trying to ' \ @@ -119,7 +122,7 @@ def _raise_error(self, code, reason): api_error = PyiCloudAPIResponseError(reason, code) logger.error(api_error) - raise api_error + raise(api_error) class PyiCloudService(object): @@ -147,8 +150,11 @@ def __init__( self._password_filter = PyiCloudPasswordFilter(password) logger.addFilter(self._password_filter) - self._home_endpoint = 'https://www.icloud.com' + self.user_agent = 'Opera/9.52 (X11; Linux i686; U; en)' self._setup_endpoint = 'https://setup.icloud.com/setup/ws/1' + self.referer = 'https://www.icloud.com' + self.origin = 'https://www.icloud.com' + self.response = None self._base_login_url = '%s/login' % self._setup_endpoint @@ -165,9 +171,9 @@ def __init__( self.session = PyiCloudSession(self) self.session.verify = verify self.session.headers.update({ - 'Origin': self._home_endpoint, - 'Referer': '%s/' % self._home_endpoint, - 'User-Agent': 'Opera/9.52 (X11; Linux i686; U; en)' + 'Origin': self.referer, + 'Referer': '%s/' % self.referer, + 'User-Agent': self.user_agent }) cookiejar_path = self._get_cookiejar_path() @@ -190,8 +196,19 @@ def __init__( 'clientId': self.client_id, } + self.clientID = self.generateClientID() + self.setupiCloud = SetupiCloudService(self) + self.idmsaApple = IdmsaAppleService(self) self.authenticate() + def get_session_token(self): + self.clientID = self.generateClientID() + widgetKey = self.setupiCloud.requestAppleWidgetKey(self.clientID) + return self.idmsaApple.requestAppleSessionToken(self.user['apple_id'], + self.user['password'], + widgetKey + ) + def authenticate(self): """ Handles authentication, and persists the X-APPLE-WEB-KB cookie so that @@ -202,13 +219,16 @@ def authenticate(self): data = dict(self.user) - # We authenticate every time, so "remember me" is not needed - data.update({'extended_login': False}) + sess_token = self.get_session_token() + data = { + 'accountCountryCode': "GBR", + 'extended_login': False, + 'dsWebAuthToken': sess_token + } try: req = self.session.post( - self._base_login_url, - params=self.params, + self._setup_endpoint + '/accountLogin', data=json.dumps(data) ) except PyiCloudAPIResponseError as error: @@ -229,6 +249,9 @@ def authenticate(self): logger.info("Authentication completed successfully") logger.debug(self.params) + def generateClientID(self): + return str(generateClientID()).upper() + def _get_cookiejar_path(self): # Get path for cookiejar file return os.path.join( @@ -360,3 +383,157 @@ def __str__(self): def __repr__(self): return '<%s>' % str(self) + + +class HTTPService: + def __init__(self, session, response=None, origin=None, referer=None): + try: + self.session = session.session + self.response = session.response + self.origin = session.origin + self.referer = session.referer + self.user_agent = session.user_agent + except: + session = session + self.response = response + self.origin = origin + self.referer = referer + self.user_agent = "Python (X11; Linux x86_64)" + + +class SetupiCloudService(HTTPService): + def __init__(self, session): + super(SetupiCloudService, self).__init__(session) + self.url = "https://setup.icloud.com/setup/ws/1" + self.urlKey = self.url + "/validate" + self.urlLogin = self.url + "/accountLogin" + + self.appleWidgetKey = None + self.cookies = None + self.dsid = None + + def requestAppleWidgetKey(self, clientID): + self.session.headers.update(self.getRequestHeader()) + apple_widget_params = self.getQueryParameters(clientID) + self.response = self.session.get(self.urlKey, + params=apple_widget_params) + try: + self.appleWidgetKey = self.findQyery(self.response.text, + "widgetKey=") + except Exception as e: + err_str = "requestAppletWidgetKey: Apple Widget Key query failed" + raise Exception(err_str, + self.urlKey, repr(e)) + return self.appleWidgetKey + + def requestCookies(self, appleSessionToken, clientID): + self.session.headers.update(self.getRequestHeader()) + login_payload = self.getLoginRequestPayload(appleSessionToken) + login_params = self.getQueryParameters(clientID) + self.response = self.session.post(self.urlLogin, + login_payload, + params=login_params) + try: + self.cookies = self.response.headers["Set-Cookie"] + except Exception as e: + raise Exception("requestCookies: Cookies query failed", + self.urlLogin, repr(e)) + try: + self.dsid = self.response.json()["dsInfo"]["dsid"] + except Exception as e: + raise Exception("requestCookies: dsid query failed", + self.urlLogin, repr(e)) + return self.cookies, self.dsid + + def findQyery(self, data, query): + response = '' + foundAt = data.find(query) + if foundAt == -1: + except_str = "findQyery: " + query + " could not be found in data" + raise Exception(except_str) + foundAt += len(query) + char = data[foundAt] + while char.isalnum(): + response += char + foundAt += 1 + char = data[foundAt] + return response + + def getRequestHeader(self): + header = { + "Accept": "*/*", + "Connection": "keep-alive", + "Content-Type": "text/plain", + "User-Agent": self.user_agent, + "Origin": self.origin, + "Referer": self.referer, + } + return header + + def getQueryParameters(self, clientID): + if not clientID: + raise NameError("getQueryParameters: clientID not found") + return { + "clientBuildNumber": "16CHotfix21", + "clientID": clientID, + "clientMasteringNumber": "16CHotfix21", + } + + def getLoginRequestPayload(self, appleSessionToken): + if not appleSessionToken: + err_str = "getLoginRequestPayload: X-Apple-ID-Session-Id not found" + raise NameError(err_str) + return json({ + "dsWebAuthToken": appleSessionToken, + "extended_login": False, + }) + + +class IdmsaAppleService(HTTPService): + def __init__(self, session): + super(IdmsaAppleService, self).__init__(session) + self.url = "https://idmsa.apple.com" + self.urlAuth = self.url + "/appleauth/auth/signin?widgetKey=" + + self.appleSessionToken = None + + def requestAppleSessionToken(self, user, password, appleWidgetKey): + self.session.headers.update(self.getRequestHeader(appleWidgetKey)) + self.response = self.session.post(self.urlAuth + appleWidgetKey, + self.getRequestPayload(user, + password)) + try: + headers = self.response.headers + self.appleSessionToken = headers["X-Apple-Session-Token"] + except Exception as e: + err_str = "requestAppleSessionToken: " + \ + "Apple Session Token query failed" + + raise Exception(err_str, + self.urlAuth, repr(e)) + return self.appleSessionToken + + def getRequestHeader(self, appleWidgetKey): + if not appleWidgetKey: + raise NameError("getRequestHeader: clientID not found") + return { + "Accept": "application/json, text/javascript", + "Content-Type": "application/json", + "User-Agent": self.user_agent, + "X-Apple-Widget-Key": appleWidgetKey, + "X-Requested-With": "XMLHttpRequest", + "Origin": self.origin, + "Referer": self.referer, + } + + def getRequestPayload(self, user, password): + if not user: + raise NameError("getAuthenticationRequestPayload: user not found") + if not password: + err_str = "getAuthenticationRequestPayload: password not found" + raise NameError(err_str) + return json.dumps({ + "accountName": user, + "password": password, + "rememberMe": False, + }) diff --git a/pyicloud_ipd/services/calendar.py b/pyicloud_ipd/services/calendar.py index 49cf1188..08cdd761 100644 --- a/pyicloud_ipd/services/calendar.py +++ b/pyicloud_ipd/services/calendar.py @@ -19,6 +19,7 @@ def __init__(self, service_root, session, params): self._calendar_event_detail_url = '%s/eventdetail' % ( self._calendar_endpoint, ) + self._calendars = '%s/startup' % self._calendar_endpoint def get_event_detail(self, pguid, guid): """ @@ -60,3 +61,22 @@ def events(self, from_dt=None, to_dt=None): """ self.refresh_client(from_dt, to_dt) return self.response['Event'] + + def calendars(self): + """ + Retrieves calendars for this month + """ + today = datetime.today() + first_day, last_day = monthrange(today.year, today.month) + from_dt = datetime(today.year, today.month, first_day) + to_dt = datetime(today.year, today.month, last_day) + params = dict(self.params) + params.update({ + 'lang': 'en-us', + 'usertz': get_localzone().zone, + 'startDate': from_dt.strftime('%Y-%m-%d'), + 'endDate': to_dt.strftime('%Y-%m-%d') + }) + req = self.session.get(self._calendars, params=params) + self.response = req.json() + return self.response['Collection'] diff --git a/setup.cfg b/setup.cfg index 8f505f45..7aa5e453 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ -[pytest] +[tool:pytest] norecursedirs=lib build .tox diff --git a/setup.py b/setup.py index 5f905ba7..2760b35a 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='pyicloud_ipd', - version='0.10.0', + version='0.11.0', url='https://github.com/ndbroadbent/pyicloud', description=( 'PyiCloud is a module which allows pythonistas to '