Skip to content

Commit

Permalink
Support Garmin MFA
Browse files Browse the repository at this point in the history
  • Loading branch information
oldnapalm committed Nov 30, 2024
1 parent d564f7c commit 1230855
Show file tree
Hide file tree
Showing 4 changed files with 80 additions and 39 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ To obtain your current profile:
<password>
```
* Note: this is not secure. Only do this if you are comfortable with your login credentials being stored in a clear text file.
* If your account has multi-factor authentication, use the script ``garmin_auth.py`` and move the resulting ``garth`` folder (saved in whatever directory you ran ``garmin_auth.py`` in) into the ``storage/1`` directory.
* If testing, ride at least 300 meters, shorter activities won't be uploaded.

</details>
Expand Down
5 changes: 5 additions & 0 deletions cdn/static/web/launcher/garmin.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ <h4 class="text-shadow">Logged in as {{ username }}</h4>
{% if uname or passw %}
<a href="/delete/garmin_credentials.bin" class="btn btn-sm btn-danger">Remove credentials</a>
{% endif %}
{% if token %}
<a href="/delete/garth/oauth1_token.json" class="btn btn-sm btn-danger">Remove authorization</a>
{% elif uname and passw %}
<a href="{{ url_for('garmin_auth') }}" class="btn btn-sm btn-secondary">Authorize</a>
{% endif %}
</div>
</div>
<div class="row">
Expand Down
24 changes: 24 additions & 0 deletions scripts/garmin_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env python

import os
import sys
import getpass
import garth

domain = input("Garmin domain [garmin.com]: ") or 'garmin.com'
username = input("Username (e-mail): ")
if not sys.stdin.isatty(): # This terminal cannot support input without displaying text
print(f'*WARNING* The current shell ({os.name}) cannot support hidden text entry.')
print(f'Your password entry WILL BE VISIBLE.')
print(f'If you are running a bash shell under windows, try executing this program via winpty:')
print(f'>winpty python {sys.argv[0]}')
password = input("Password (will be shown): ")
else:
password = getpass.getpass("Password: ")

garth.configure(domain=domain)
try:
garth.login(username, password)
garth.save('./garth')
except Exception as e:
print(e)
89 changes: 50 additions & 39 deletions zwift_offline.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from copy import deepcopy
from functools import wraps
from io import BytesIO
from shutil import copyfile, rmtree
from shutil import copyfile
from urllib.parse import quote
from flask import Flask, request, jsonify, redirect, render_template, url_for, flash, session, make_response, send_file, send_from_directory
from flask_login import UserMixin, AnonymousUserMixin, LoginManager, login_user, current_user, login_required, logout_user
Expand Down Expand Up @@ -132,6 +132,12 @@ def make_dir(name):
with open(CREDENTIALS_KEY_FILE, 'rb') as f:
credentials_key = f.read()

GARMIN_DOMAIN = 'garmin.com'
GARMIN_DOMAIN_FILE = '%s/garmin_domain.txt' % STORAGE_DIR
if os.path.exists(GARMIN_DOMAIN_FILE):
with open(GARMIN_DOMAIN_FILE) as f:
GARMIN_DOMAIN = f.readline().rstrip('\r\n')

import warnings
with warnings.catch_warnings():
from stravalib.client import Client
Expand Down Expand Up @@ -892,15 +898,30 @@ def profile(username):
@login_required
def garmin(username):
file = '%s/%s/garmin_credentials.bin' % (STORAGE_DIR, current_user.player_id)
token = os.path.isfile('%s/%s/garth/oauth1_token.json' % (STORAGE_DIR, current_user.player_id))
if request.method == "POST":
if request.form['username'] == "" or request.form['password'] == "":
flash("Garmin credentials can't be empty.")
return render_template("garmin.html", username=current_user.username)
return render_template("garmin.html", username=current_user.username, token=token)
encrypt_credentials(file, (request.form['username'], request.form['password']))
rmtree('%s/%s/garth' % (STORAGE_DIR, current_user.player_id), ignore_errors=True)
return redirect(url_for('settings', username=current_user.username))
cred = decrypt_credentials(file)
return render_template("garmin.html", username=current_user.username, uname=cred[0], passw=cred[1])
return render_template("garmin.html", username=current_user.username, uname=cred[0], passw=cred[1], token=token)


@app.route("/garmin_auth", methods=['GET'])
@login_required
def garmin_auth():
try:
import garth
garth.configure(domain=GARMIN_DOMAIN)
username, password = decrypt_credentials('%s/%s/garmin_credentials.bin' % (STORAGE_DIR, current_user.player_id))
garth.login(username, password)
garth.save('%s/%s/garth' % (STORAGE_DIR, current_user.player_id))
flash("Garmin authorized.")
except Exception as exc:
logger.warning('garmin_auth: %s' % repr(exc))
flash("Garmin authorization failed.")
return redirect(url_for('garmin', username=current_user.username))


@app.route("/intervals/<username>/", methods=["GET", "POST"])
Expand Down Expand Up @@ -1046,18 +1067,21 @@ def download_avatarLarge(player_id):
else:
return '', 404

@app.route("/delete/<filename>", methods=["GET"])
@app.route("/delete/<path:filename>", methods=["GET"])
@login_required
def delete(filename):
credentials = ['garmin_credentials.bin', 'zwift_credentials.bin', 'intervals_credentials.bin']
credentials = ['zwift_credentials.bin', 'intervals_credentials.bin']
strava = ['strava_api.bin', 'strava_token.txt']
if filename not in ['profile.bin', 'achievements.bin'] + credentials + strava:
garmin = ['garmin_credentials.bin', 'garth/oauth1_token.json']
if filename not in ['profile.bin', 'achievements.bin'] + credentials + strava + garmin:
return '', 403
delete_file = os.path.join(STORAGE_DIR, str(current_user.player_id), filename)
if os.path.isfile(delete_file):
os.remove("%s" % delete_file)
os.remove(delete_file)
if filename in strava:
return redirect(url_for('strava', username=current_user.username))
if filename in garmin:
return redirect(url_for('garmin', username=current_user.username))
if filename in credentials:
flash("Credentials removed.")
return redirect(url_for('settings', username=current_user.username))
Expand Down Expand Up @@ -2234,49 +2258,36 @@ def garmin_upload(player_id, activity):
except ImportError as exc:
logger.warning("garth is not installed. Skipping Garmin upload attempt: %s" % repr(exc))
return
profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
garmin_credentials = '%s/garmin_credentials' % profile_dir
if os.path.exists(garmin_credentials + '.bin'):
garmin_credentials += '.bin'
elif os.path.exists(garmin_credentials + '.txt'):
garmin_credentials += '.txt'
else:
logger.info("garmin_credentials missing, skip Garmin activity update")
return
if garmin_credentials.endswith('.bin'):
username, password = decrypt_credentials(garmin_credentials)
else:
try:
with open(garmin_credentials) as f:
username = f.readline().rstrip('\r\n')
password = f.readline().rstrip('\r\n')
except Exception as exc:
logger.warning("Failed to read %s. Skipping Garmin upload attempt: %s" % (garmin_credentials, repr(exc)))
return
domain = 'garmin.com'
domain_file = '%s/garmin_domain.txt' % STORAGE_DIR
if os.path.exists(domain_file):
try:
with open(domain_file) as f:
domain = f.readline().rstrip('\r\n')
garth.configure(domain=domain)
except Exception as exc:
logger.warning("Failed to read %s: %s" % (domain_file, repr(exc)))
tokens_dir = '%s/garth' % profile_dir
garth.configure(domain=GARMIN_DOMAIN)
tokens_dir = '%s/%s/garth' % (STORAGE_DIR, player_id)
try:
garth.resume(tokens_dir)
if garth.client.oauth2_token.expired:
garth.client.refresh_oauth2()
garth.save(tokens_dir)
except:
garmin_credentials = '%s/%s/garmin_credentials' % (STORAGE_DIR, player_id)
if os.path.exists(garmin_credentials + '.bin'):
garmin_credentials += '.bin'
elif os.path.exists(garmin_credentials + '.txt'):
garmin_credentials += '.txt'
else:
logger.info("garmin_credentials missing, skip Garmin activity update")
return
if garmin_credentials.endswith('.bin'):
username, password = decrypt_credentials(garmin_credentials)
else:
with open(garmin_credentials) as f:
username = f.readline().rstrip('\r\n')
password = f.readline().rstrip('\r\n')
try:
garth.login(username, password)
garth.save(tokens_dir)
except Exception as exc:
logger.warning("Garmin login failed: %s" % repr(exc))
return
try:
requests.post('https://connectapi.%s/upload-service/upload' % domain,
requests.post('https://connectapi.%s/upload-service/upload' % GARMIN_DOMAIN,
files={"file": (activity.fit_filename, BytesIO(activity.fit))},
headers={'authorization': str(garth.client.oauth2_token)})
except Exception as exc:
Expand Down

0 comments on commit 1230855

Please sign in to comment.