Skip to content

Commit

Permalink
Add Android user registration and password reset
Browse files Browse the repository at this point in the history
Closes #236
  • Loading branch information
oldnapalm committed Apr 21, 2024
1 parent 5d57b0d commit 2ba2678
Show file tree
Hide file tree
Showing 2 changed files with 74 additions and 36 deletions.
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,7 @@ to generate your own certificates and do the same.
* Run Zwift, hopefully it verifies download and runs
* Play Zwift:
* Virtual Hosts button must be ON
* Start Zwift and sign in using any email/password
* If multiplayer is enabled, access `https://<zoffline ip>/signup/` to sign up and import your files. (You must accept an invalid certificate alert).
* Start Zwift and create a new user.

Why: We need to redirect Zwift to use zoffline (this is done by the Virtual Hosts app) and convince Zwift to
accept zoffline's self signed certificates for Zwift's domain names (this is done by the patch tool ZofflineObb).
Expand All @@ -194,8 +193,7 @@ accept zoffline's self signed certificates for Zwift's domain names (this is don
* (modify on PC)
* ``adb push hosts /etc/hosts``
* Note: If you know what you're doing and have a capable enough router you can adjust your router to alter these DNS records instead of modifying your ``hosts`` file.
* Start Zwift and sign in using any email/password
* If multiplayer is enabled, access `https://<zoffline ip>/signup/` to sign up and import your files.
* Start Zwift and create a new user.

Why: We need to redirect Zwift to use zoffline and convince Zwift to
accept zoffline's self signed certificates for Zwift's domain names. Feel free
Expand Down Expand Up @@ -270,7 +268,7 @@ To enable support for multiple users perform the steps below:
* Create a ``multiplayer.txt`` file in the ``storage`` directory.
* If you are not running zoffline on the same PC that Zwift is running: create a ``server-ip.txt`` file in the ``storage`` directory containing the IP address of the PC running zoffline.
* TCP ports 80, 443, 3025 and UDP port 3024 will need to be open on the PC running zoffline if its running remotely.
* Start Zwift and create an account in the launcher (desktop solution only, for Android go to `https://<zoffline ip>/signup/`).
* Start Zwift and create an account.
* This account will only exist on your zoffline server and has no relation with your actual Zwift account.

</details>
Expand Down Expand Up @@ -326,7 +324,8 @@ To enable support for multiple users perform the steps below:
#### Ghosts

* Enable this feature by checking "Enable ghosts" in zoffline's launcher.
* If you are running Zwift on Android, create a file ``enable_ghosts.txt`` inside the ``storage`` folder or access ``https://<zoffline_ip>/login/`` from a browser if you are using multiplayer (you must click the "Start Zwift" button to save the option).
* If you are running Zwift on Android, create a file ``enable_ghosts.txt`` inside the ``storage`` folder.
* If multiplayer is enabled, access ``https://<zoffline_ip>/login/``, check "Enable ghosts" and click "Start Zwift" to save the option.
* When you save an activity, the ghost will be saved in ``storage/<player_id>/ghosts/<world>/<route>``. Next time you ride the same route, the ghost will be loaded.
* Type ``.regroup`` in chat to regroup the ghosts.

Expand Down
99 changes: 69 additions & 30 deletions zwift_offline.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,15 @@ def check_sha256_hash(pwhash, password):
return False
return hmac.compare_digest(hmac.new(salt.encode("utf-8"), password.encode("utf-8"), method).hexdigest(), hashval)

def make_profile_dir(player_id):
profile_dir = os.path.join(STORAGE_DIR, str(player_id))
try:
if not os.path.isdir(profile_dir):
os.makedirs(profile_dir)
except IOError as e:
logger.error("failed to create profile dir (%s): %s", profile_dir, str(e))
return False
return True

@app.route("/login/", methods=["GET", "POST"])
def login():
Expand All @@ -606,12 +615,7 @@ def login():
login_user(user, remember=True)
user.remember = remember
db.session.commit()
profile_dir = os.path.join(STORAGE_DIR, str(user.player_id))
try:
if not os.path.isdir(profile_dir):
os.makedirs(profile_dir)
except IOError as e:
logger.error("failed to create profile dir (%s): %s", profile_dir, str(e))
if not make_profile_dir(user.player_id):
return '', 500
return redirect(url_for("user_home", username=username, enable_ghosts=bool(user.enable_ghosts), online=get_online()))
else:
Expand All @@ -628,6 +632,26 @@ def login():
return render_template("login_form.html")


def send_mail(username, token):
try:
with open('%s/gmail_credentials.txt' % STORAGE_DIR) as f:
sender_email = f.readline().rstrip('\r\n')
password = f.readline().rstrip('\r\n')
with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=ssl.create_default_context()) as server:
server.login(sender_email, password)
message = MIMEMultipart()
message['From'] = sender_email
message['To'] = username
message['Subject'] = "Password reset"
content = "https://%s/login/?token=%s" % (server_ip, token)
message.attach(MIMEText(content, 'plain'))
server.sendmail(sender_email, username, message.as_string())
server.close()
except Exception as exc:
logger.warning('send e-mail: %s' % repr(exc))
return False
return True

@app.route("/forgot/", methods=["GET", "POST"])
def forgot():
if request.method == "POST":
Expand All @@ -641,23 +665,9 @@ def forgot():

user = User.query.filter_by(username=username).first()
if user:
try:
with open('%s/gmail_credentials.txt' % STORAGE_DIR, 'r') as f:
sender_email = f.readline().rstrip('\r\n')
password = f.readline().rstrip('\r\n')
with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=ssl.create_default_context()) as server:
server.login(sender_email, password)
message = MIMEMultipart()
message['From'] = sender_email
message['To'] = username
message['Subject'] = "Password reset"
content = "https://%s/login/?token=%s" % (server_ip, user.get_token())
message.attach(MIMEText(content, 'plain'))
server.sendmail(sender_email, username, message.as_string())
server.close()
flash("E-mail sent.")
except Exception as exc:
logger.warning('send e-mail: %s' % repr(exc))
if send_mail(username, user.get_token()):
flash("E-mail sent.")
else:
flash("Could not send e-mail.")
else:
flash("Invalid username.")
Expand All @@ -669,6 +679,38 @@ def forgot():
def api_push_fcm_production(type, token):
return '', 500

@app.route("/api/users", methods=["POST"]) # Android user registration
def api_users():
first_name = request.json['profile']['firstName']
last_name = request.json['profile']['lastName']
if MULTIPLAYER:
username = request.json['email']
if not re.match(r"[^@]+@[^@]+\.[^@]+", username):
return '', 400
pass_hash = generate_password_hash(request.json['password'], 'scrypt')
user = User(username=username, pass_hash=pass_hash, first_name=first_name, last_name=last_name)
db.session.add(user)
try:
db.session.commit()
except sqlalchemy.exc.IntegrityError:
return '', 400
login_user(user, remember=True)
if not make_profile_dir(user.player_id):
return '', 500
else:
AnonUser.first_name = first_name
AnonUser.last_name = last_name
return '', 200

@app.route("/api/users/reset-password-email", methods=["PUT"]) # Android password reset
def api_users_reset_password_email():
username = request.form['username']
if re.match(r"[^@]+@[^@]+\.[^@]+", username):
user = User.query.filter_by(username=username).first()
if user:
send_mail(username, user.get_token())
return '', 200

@app.route("/api/users/password-reset/", methods=["POST"])
@jwt_to_session_cookie
@login_required
Expand Down Expand Up @@ -999,7 +1041,7 @@ def delete(filename):
flash("Credentials removed.")
return redirect(url_for('settings', username=current_user.username))

@app.route("/power_curves/<username>", methods=["GET", "POST"])
@app.route("/power_curves/<username>/", methods=["GET", "POST"])
@login_required
def power_curves(username):
if request.method == "POST":
Expand Down Expand Up @@ -3759,6 +3801,8 @@ def auth_realms_zwift_protocol_openid_connect_token():

if user and check_password_hash(user.pass_hash, password):
login_user(user, remember=True)
if not make_profile_dir(user.player_id):
return '', 500
else:
return '', 401

Expand Down Expand Up @@ -3864,12 +3908,7 @@ def run_standalone(passed_online, passed_global_relay, passed_global_pace_partne
break
if not player_id:
player_id = 1
profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
try:
if not os.path.isdir(profile_dir):
os.makedirs(profile_dir)
except IOError as e:
logger.error("failed to create profile dir (%s): %s", profile_dir, str(e))
if not make_profile_dir(player_id):
sys.exit(1)
AnonUser.player_id = player_id
login_manager.anonymous_user = AnonUser
Expand Down

0 comments on commit 2ba2678

Please sign in to comment.