Support Garmin MFA

This commit is contained in:
oldnapalm
2024-11-30 11:55:07 -03:00
parent d564f7c108
commit 1230855ba4
4 changed files with 80 additions and 39 deletions
+1
View File
@@ -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>
+5
View File
@@ -10,6 +10,11 @@
{% 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">
+24
View File
@@ -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)
+50 -39
View File
@@ -25,7 +25,7 @@ import xml.etree.ElementTree as ET
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
@@ -132,6 +132,12 @@ if not os.path.exists(CREDENTIALS_KEY_FILE):
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
@@ -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"])
@@ -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))
@@ -2234,41 +2258,28 @@ 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)
@@ -2276,7 +2287,7 @@ def garmin_upload(player_id, activity):
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: