Skip to content

Commit

Permalink
Merge pull request #129 from SimonBaars/migrate-to-oauth-through-garth
Browse files Browse the repository at this point in the history
Migrate Garmin authentication to Garth OAuth fixes #128 (broken garmin upload)
  • Loading branch information
longstone authored Sep 28, 2023
2 parents 9ade05d + 547360d commit 43f036e
Show file tree
Hide file tree
Showing 3 changed files with 11 additions and 165 deletions.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
lxml
requests
cloudscraper
garth
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def read(fname):

setup(
name="withings-sync",
version="3.6.2",
version="4.0.0",
author="Masayuki Hamasaki, Steffen Vogel",
author_email="[email protected]",
description="A tool for synchronisation of Withings (ex. Nokia Health Body) to Garmin Connect and Trainer Road.",
Expand All @@ -26,7 +26,7 @@ def read(fname):
"Topic :: Utilities",
"License :: OSI Approved :: MIT License",
],
install_requires=["lxml", "requests", "cloudscraper"],
install_requires=["lxml", "requests", "cloudscraper", "garth"],
entry_points={
"console_scripts": ["withings-sync=withings_sync.sync:main"],
},
Expand Down
171 changes: 8 additions & 163 deletions withings_sync/garmin.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
"""This module handles the Garmin connectivity."""
import urllib.request
import urllib.error
import urllib.parse
import re
import json
import logging
import cloudscraper
import garth


log = logging.getLogger("garmin")
Expand All @@ -26,177 +22,26 @@ class APIException(Exception):
class GarminConnect:
"""Main GarminConnect class"""

LOGIN_URL = "https://connect.garmin.com/signin"
UPLOAD_URL = "https://connect.garmin.com/modern/proxy/upload-service/upload/.fit"

def create_opener(self, cookie):
"""Garmin opener"""
this = self

class _HTTPRedirectHandler(urllib.request.HTTPRedirectHandler):
def http_error_302(
self, req, fp, code, msg, headers
): # pylint: disable=too-many-arguments
if req.get_full_url() == this.LOGIN_URL:
raise LoginSucceeded

return urllib.request.HTTPRedirectHandler.http_error_302(
self, req, fp, code, msg, headers
)

return urllib.request.build_opener(
_HTTPRedirectHandler, urllib.request.HTTPCookieProcessor(cookie)
)
UPLOAD_URL = "https://connect.garmin.com/upload-service/upload/.fit"

# From https://github.com/cpfair/tapiriik
@staticmethod
def get_session(email=None, password=None):
"""tapiriik get_session code"""
session = cloudscraper.CloudScraper()

data = {
"username": email,
"password": password,
"_eventId": "submit",
"embed": "true",
}
params = {
"service": "https://connect.garmin.com/modern",
"clientId": "GarminConnect",
"gauthHost": "https://sso.garmin.com/sso",
"consumeServiceTicket": "false",
}

headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36",
"Referer": "https://jhartman.pl",
"origin": "https://sso.garmin.com",
}

# 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/signin", params=params, headers=headers
)
if preresp.status_code != 200:
raise APIException(
f"SSO prestart error {preresp.status_code} {preresp.text}"
)

ssoresp = session.post(
"https://sso.garmin.com/sso/login",
params=params,
data=data,
allow_redirects=False,
headers=headers,
)

if ssoresp.status_code == 429:
raise APIException(
"SSO error 429: You are being rate limited: "
+ "The owner of this website (sso.garmin.com) "
+ "has banned you temporarily from accessing this website."
)

if ssoresp.status_code != 200 or "temporarily unavailable" in ssoresp.text:
raise APIException(f"SSO error {ssoresp.status_code} {ssoresp.text}")

if ">sendEvent('FAIL')" in ssoresp.text:
raise APIException("Invalid login")

if ">sendEvent('ACCOUNT_LOCKED')" in ssoresp.text:
raise APIException("Account Locked")

if "renewPassword" in ssoresp.text:
raise APIException("Reset password")

# self.print_cookies(cookies=session.cookies)

# ...AND WE'RE NOT DONE YET!

gcredeemresp = session.get(
"https://connect.garmin.com/modern", allow_redirects=False, headers=headers
)
if gcredeemresp.status_code != 302:
raise APIException(
f"GC redeem-start error {gcredeemresp.status_code} {gcredeemresp.text}"
)

url_prefix = "https://connect.garmin.com"

# There are 6 redirects that need to be followed to get the correct cookie
# ... :(
max_redirect_count = 7
current_redirect_count = 1
while True:
url = gcredeemresp.headers["location"]

# Fix up relative redirects.
if url.startswith("/"):
url = url_prefix + url
url_prefix = "/".join(url.split("/")[:3])
gcredeemresp = session.get(url, allow_redirects=False)

if (
current_redirect_count >= max_redirect_count
and gcredeemresp.status_code != 200
):
raise APIException(
f"GC redeem {current_redirect_count}/"
"{max_redirect_count} error "
"{gcredeemresp.status_code} "
"{gcredeemresp.text}"
)

if gcredeemresp.status_code in [200, 404]:
break

current_redirect_count += 1
if current_redirect_count > max_redirect_count:
break

# GarminConnect.print_cookies(session.cookies)
session.headers.update(headers)
try:
garth.login(email, password)
except Exception as ex:
raise APIException("Authentication failure: {}. Did you enter correct credentials?".format(ex))

session.headers.update({'NK': 'NT', 'authorization': garth.client.oauth2_token.__str__(), 'di-backend': 'connectapi.garmin.com'})
return session

@staticmethod
def get_json(page_html, key):
"""Return json from text."""
found = re.search(key + r" = (\{.*\});", page_html, re.M)
if found:
json_text = found.group(1).replace('\\"', '"')
return json.loads(json_text)
return None

@staticmethod
def print_cookies(cookies):
"""print cookies"""
log.debug("Cookies: ")
for key, value in list(cookies.items()):
log.debug(" %s = %s", key, value)

@staticmethod
def login(username, password):
"""login to Garmin"""
session = GarminConnect.get_session(email=username, password=password)
try:
dashboard = session.get("http://connect.garmin.com/modern")
userdata = GarminConnect.get_json(dashboard.text, "VIEWER_SOCIAL_PROFILE")
username = userdata["displayName"]

log.info("Garmin Connect User Name: %s", username)

except Exception as exception: # pylint: disable=broad-except
log.error(exception)
log.error(
"Unable to retrieve Garmin username! Most likely: "
"incorrect Garmin login or password!"
)
log.debug(dashboard.text)

return session
return GarminConnect.get_session(email=username, password=password)

def upload_file(self, ffile, session):
"""upload fit file to Garmin connect"""
Expand Down

0 comments on commit 43f036e

Please sign in to comment.