From e585831647dc17270300fd046697daa4e1017a41 Mon Sep 17 00:00:00 2001 From: Jarek Hartman Date: Fri, 21 Apr 2017 15:05:25 +0200 Subject: [PATCH] Fix Garmin sync after switching Garmin Connect to 'modern' Fixing authorisation issues. Changed file upload procedure. --- garmin.py | 235 ++++++++++++++++++++++++++---------------------------- sync.py | 7 +- 2 files changed, 119 insertions(+), 123 deletions(-) diff --git a/garmin.py b/garmin.py index 537fc2d..98eda8c 100644 --- a/garmin.py +++ b/garmin.py @@ -19,10 +19,10 @@ class LoginFailed(Exception): class GarminConnect(object): LOGIN_URL = 'https://connect.garmin.com/signin' - UPLOAD_URL = 'https://connect.garmin.com/proxy/upload-service-1.1/json/upload/.fit' + UPLOAD_URL = 'https://connect.garmin.com/modern/proxy/upload-service/upload/.fit' _sessionCache = SessionCache(lifetime=timedelta(minutes=30), freshen_on_get=True) - + def create_opener(self, cookie): this = self class _HTTPRedirectHandler(urllib2.HTTPRedirectHandler): @@ -35,133 +35,128 @@ def http_error_302(self, req, fp, code, msg, headers): ############################################## # From https://github.com/cpfair/tapiriik - def _get_cookies(self, record=None, email=None, password=None): - - gcPreResp = requests.get("https://connect.garmin.com/", allow_redirects=False) - # New site gets this redirect, old one does not - if gcPreResp.status_code == 200: - gcPreResp = requests.get("https://connect.garmin.com/signin", allow_redirects=False) - req_count = int(re.search("j_id(\d+)", gcPreResp.text).groups(1)[0]) - params = {"login": "login", "login:loginUsernameField": email, "login:password": password, "login:signInButton": "Sign In"} - auth_retries = 3 # Did I mention Garmin Connect is silly? - for retries in range(auth_retries): - params["javax.faces.ViewState"] = "j_id%d" % req_count - req_count += 1 - self._rate_limit() - resp = requests.post("https://connect.garmin.com/signin", data=params, allow_redirects=False, cookies=gcPreResp.cookies) - if resp.status_code >= 500 and resp.status_code < 600: - raise APIException("Remote API failure") - if resp.status_code != 302: # yep - if "errorMessage" in resp.text: - if retries < auth_retries - 1: - time.sleep(1) - continue - else: - raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) - else: - raise APIException("Mystery login error %s" % resp.text) + def _get_session(self, record=None, email=None, password=None): + session = requests.Session() + + # JSIG CAS, cool I guess. + # Not quite OAuth though, so I'll continue to collect raw credentials. + # Commented stuff left in case this ever breaks because of missing parameters... + data = { + "username": email, + "password": password, + "_eventId": "submit", + "embed": "true", + # "displayNameRequired": "false" + } + params = { + "service": "https://connect.garmin.com/post-auth/login", + "redirectAfterAccountLoginUrl": "http://connect.garmin.com/post-auth/login", + "redirectAfterAccountCreationUrl": "http://connect.garmin.com/post-auth/login", + # "webhost": "olaxpw-connect00.garmin.com", + "clientId": "GarminConnect", + "gauthHost": "https://sso.garmin.com/sso", + # "rememberMeShown": "true", + # "rememberMeChecked": "false", + "consumeServiceTicket": "false", + # "id": "gauth-widget", + # "embedWidget": "false", + # "cssUrl": "https://static.garmincdn.com/com.garmin.connect/ui/src-css/gauth-custom.css", + # "source": "http://connect.garmin.com/en-US/signin", + # "createAccountShown": "true", + # "openCreateAccount": "false", + # "usernameShown": "true", + # "displayNameShown": "false", + # "initialFocus": "true", + # "locale": "en" + } + # I may never understand what motivates people to mangle a perfectly good protocol like HTTP in the ways they do... + preResp = session.get("https://sso.garmin.com/sso/login", params=params) + if preResp.status_code != 200: + raise APIException("SSO prestart error %s %s" % (preResp.status_code, preResp.text)) + + data["lt"] = re.search("name=\"lt\"\s+value=\"([^\"]+)\"", preResp.text).groups(1)[0] + + # print "Received lt: " + data["lt"] + + ssoResp = session.post("https://sso.garmin.com/sso/login", params=params, data=data, allow_redirects=False) + if ssoResp.status_code != 200 or "temporarily unavailable" in ssoResp.text: + raise APIException("SSO error %s %s" % (ssoResp.status_code, ssoResp.text)) + + if ">sendEvent('FAIL')" in ssoResp.text: + raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) + if ">sendEvent('ACCOUNT_LOCKED')" in ssoResp.text: + raise APIException("Account Locked", block=True, user_exception=UserException(UserExceptionType.Locked, intervention_required=True)) + + if "renewPassword" in ssoResp.text: + raise APIException("Reset password", block=True, user_exception=UserException(UserExceptionType.RenewPassword, intervention_required=True)) + + ticket_match = re.search("ticket=([^']+)'", ssoResp.text) + + if not ticket_match: + raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) + ticket = ticket_match.groups(1)[0] + + # print "Ticket: " + ticket + # self.print_cookies(cookies=session.cookies) + + # ...AND WE'RE NOT DONE YET! + + gcRedeemResp = session.get("https://connect.garmin.com/post-auth/login", params={"ticket": ticket}, allow_redirects=False) + if gcRedeemResp.status_code != 302: + raise APIException("GC redeem-start error %s %s" % (gcRedeemResp.status_code, gcRedeemResp.text)) + + # self.print_cookies(cookies=session.cookies) + + # There are 6 redirects that need to be followed to get the correct cookie + # ... :( + max_redirect_count = 7 + current_redirect_count = 1 + while True: + gcRedeemResp = session.get(gcRedeemResp.headers["location"], allow_redirects=False) + + # self.print_cookies(cookies=session.cookies) + + if current_redirect_count >= max_redirect_count and gcRedeemResp.status_code != 200: + raise APIException("GC redeem %d/%d error %s %s" % (current_redirect_count, max_redirect_count, gcRedeemResp.status_code, gcRedeemResp.text)) + if gcRedeemResp.status_code == 200 or gcRedeemResp.status_code == 404: + break + current_redirect_count += 1 + if current_redirect_count > max_redirect_count: break - elif gcPreResp.status_code == 302: - # JSIG CAS, cool I guess. - # Not quite OAuth though, so I'll continue to collect raw credentials. - # Commented stuff left in case this ever breaks because of missing parameters... - data = { - "username": email, - "password": password, - "_eventId": "submit", - "embed": "true", - # "displayNameRequired": "false" - } - params = { - "service": "https://connect.garmin.com/post-auth/login", - # "redirectAfterAccountLoginUrl": "http://connect.garmin.com/post-auth/login", - # "redirectAfterAccountCreationUrl": "http://connect.garmin.com/post-auth/login", - # "webhost": "olaxpw-connect00.garmin.com", - "clientId": "GarminConnect", - # "gauthHost": "https://sso.garmin.com/sso", - # "rememberMeShown": "true", - # "rememberMeChecked": "false", - "consumeServiceTicket": "false", - # "id": "gauth-widget", - # "embedWidget": "false", - # "cssUrl": "https://static.garmincdn.com/com.garmin.connect/ui/src-css/gauth-custom.css", - # "source": "http://connect.garmin.com/en-US/signin", - # "createAccountShown": "true", - # "openCreateAccount": "false", - # "usernameShown": "true", - # "displayNameShown": "false", - # "initialFocus": "true", - # "locale": "en" - } - # I may never understand what motivates people to mangle a perfectly good protocol like HTTP in the ways they do... - preResp = requests.get("https://sso.garmin.com/sso/login", params=params) - if preResp.status_code != 200: - raise APIException("SSO prestart error %s %s" % (preResp.status_code, preResp.text)) - data["lt"] = re.search("name=\"lt\"\s+value=\"([^\"]+)\"", preResp.text).groups(1)[0] - - ssoResp = requests.post("https://sso.garmin.com/sso/login", params=params, data=data, allow_redirects=False, cookies=preResp.cookies) - if ssoResp.status_code != 200: - raise APIException("SSO error %s %s" % (ssoResp.status_code, ssoResp.text)) - - ticket_match = re.search("ticket=([^']+)'", ssoResp.text) - if not ticket_match: - raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) - ticket = ticket_match.groups(1)[0] - - # ...AND WE'RE NOT DONE YET! - - gcRedeemResp1 = requests.get("https://connect.garmin.com/post-auth/login", params={"ticket": ticket}, allow_redirects=False, cookies=gcPreResp.cookies) - if gcRedeemResp1.status_code != 302: - raise APIException("GC redeem 1 error %s %s" % (gcRedeemResp1.status_code, gcRedeemResp1.text)) - - gcRedeemResp2 = requests.get(gcRedeemResp1.headers["location"], cookies=gcPreResp.cookies, allow_redirects=False) - if gcRedeemResp2.status_code != 302: - raise APIException("GC redeem 2 error %s %s" % (gcRedeemResp2.status_code, gcRedeemResp2.text)) - - else: - raise APIException("Unknown GC prestart response %s %s" % (gcPreResp.status_code, gcPreResp.text)) - - self._sessionCache.Set(record.ExternalID if record else email, gcPreResp.cookies) - - return gcPreResp.cookies + self._sessionCache.Set(record.ExternalID if record else email, session.cookies) + + return session + def print_cookies(self, cookies): + print "Cookies" + + for key, value in cookies.items(): + print "Key: " + key + ", " + value def login(self, username, password): - cookies = self._get_cookies(email=username, password=password) - GCusername = requests.get("https://connect.garmin.com/user/username", cookies=cookies).json()["username"] + session = self._get_session(email=username, password=password) + res = session.get("https://connect.garmin.com/user/username") + GCusername = res.json()["username"] + sys.stderr.write('Garmin Connect User Name: ' + GCusername + '\n') if not len(GCusername): raise APIException("Unable to retrieve username", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) - return (cookies) + return (session) - def upload_file(self, f, cookie): - self.opener = self.create_opener(cookie) - - - # accept file object or string - if isinstance(f, file): - f.seek(0) - fbody = f.read() - else: - fbody = f - - boundary = '----withingsgarmin' - req = urllib2.Request(self.UPLOAD_URL) - req.add_header('Content-Type', 'multipart/form-data; boundary=%s' % boundary) - - # file - lines = [] - lines.append('--%s' % boundary) - lines.append('Content-Disposition: form-data; name="data"; filename="weight.fit"') - lines.append('Content-Type: application/octet-stream') - lines.append('') - lines.append(fbody) - - lines.append('--%s--' % boundary) - lines.append('') - r = self.opener.open(req, '\r\n'.join(lines)) - return r.code == 200 + def upload_file(self, f, session): + files = {"data": ("withings.fit", f)} + + res = session.post(self.UPLOAD_URL, + files=files, + headers={"nk": "NT"}) + + try: + resp = res.json()["detailedImportResult"] + except ValueError: + raise APIException("Bad response during GC upload: %s %s" % (res.status_code, res.text)) + + return (res.status_code == 200 or res.status_code == 201) diff --git a/sync.py b/sync.py index 33bbb70..e9e58b9 100755 --- a/sync.py +++ b/sync.py @@ -21,7 +21,6 @@ GARMIN_USERNAME = '' GARMIN_PASSWORD = '' - class DateOption(Option): def check_date(option, opt, value): valid_formats = ['%Y-%m-%d', '%Y%m%d', '%Y/%m/%d'] @@ -112,11 +111,13 @@ def verbose_print(s): sys.stdout.write(fit.getvalue()) return + verbose_print("Fit file: " + fit.getvalue()) + # garmin connect garmin = GarminConnect() - cookie = garmin.login(garmin_username, garmin_password) + session = garmin.login(garmin_username, garmin_password) verbose_print('attempting to upload fit file...\n') - r = garmin.upload_file(fit.getvalue(), cookie) + r = garmin.upload_file(fit.getvalue(), session) if r: verbose_print('weight.fit has been successfully uploaded!\n')