mirror of
https://github.com/zoffline/zwift-offline.git
synced 2026-06-12 19:11:33 -07:00
Support Garmin MFA
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user