From 8f90cb0c8fffc23d22e43bec662130a8473e45ed Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 15:48:05 +0100 Subject: [PATCH 01/24] Add Password Reset Page --- server/fishtest/__init__.py | 4 + server/fishtest/emailer.py | 71 ++++++++++++++ server/fishtest/routes.py | 2 + server/fishtest/schemas.py | 1 + server/fishtest/templates/forgot_password.mak | 36 +++++++ server/fishtest/templates/login.mak | 4 + server/fishtest/templates/reset_password.mak | 55 +++++++++++ server/fishtest/views.py | 96 ++++++++++++++++++- 8 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 server/fishtest/emailer.py create mode 100644 server/fishtest/templates/forgot_password.mak create mode 100644 server/fishtest/templates/reset_password.mak diff --git a/server/fishtest/__init__.py b/server/fishtest/__init__.py index ba13a59e44..b2390a2b98 100644 --- a/server/fishtest/__init__.py +++ b/server/fishtest/__init__.py @@ -5,6 +5,7 @@ import traceback import fishtest.github_api as gh +from fishtest.emailer import EmailSender from fishtest.routes import setup_routes from fishtest.rundb import RunDb from pyramid.authentication import AuthTktAuthenticationPolicy @@ -47,6 +48,8 @@ def main(global_config, **settings): # assume the instance is primary for backward compatibility. is_primary_instance = port == primary_port + email_sender = EmailSender() + rundb = RunDb(port=port, is_primary_instance=is_primary_instance) def add_rundb(event): @@ -54,6 +57,7 @@ def add_rundb(event): event.request.userdb = rundb.userdb event.request.actiondb = rundb.actiondb event.request.workerdb = rundb.workerdb + event.request.email_sender = email_sender def add_renderer_globals(event): pass diff --git a/server/fishtest/emailer.py b/server/fishtest/emailer.py new file mode 100644 index 0000000000..6fd6eb1e1d --- /dev/null +++ b/server/fishtest/emailer.py @@ -0,0 +1,71 @@ +import os + +import requests + +RESEND_API_URL = "https://api.resend.com/emails" +RESEND_TIMEOUT = 10 + + +class EmailSendError(RuntimeError): + pass + + +class EmailSender: + def __init__( + self, + api_key=None, + from_email=None, + from_name=None, + session=None, + ): + self.api_key = api_key or os.getenv("FISHTEST_RESEND_API_KEY") + self.from_email = from_email or os.getenv("FISHTEST_RESEND_FROM_EMAIL") + self.from_name = from_name or os.getenv("FISHTEST_RESEND_FROM_NAME", "Fishtest") + self.session = session or requests.Session() + + def _from_field(self): + if not self.from_email: + raise EmailSendError("FISHTEST_RESEND_FROM_EMAIL is missing.") + if self.from_name: + return f"{self.from_name} <{self.from_email}>" + return self.from_email + + def send( + self, + username, + to_email, + subject, + text, + html=None, + reply_to=None, + ): + if not self.api_key: + raise EmailSendError("FISHTEST_RESEND_API_KEY is missing.") + if not to_email: + raise EmailSendError("to_email is required.") + + payload = { + "from": self._from_field(), + "to": [to_email] if isinstance(to_email, str) else to_email, + "subject": subject, + "text": text, + } + if html: + payload["html"] = html + if reply_to: + payload["reply_to"] = reply_to + + headers = {"Authorization": f"Bearer {self.api_key}"} + try: + response = self.session.post( + RESEND_API_URL, json=payload, headers=headers, timeout=RESEND_TIMEOUT + ) + except Exception as exc: + raise EmailSendError(f"Failed to reach Resend: {exc}") from exc + + if not response.ok: + raise EmailSendError( + f"Resend error {response.status_code}: {response.text}" + ) + + return response.json() diff --git a/server/fishtest/routes.py b/server/fishtest/routes.py index 5dd7e9d965..c9abb9a83d 100644 --- a/server/fishtest/routes.py +++ b/server/fishtest/routes.py @@ -14,6 +14,8 @@ def setup_routes(config): config.add_route("home", "/") config.add_route("login", "/login") + config.add_route("forgot_password", "/forgot_password") + config.add_route("reset_password", "/reset_password/{token}") config.add_route("nn_upload", "/upload") config.add_route("logout", "/logout") config.add_route("signup", "/signup") diff --git a/server/fishtest/schemas.py b/server/fishtest/schemas.py index 97737a7d53..41ea0c8217 100644 --- a/server/fishtest/schemas.py +++ b/server/fishtest/schemas.py @@ -108,6 +108,7 @@ def size_is_length(pgn_doc): "groups": [str, ...], "tests_repo": union(github_repo, ""), "machine_limit": uint, + "password_reset?": {"token": str, "expires_at": datetime_utc}, } kvstore_schema = { diff --git a/server/fishtest/templates/forgot_password.mak b/server/fishtest/templates/forgot_password.mak new file mode 100644 index 0000000000..9ccb839ddc --- /dev/null +++ b/server/fishtest/templates/forgot_password.mak @@ -0,0 +1,36 @@ +<%inherit file="base.mak"/> + + + +
+
+

Reset your password

+
+ Enter the email linked to your account and we'll send a reset link. +
+
+ +
+
+ + +
+ + +
+ + +
diff --git a/server/fishtest/templates/login.mak b/server/fishtest/templates/login.mak index bf9683755c..71e8c5cdb6 100644 --- a/server/fishtest/templates/login.mak +++ b/server/fishtest/templates/login.mak @@ -58,6 +58,10 @@ + +
+ Reset password +
diff --git a/server/fishtest/templates/reset_password.mak b/server/fishtest/templates/reset_password.mak new file mode 100644 index 0000000000..da5515d7db --- /dev/null +++ b/server/fishtest/templates/reset_password.mak @@ -0,0 +1,55 @@ +<%inherit file="base.mak"/> + + + +
+
+

Choose a new password

+
+ +
+
+
+ + +
+ + + +
+ +
+
+ + +
+ + + +
+ + +
+
+ + diff --git a/server/fishtest/views.py b/server/fishtest/views.py index dfb16a4de1..fe4491851a 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -5,7 +5,8 @@ import json import os import re -from datetime import UTC, datetime +import secrets +from datetime import UTC, datetime, timedelta from pathlib import Path import bson @@ -150,6 +151,99 @@ def login(request): return {} +@view_config( + route_name="forgot_password", + renderer="forgot_password.mak", + require_csrf=True, + request_method=("GET", "POST"), +) +def forgot_password(request): + if request.method == "POST": + email = request.POST.get("email", "").strip() + email_is_valid, validated_email = email_valid(email) + if not email_is_valid: + request.session.flash("Error! Invalid email: " + validated_email, "error") + return {} + + user = request.userdb.find_by_email(validated_email) + if user is not None: + token = secrets.token_urlsafe(32) + expires_at = datetime.now(UTC) + timedelta(hours=1) + user["password_reset"] = {"token": token, "expires_at": expires_at} + request.userdb.save_user(user) + reset_url = request.route_url("reset_password", token=token) + body = ( + "We received a request to reset your Fishtest password.\n\n" + f"Reset link: {reset_url}\n\n" + "If you did not request a reset, you can ignore this email." + ) + try: + request.email_sender.send( + user["username"], + user["email"], + "Fishtest password reset", + body, + ) + except Exception as e: + print("failed to send email") + print(e) + request.session.flash( + "Unable to send reset email. Please try again later.", "error" + ) + return {} + + request.session.flash( + "If that email exists, a reset link has been sent.", "info" + ) + return {} + + +@view_config( + route_name="reset_password", + renderer="reset_password.mak", + require_csrf=True, + request_method=("GET", "POST"), +) +def reset_password(request): + token = request.matchdict.get("token", "") + if not token: + raise HTTPNotFound() + + user = request.userdb.users.find_one({"password_reset.token": token}) + if not user: + request.session.flash("Invalid or expired reset link.", "error") + return HTTPFound(location=request.route_url("login")) + + reset_info = user.get("password_reset", {}) + expires_at = reset_info.get("expires_at") + if not expires_at or expires_at < datetime.now(UTC): + user.pop("password_reset", None) + request.userdb.save_user(user) + request.session.flash("Reset link has expired.", "error") + return HTTPFound(location=request.route_url("forgot_password")) + + if request.method == "POST": + new_password = request.POST.get("password", "").strip() + new_password_verify = request.POST.get("password2", "").strip() + if new_password != new_password_verify: + request.session.flash("Error! Matching verify password required", "error") + return {"token": token} + strong_password, password_err = password_strength( + new_password, user["username"], user["email"] + ) + if not strong_password: + request.session.flash("Error! Weak password: " + password_err, "error") + return {"token": token} + + user["password"] = new_password + user.pop("password_reset", None) + request.userdb.save_user(user) + request.session.flash("Success! Password updated. Please login.") + return HTTPFound(location=request.route_url("login")) + + return {"token": token} + + # Note that the allowed length of mailto URLs on Chrome/Windows is severely # limited. From b4836ff07fa2c27b812d788c661fe4b6f0cc1e00 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:03:37 +0100 Subject: [PATCH 02/24] update error message --- server/fishtest/views.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/server/fishtest/views.py b/server/fishtest/views.py index fe4491851a..a926abca18 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -185,12 +185,7 @@ def forgot_password(request): body, ) except Exception as e: - print("failed to send email") - print(e) - request.session.flash( - "Unable to send reset email. Please try again later.", "error" - ) - return {} + print(f"failed to send password reset email to {validated_email}: {e}") request.session.flash( "If that email exists, a reset link has been sent.", "info" From 6cba9812c7157498615c39b3475e5b10195f2135 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:08:29 +0100 Subject: [PATCH 03/24] update reset flow --- server/fishtest/views.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/server/fishtest/views.py b/server/fishtest/views.py index a926abca18..be228081e9 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -204,19 +204,22 @@ def reset_password(request): if not token: raise HTTPNotFound() - user = request.userdb.users.find_one({"password_reset.token": token}) + now = datetime.now(UTC) + user = request.userdb.users.find_one( + {"password_reset.token": token, "password_reset.expires_at": {"$gte": now}} + ) if not user: + expired_cleanup = request.userdb.users.update_one( + {"password_reset.token": token, "password_reset.expires_at": {"$lt": now}}, + {"$unset": {"password_reset": ""}}, + ) + if expired_cleanup.matched_count: + request.userdb.clear_cache() + request.session.flash("Reset link has expired.", "error") + return HTTPFound(location=request.route_url("forgot_password")) request.session.flash("Invalid or expired reset link.", "error") return HTTPFound(location=request.route_url("login")) - reset_info = user.get("password_reset", {}) - expires_at = reset_info.get("expires_at") - if not expires_at or expires_at < datetime.now(UTC): - user.pop("password_reset", None) - request.userdb.save_user(user) - request.session.flash("Reset link has expired.", "error") - return HTTPFound(location=request.route_url("forgot_password")) - if request.method == "POST": new_password = request.POST.get("password", "").strip() new_password_verify = request.POST.get("password2", "").strip() @@ -230,9 +233,14 @@ def reset_password(request): request.session.flash("Error! Weak password: " + password_err, "error") return {"token": token} - user["password"] = new_password - user.pop("password_reset", None) - request.userdb.save_user(user) + update_result = request.userdb.users.update_one( + {"_id": user["_id"], "password_reset.token": token}, + {"$set": {"password": new_password}, "$unset": {"password_reset": ""}}, + ) + if update_result.modified_count == 0: + request.session.flash("Reset link has expired.", "error") + return HTTPFound(location=request.route_url("forgot_password")) + request.userdb.clear_cache() request.session.flash("Success! Password updated. Please login.") return HTTPFound(location=request.route_url("login")) From 0325a09f823a755347d44d535668dc8e68a1791b Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:09:01 +0100 Subject: [PATCH 04/24] request route url --- server/fishtest/templates/forgot_password.mak | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/fishtest/templates/forgot_password.mak b/server/fishtest/templates/forgot_password.mak index 9ccb839ddc..c0775c9b5b 100644 --- a/server/fishtest/templates/forgot_password.mak +++ b/server/fishtest/templates/forgot_password.mak @@ -31,6 +31,6 @@ From fb2885610fc8b064b7c766b6c8a03cfdf635d636 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:10:29 +0100 Subject: [PATCH 05/24] update email body --- server/fishtest/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/fishtest/views.py b/server/fishtest/views.py index be228081e9..d90355e0ad 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -175,7 +175,11 @@ def forgot_password(request): body = ( "We received a request to reset your Fishtest password.\n\n" f"Reset link: {reset_url}\n\n" - "If you did not request a reset, you can ignore this email." + "This link will expire in 1 hour.\n" + "For your security, do not share this link with anyone, as it can " + "be used to change your password.\n\n" + "If you did not request a password reset, you can ignore this " + "email or contact the site administrators for assistance." ) try: request.email_sender.send( From cf7d6115b0cfcd139052fb9e249e32d17ac8d94a Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:12:40 +0100 Subject: [PATCH 06/24] env check --- server/fishtest/emailer.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/fishtest/emailer.py b/server/fishtest/emailer.py index 6fd6eb1e1d..352f49b69d 100644 --- a/server/fishtest/emailer.py +++ b/server/fishtest/emailer.py @@ -22,6 +22,18 @@ def __init__( self.from_email = from_email or os.getenv("FISHTEST_RESEND_FROM_EMAIL") self.from_name = from_name or os.getenv("FISHTEST_RESEND_FROM_NAME", "Fishtest") self.session = session or requests.Session() + missing_settings = [] + if not self.api_key: + missing_settings.append("FISHTEST_RESEND_API_KEY") + if not self.from_email: + missing_settings.append("FISHTEST_RESEND_FROM_EMAIL") + if missing_settings: + print( + "Email sending is not configured; missing {}.".format( + ", ".join(missing_settings) + ), + flush=True, + ) def _from_field(self): if not self.from_email: From bd9ab6116fc6e000b949558f06538b3cffacf28a Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:15:40 +0100 Subject: [PATCH 07/24] move logic into userdb --- server/fishtest/userdb.py | 22 ++++++++++++++++++++++ server/fishtest/views.py | 17 ++++++----------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/server/fishtest/userdb.py b/server/fishtest/userdb.py index 656204866f..b3358d3e60 100644 --- a/server/fishtest/userdb.py +++ b/server/fishtest/userdb.py @@ -137,6 +137,28 @@ def save_user(self, user): self.last_blocked_time = 0 self.clear_cache() + def set_password_reset(self, user, token, expires_at): + user["password_reset"] = {"token": token, "expires_at": expires_at} + self.save_user(user) + + def clear_expired_password_reset(self, token, now): + result = self.users.update_one( + {"password_reset.token": token, "password_reset.expires_at": {"$lt": now}}, + {"$unset": {"password_reset": ""}}, + ) + if result.matched_count: + self.clear_cache() + return result + + def update_password_with_reset_token(self, user_id, token, new_password): + result = self.users.update_one( + {"_id": user_id, "password_reset.token": token}, + {"$set": {"password": new_password}, "$unset": {"password_reset": ""}}, + ) + if result.modified_count: + self.clear_cache() + return result + def remove_user(self, user, rejector): result = self.users.delete_one({"_id": user["_id"]}) if result.deleted_count > 0: diff --git a/server/fishtest/views.py b/server/fishtest/views.py index d90355e0ad..5e2233dc21 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -169,8 +169,7 @@ def forgot_password(request): if user is not None: token = secrets.token_urlsafe(32) expires_at = datetime.now(UTC) + timedelta(hours=1) - user["password_reset"] = {"token": token, "expires_at": expires_at} - request.userdb.save_user(user) + request.userdb.set_password_reset(user, token, expires_at) reset_url = request.route_url("reset_password", token=token) body = ( "We received a request to reset your Fishtest password.\n\n" @@ -213,12 +212,8 @@ def reset_password(request): {"password_reset.token": token, "password_reset.expires_at": {"$gte": now}} ) if not user: - expired_cleanup = request.userdb.users.update_one( - {"password_reset.token": token, "password_reset.expires_at": {"$lt": now}}, - {"$unset": {"password_reset": ""}}, - ) + expired_cleanup = request.userdb.clear_expired_password_reset(token, now) if expired_cleanup.matched_count: - request.userdb.clear_cache() request.session.flash("Reset link has expired.", "error") return HTTPFound(location=request.route_url("forgot_password")) request.session.flash("Invalid or expired reset link.", "error") @@ -237,14 +232,14 @@ def reset_password(request): request.session.flash("Error! Weak password: " + password_err, "error") return {"token": token} - update_result = request.userdb.users.update_one( - {"_id": user["_id"], "password_reset.token": token}, - {"$set": {"password": new_password}, "$unset": {"password_reset": ""}}, + update_result = request.userdb.update_password_with_reset_token( + user["_id"], + token, + new_password, ) if update_result.modified_count == 0: request.session.flash("Reset link has expired.", "error") return HTTPFound(location=request.route_url("forgot_password")) - request.userdb.clear_cache() request.session.flash("Success! Password updated. Please login.") return HTTPFound(location=request.route_url("login")) From 5eb0ba16c72f1849664133980f27778bc65edf8a Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:15:53 +0100 Subject: [PATCH 08/24] add comment --- server/fishtest/emailer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/fishtest/emailer.py b/server/fishtest/emailer.py index 352f49b69d..fe81b1c622 100644 --- a/server/fishtest/emailer.py +++ b/server/fishtest/emailer.py @@ -42,9 +42,10 @@ def _from_field(self): return f"{self.from_name} <{self.from_email}>" return self.from_email + # ideally only allow a limited number of emails per user per time period def send( self, - username, + _username, to_email, subject, text, From e51e5e0be5fd3ab5eb99c74ddfaf621ae6f2af8b Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:16:27 +0100 Subject: [PATCH 09/24] update forgot link --- server/fishtest/templates/login.mak | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/fishtest/templates/login.mak b/server/fishtest/templates/login.mak index 71e8c5cdb6..a9a1b31955 100644 --- a/server/fishtest/templates/login.mak +++ b/server/fishtest/templates/login.mak @@ -60,7 +60,7 @@ From badade7453df905770ceed21dd627d6939183878 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:17:10 +0100 Subject: [PATCH 10/24] update error --- server/fishtest/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/fishtest/views.py b/server/fishtest/views.py index 5e2233dc21..9253886539 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -216,7 +216,10 @@ def reset_password(request): if expired_cleanup.matched_count: request.session.flash("Reset link has expired.", "error") return HTTPFound(location=request.route_url("forgot_password")) - request.session.flash("Invalid or expired reset link.", "error") + request.session.flash( + "Invalid reset link. It may have been replaced by a newer reset request.", + "error", + ) return HTTPFound(location=request.route_url("login")) if request.method == "POST": From 8e31d8228a0bba962c59a09689f410cdd5328beb Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:28:11 +0100 Subject: [PATCH 11/24] add unit tests --- server/tests/test_users.py | 166 ++++++++++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 2 deletions(-) diff --git a/server/tests/test_users.py b/server/tests/test_users.py index 1543ed3fed..62ac7be914 100644 --- a/server/tests/test_users.py +++ b/server/tests/test_users.py @@ -1,8 +1,9 @@ +import secrets import unittest -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta import util -from fishtest.views import login, signup +from fishtest.views import forgot_password, login, reset_password, signup from pyramid import testing @@ -109,5 +110,166 @@ def tearDown(self): testing.tearDown() +class _DummyEmailSender: + def __init__(self, should_fail=False): + self.should_fail = should_fail + self.sent = [] + + def send(self, username, to_email, subject, text, html=None, reply_to=None): + if self.should_fail: + raise Exception("boom") + self.sent.append( + { + "username": username, + "to_email": to_email, + "subject": subject, + "text": text, + "html": html, + "reply_to": reply_to, + } + ) + return {"ok": True} + + +class ForgotResetPasswordTest(unittest.TestCase): + def setUp(self): + self.rundb = util.get_rundb() + self.config = testing.setUp() + self.config.add_route("forgot_password", "/forgot_password") + self.config.add_route("reset_password", "/reset_password/{token}") + self.config.add_route("login", "/login") + self.test_user = { + "username": "ResetUser", + "password": "secret", + "email": "reset@user.net", + "tests_repo": "https://github.com/official-stockfish/Stockfish", + } + self.rundb.userdb.create_user(**self.test_user) + + def tearDown(self): + self.rundb.userdb.users.delete_many({"username": self.test_user["username"]}) + self.rundb.userdb.user_cache.delete_many( + {"username": self.test_user["username"]} + ) + testing.tearDown() + + def test_forgot_password_valid_email(self): + email_sender = _DummyEmailSender() + request = testing.DummyRequest( + userdb=self.rundb.userdb, + email_sender=email_sender, + method="POST", + params={"email": self.test_user["email"]}, + ) + forgot_password(request) + self.assertEqual(len(email_sender.sent), 1) + user = self.rundb.userdb.find_by_email(self.test_user["email"]) + self.assertIn("password_reset", user) + self.assertIn("token", user["password_reset"]) + self.assertIn("expires_at", user["password_reset"]) + self.assertTrue(user["password_reset"]["expires_at"] > datetime.now(UTC)) + self.assertTrue( + "If that email exists, a reset link has been sent." + in request.session.pop_flash("info") + ) + + def test_forgot_password_invalid_email(self): + email_sender = _DummyEmailSender() + request = testing.DummyRequest( + userdb=self.rundb.userdb, + email_sender=email_sender, + method="POST", + params={"email": "not-an-email"}, + ) + forgot_password(request) + self.assertEqual(len(email_sender.sent), 0) + self.assertTrue( + "Error! Invalid email:" in request.session.pop_flash("error")[0] + ) + + def test_forgot_password_email_send_error(self): + email_sender = _DummyEmailSender(should_fail=True) + request = testing.DummyRequest( + userdb=self.rundb.userdb, + email_sender=email_sender, + method="POST", + params={"email": self.test_user["email"]}, + ) + forgot_password(request) + user = self.rundb.userdb.find_by_email(self.test_user["email"]) + self.assertIn("password_reset", user) + self.assertTrue( + "If that email exists, a reset link has been sent." + in request.session.pop_flash("info") + ) + + def test_reset_password_expired_token(self): + token = secrets.token_urlsafe(16) + user = self.rundb.userdb.find_by_email(self.test_user["email"]) + expires_at = datetime.now(UTC) - timedelta(minutes=1) + self.rundb.userdb.set_password_reset(user, token, expires_at) + request = testing.DummyRequest( + userdb=self.rundb.userdb, + method="GET", + matchdict={"token": token}, + ) + response = reset_password(request) + self.assertEqual(response.location, request.route_url("forgot_password")) + user = self.rundb.userdb.find_by_email(self.test_user["email"]) + self.assertNotIn("password_reset", user) + self.assertTrue( + "Reset link has expired." in request.session.pop_flash("error")[0] + ) + + def test_reset_password_token_invalid_after_use(self): + token = secrets.token_urlsafe(16) + user = self.rundb.userdb.find_by_email(self.test_user["email"]) + expires_at = datetime.now(UTC) + timedelta(hours=1) + self.rundb.userdb.set_password_reset(user, token, expires_at) + new_password = "CorrectHorseBatteryStaple123!@#" + request = testing.DummyRequest( + userdb=self.rundb.userdb, + method="POST", + matchdict={"token": token}, + params={"password": new_password, "password2": new_password}, + ) + response = reset_password(request) + self.assertEqual(response.location, request.route_url("login")) + user = self.rundb.userdb.find_by_email(self.test_user["email"]) + self.assertNotIn("password_reset", user) + self.assertEqual(user["password"], new_password) + + request = testing.DummyRequest( + userdb=self.rundb.userdb, + method="GET", + matchdict={"token": token}, + ) + response = reset_password(request) + self.assertEqual(response.location, request.route_url("login")) + self.assertTrue( + "Invalid reset link. It may have been replaced by a newer reset request." + in request.session.pop_flash("error")[0] + ) + + def test_reset_password_weak_password(self): + token = "weak-token" + user = self.rundb.userdb.find_by_email(self.test_user["email"]) + expires_at = datetime.now(UTC) + timedelta(hours=1) + self.rundb.userdb.set_password_reset(user, token, expires_at) + request = testing.DummyRequest( + userdb=self.rundb.userdb, + method="POST", + matchdict={"token": token}, + params={"password": "short", "password2": "short"}, + ) + response = reset_password(request) + self.assertEqual(response, {"token": token}) + user = self.rundb.userdb.find_by_email(self.test_user["email"]) + self.assertIn("password_reset", user) + self.assertTrue( + "Error! Weak password:" in request.session.pop_flash("error")[0] + ) + + if __name__ == "__main__": unittest.main() From 1831d198b00a850f24ec710b20a9d0c901c629b5 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:47:37 +0100 Subject: [PATCH 12/24] add pattern --- server/fishtest/templates/reset_password.mak | 1 + 1 file changed, 1 insertion(+) diff --git a/server/fishtest/templates/reset_password.mak b/server/fishtest/templates/reset_password.mak index da5515d7db..c320240c5c 100644 --- a/server/fishtest/templates/reset_password.mak +++ b/server/fishtest/templates/reset_password.mak @@ -18,6 +18,7 @@ id="password" name="password" placeholder="New Password" + pattern=".{8,}" title="Eight or more characters: a password too simple or trivial to guess will be rejected" autocomplete="new-password" required From d99f0830a85d4a742f0cb92a4f2c2d7a05583db3 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 17:52:01 +0100 Subject: [PATCH 13/24] update tests --- server/fishtest/views.py | 4 ++++ server/tests/test_users.py | 38 ++++++++++++++++---------------------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/server/fishtest/views.py b/server/fishtest/views.py index 9253886539..632e4099c9 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -158,6 +158,10 @@ def login(request): request_method=("GET", "POST"), ) def forgot_password(request): + userid = request.authenticated_userid + if userid: + return home(request) + if request.method == "POST": email = request.POST.get("email", "").strip() email_is_valid, validated_email = email_valid(email) diff --git a/server/tests/test_users.py b/server/tests/test_users.py index 62ac7be914..4ee20a1a84 100644 --- a/server/tests/test_users.py +++ b/server/tests/test_users.py @@ -167,10 +167,10 @@ def test_forgot_password_valid_email(self): self.assertIn("password_reset", user) self.assertIn("token", user["password_reset"]) self.assertIn("expires_at", user["password_reset"]) - self.assertTrue(user["password_reset"]["expires_at"] > datetime.now(UTC)) - self.assertTrue( - "If that email exists, a reset link has been sent." - in request.session.pop_flash("info") + self.assertGreater(user["password_reset"]["expires_at"], datetime.now(UTC)) + self.assertIn( + "If that email exists, a reset link has been sent.", + request.session.pop_flash("info")[0], ) def test_forgot_password_invalid_email(self): @@ -183,9 +183,7 @@ def test_forgot_password_invalid_email(self): ) forgot_password(request) self.assertEqual(len(email_sender.sent), 0) - self.assertTrue( - "Error! Invalid email:" in request.session.pop_flash("error")[0] - ) + self.assertIn("Error! Invalid email:", request.session.pop_flash("error")[0]) def test_forgot_password_email_send_error(self): email_sender = _DummyEmailSender(should_fail=True) @@ -198,13 +196,13 @@ def test_forgot_password_email_send_error(self): forgot_password(request) user = self.rundb.userdb.find_by_email(self.test_user["email"]) self.assertIn("password_reset", user) - self.assertTrue( - "If that email exists, a reset link has been sent." - in request.session.pop_flash("info") + self.assertIn( + "If that email exists, a reset link has been sent.", + request.session.pop_flash("info")[0], ) def test_reset_password_expired_token(self): - token = secrets.token_urlsafe(16) + token = secrets.token_urlsafe(32) user = self.rundb.userdb.find_by_email(self.test_user["email"]) expires_at = datetime.now(UTC) - timedelta(minutes=1) self.rundb.userdb.set_password_reset(user, token, expires_at) @@ -217,12 +215,10 @@ def test_reset_password_expired_token(self): self.assertEqual(response.location, request.route_url("forgot_password")) user = self.rundb.userdb.find_by_email(self.test_user["email"]) self.assertNotIn("password_reset", user) - self.assertTrue( - "Reset link has expired." in request.session.pop_flash("error")[0] - ) + self.assertIn("Reset link has expired.", request.session.pop_flash("error")[0]) def test_reset_password_token_invalid_after_use(self): - token = secrets.token_urlsafe(16) + token = secrets.token_urlsafe(32) user = self.rundb.userdb.find_by_email(self.test_user["email"]) expires_at = datetime.now(UTC) + timedelta(hours=1) self.rundb.userdb.set_password_reset(user, token, expires_at) @@ -246,13 +242,13 @@ def test_reset_password_token_invalid_after_use(self): ) response = reset_password(request) self.assertEqual(response.location, request.route_url("login")) - self.assertTrue( - "Invalid reset link. It may have been replaced by a newer reset request." - in request.session.pop_flash("error")[0] + self.assertIn( + "Invalid reset link. It may have been replaced by a newer reset request.", + request.session.pop_flash("error")[0], ) def test_reset_password_weak_password(self): - token = "weak-token" + token = secrets.token_urlsafe(32) user = self.rundb.userdb.find_by_email(self.test_user["email"]) expires_at = datetime.now(UTC) + timedelta(hours=1) self.rundb.userdb.set_password_reset(user, token, expires_at) @@ -266,9 +262,7 @@ def test_reset_password_weak_password(self): self.assertEqual(response, {"token": token}) user = self.rundb.userdb.find_by_email(self.test_user["email"]) self.assertIn("password_reset", user) - self.assertTrue( - "Error! Weak password:" in request.session.pop_flash("error")[0] - ) + self.assertIn("Error! Weak password:", request.session.pop_flash("error")[0]) if __name__ == "__main__": From 5165930329c1f8d1d78ecd1edf766b4529e56121 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 18:02:46 +0100 Subject: [PATCH 14/24] update tests --- server/tests/test_users.py | 54 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/server/tests/test_users.py b/server/tests/test_users.py index 4ee20a1a84..e1af310142 100644 --- a/server/tests/test_users.py +++ b/server/tests/test_users.py @@ -1,6 +1,7 @@ import secrets import unittest from datetime import UTC, datetime, timedelta +from unittest.mock import patch import util from fishtest.views import forgot_password, login, reset_password, signup @@ -185,6 +186,25 @@ def test_forgot_password_invalid_email(self): self.assertEqual(len(email_sender.sent), 0) self.assertIn("Error! Invalid email:", request.session.pop_flash("error")[0]) + def test_forgot_password_nonexistent_email(self): + email_sender = _DummyEmailSender() + request = testing.DummyRequest( + userdb=self.rundb.userdb, + email_sender=email_sender, + method="POST", + params={"email": "missing-user@example.net"}, + ) + with patch( + "fishtest.views.email_valid", + return_value=(True, "missing-user@example.net"), + ): + forgot_password(request) + self.assertEqual(len(email_sender.sent), 0) + self.assertIn( + "If that email exists, a reset link has been sent.", + request.session.pop_flash("info")[0], + ) + def test_forgot_password_email_send_error(self): email_sender = _DummyEmailSender(should_fail=True) request = testing.DummyRequest( @@ -217,6 +237,20 @@ def test_reset_password_expired_token(self): self.assertNotIn("password_reset", user) self.assertIn("Reset link has expired.", request.session.pop_flash("error")[0]) + def test_reset_password_invalid_token(self): + token = secrets.token_urlsafe(32) + request = testing.DummyRequest( + userdb=self.rundb.userdb, + method="GET", + matchdict={"token": token}, + ) + response = reset_password(request) + self.assertEqual(response.location, request.route_url("login")) + self.assertIn( + "Invalid reset link. It may have been replaced by a newer reset request.", + request.session.pop_flash("error")[0], + ) + def test_reset_password_token_invalid_after_use(self): token = secrets.token_urlsafe(32) user = self.rundb.userdb.find_by_email(self.test_user["email"]) @@ -264,6 +298,26 @@ def test_reset_password_weak_password(self): self.assertIn("password_reset", user) self.assertIn("Error! Weak password:", request.session.pop_flash("error")[0]) + def test_reset_password_mismatch(self): + token = secrets.token_urlsafe(32) + user = self.rundb.userdb.find_by_email(self.test_user["email"]) + expires_at = datetime.now(UTC) + timedelta(hours=1) + self.rundb.userdb.set_password_reset(user, token, expires_at) + request = testing.DummyRequest( + userdb=self.rundb.userdb, + method="POST", + matchdict={"token": token}, + params={"password": "MismatchPassword123!", "password2": "Different123!"}, + ) + response = reset_password(request) + self.assertEqual(response, {"token": token}) + user = self.rundb.userdb.find_by_email(self.test_user["email"]) + self.assertNotEqual(user["password"], "MismatchPassword123!") + self.assertIn( + "Error! Matching verify password required", + request.session.pop_flash("error")[0], + ) + if __name__ == "__main__": unittest.main() From 0e5d13158dc8573be9d05af723a95b8c7b423cfd Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 19:17:14 +0100 Subject: [PATCH 15/24] code reviews --- server/fishtest/templates/reset_password.mak | 8 +++ server/fishtest/views.py | 57 ++++++++++++-------- server/tests/test_users.py | 5 +- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/server/fishtest/templates/reset_password.mak b/server/fishtest/templates/reset_password.mak index c320240c5c..17143668cd 100644 --- a/server/fishtest/templates/reset_password.mak +++ b/server/fishtest/templates/reset_password.mak @@ -4,6 +4,11 @@ document.title = "Reset Password | Stockfish Testing"; + +<%block name="head"> + + +

Choose a new password

@@ -49,6 +54,9 @@
+
+ diff --git a/server/fishtest/views.py b/server/fishtest/views.py index 632e4099c9..650f8cfc25 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -41,6 +41,7 @@ from vtjson import ValidationError, union, validate HTTP_TIMEOUT = 15.0 +PASSWORD_RESET_EXPIRY_HOURS = 1 def pagination(page_idx, num, page_size, query_params): @@ -78,6 +79,29 @@ def pagination(page_idx, num, page_size, query_params): return pages +def run_captcha(request): + secret = os.environ.get("FISHTEST_CAPTCHA_SECRET") + if not secret: + print("FISHTEST_CAPTCHA_SECRET is missing.", flush=True) + else: + payload = { + "secret": secret, + "response": request.POST.get("g-recaptcha-response", ""), + "remoteip": request.remote_addr, + } + response = requests.post( + "https://www.google.com/recaptcha/api/siteverify", + data=payload, + timeout=HTTP_TIMEOUT, + ).json() + if "success" not in response or not response["success"]: + if "error-codes" in response: + print(response["error-codes"]) + request.session.flash("Captcha failed", "error") + return False + return True + + @notfound_view_config(renderer="notfound.mak") def notfound_view(request): request.response.status = 404 @@ -172,7 +196,9 @@ def forgot_password(request): user = request.userdb.find_by_email(validated_email) if user is not None: token = secrets.token_urlsafe(32) - expires_at = datetime.now(UTC) + timedelta(hours=1) + expires_at = datetime.now(UTC) + timedelta( + hours=PASSWORD_RESET_EXPIRY_HOURS + ) request.userdb.set_password_reset(user, token, expires_at) reset_url = request.route_url("reset_password", token=token) body = ( @@ -239,13 +265,19 @@ def reset_password(request): request.session.flash("Error! Weak password: " + password_err, "error") return {"token": token} + if not run_captcha(request): + return {"token": token} + update_result = request.userdb.update_password_with_reset_token( user["_id"], token, new_password, ) if update_result.modified_count == 0: - request.session.flash("Reset link has expired.", "error") + request.session.flash( + "Unable to reset password. The reset link may have expired or already been used.", + "error", + ) return HTTPFound(location=request.route_url("forgot_password")) request.session.flash("Success! Password updated. Please login.") return HTTPFound(location=request.route_url("login")) @@ -498,25 +530,8 @@ def signup(request): request.session.flash(error, "error") return {} - secret = os.environ.get("FISHTEST_CAPTCHA_SECRET") - if not secret: - print("FISHTEST_CAPTCHA_SECRET is missing.", flush=True) - else: - payload = { - "secret": secret, - "response": request.POST.get("g-recaptcha-response", ""), - "remoteip": request.remote_addr, - } - response = requests.post( - "https://www.google.com/recaptcha/api/siteverify", - data=payload, - timeout=HTTP_TIMEOUT, - ).json() - if "success" not in response or not response["success"]: - if "error-codes" in response: - print(response["error-codes"]) - request.session.flash("Captcha failed", "error") - return {} + if not run_captcha(request): + return {} result = request.userdb.create_user( username=signup_username, diff --git a/server/tests/test_users.py b/server/tests/test_users.py index e1af310142..e0b33f4bf3 100644 --- a/server/tests/test_users.py +++ b/server/tests/test_users.py @@ -235,7 +235,10 @@ def test_reset_password_expired_token(self): self.assertEqual(response.location, request.route_url("forgot_password")) user = self.rundb.userdb.find_by_email(self.test_user["email"]) self.assertNotIn("password_reset", user) - self.assertIn("Reset link has expired.", request.session.pop_flash("error")[0]) + self.assertIn( + "Unable to reset password. The reset link may have expired or already been used.", + request.session.pop_flash("error")[0], + ) def test_reset_password_invalid_token(self): token = secrets.token_urlsafe(32) From 08c921fd31ecf7492f3fb6bb488f11e60b294abb Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 19:17:51 +0100 Subject: [PATCH 16/24] remove user --- server/fishtest/emailer.py | 1 - server/fishtest/views.py | 1 - 2 files changed, 2 deletions(-) diff --git a/server/fishtest/emailer.py b/server/fishtest/emailer.py index fe81b1c622..ef7d367303 100644 --- a/server/fishtest/emailer.py +++ b/server/fishtest/emailer.py @@ -45,7 +45,6 @@ def _from_field(self): # ideally only allow a limited number of emails per user per time period def send( self, - _username, to_email, subject, text, diff --git a/server/fishtest/views.py b/server/fishtest/views.py index 650f8cfc25..cc431f9aea 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -212,7 +212,6 @@ def forgot_password(request): ) try: request.email_sender.send( - user["username"], user["email"], "Fishtest password reset", body, From a842ed4bac08b45a2de746b31c4e610d5fc8829e Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 19:20:07 +0100 Subject: [PATCH 17/24] remove comment --- server/fishtest/emailer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server/fishtest/emailer.py b/server/fishtest/emailer.py index ef7d367303..e9f544cc74 100644 --- a/server/fishtest/emailer.py +++ b/server/fishtest/emailer.py @@ -42,7 +42,6 @@ def _from_field(self): return f"{self.from_name} <{self.from_email}>" return self.from_email - # ideally only allow a limited number of emails per user per time period def send( self, to_email, From 28c8428ac96d3bd4e65b19c881477003a92ac06e Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 19:20:52 +0100 Subject: [PATCH 18/24] fix test --- server/tests/test_users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tests/test_users.py b/server/tests/test_users.py index e0b33f4bf3..982ea1b2a6 100644 --- a/server/tests/test_users.py +++ b/server/tests/test_users.py @@ -236,7 +236,7 @@ def test_reset_password_expired_token(self): user = self.rundb.userdb.find_by_email(self.test_user["email"]) self.assertNotIn("password_reset", user) self.assertIn( - "Unable to reset password. The reset link may have expired or already been used.", + "Reset link has expired.", request.session.pop_flash("error")[0], ) From 7c8a67abd09ed96c6b9d0c9bbfcca5c719d289d6 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 19:36:09 +0100 Subject: [PATCH 19/24] fix tests --- server/fishtest/templates/forgot_password.mak | 7 +++++++ server/fishtest/templates/reset_password.mak | 8 -------- server/fishtest/views.py | 7 ++++--- server/tests/test_users.py | 13 +++++++++++-- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/server/fishtest/templates/forgot_password.mak b/server/fishtest/templates/forgot_password.mak index c0775c9b5b..2f9fd140e1 100644 --- a/server/fishtest/templates/forgot_password.mak +++ b/server/fishtest/templates/forgot_password.mak @@ -4,6 +4,10 @@ document.title = "Forgot Password | Stockfish Testing"; +<%block name="head"> + + +

Reset your password

@@ -27,6 +31,9 @@
+
+ diff --git a/server/fishtest/templates/reset_password.mak b/server/fishtest/templates/reset_password.mak index 17143668cd..c320240c5c 100644 --- a/server/fishtest/templates/reset_password.mak +++ b/server/fishtest/templates/reset_password.mak @@ -4,11 +4,6 @@ document.title = "Reset Password | Stockfish Testing"; - -<%block name="head"> - - -

Choose a new password

@@ -54,9 +49,6 @@
-
- diff --git a/server/fishtest/views.py b/server/fishtest/views.py index cc431f9aea..307bc14894 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -100,6 +100,7 @@ def run_captcha(request): request.session.flash("Captcha failed", "error") return False return True + return True @notfound_view_config(renderer="notfound.mak") @@ -187,6 +188,9 @@ def forgot_password(request): return home(request) if request.method == "POST": + if not run_captcha(request): + return {} + email = request.POST.get("email", "").strip() email_is_valid, validated_email = email_valid(email) if not email_is_valid: @@ -264,9 +268,6 @@ def reset_password(request): request.session.flash("Error! Weak password: " + password_err, "error") return {"token": token} - if not run_captcha(request): - return {"token": token} - update_result = request.userdb.update_password_with_reset_token( user["_id"], token, diff --git a/server/tests/test_users.py b/server/tests/test_users.py index 982ea1b2a6..6ab888c5cc 100644 --- a/server/tests/test_users.py +++ b/server/tests/test_users.py @@ -116,12 +116,11 @@ def __init__(self, should_fail=False): self.should_fail = should_fail self.sent = [] - def send(self, username, to_email, subject, text, html=None, reply_to=None): + def send(self, to_email, subject, text, html=None, reply_to=None): if self.should_fail: raise Exception("boom") self.sent.append( { - "username": username, "to_email": to_email, "subject": subject, "text": text, @@ -161,6 +160,7 @@ def test_forgot_password_valid_email(self): email_sender=email_sender, method="POST", params={"email": self.test_user["email"]}, + remote_addr="127.0.0.1", ) forgot_password(request) self.assertEqual(len(email_sender.sent), 1) @@ -181,6 +181,7 @@ def test_forgot_password_invalid_email(self): email_sender=email_sender, method="POST", params={"email": "not-an-email"}, + remote_addr="127.0.0.1", ) forgot_password(request) self.assertEqual(len(email_sender.sent), 0) @@ -193,6 +194,7 @@ def test_forgot_password_nonexistent_email(self): email_sender=email_sender, method="POST", params={"email": "missing-user@example.net"}, + remote_addr="127.0.0.1", ) with patch( "fishtest.views.email_valid", @@ -212,6 +214,7 @@ def test_forgot_password_email_send_error(self): email_sender=email_sender, method="POST", params={"email": self.test_user["email"]}, + remote_addr="127.0.0.1", ) forgot_password(request) user = self.rundb.userdb.find_by_email(self.test_user["email"]) @@ -230,6 +233,7 @@ def test_reset_password_expired_token(self): userdb=self.rundb.userdb, method="GET", matchdict={"token": token}, + remote_addr="127.0.0.1", ) response = reset_password(request) self.assertEqual(response.location, request.route_url("forgot_password")) @@ -246,6 +250,7 @@ def test_reset_password_invalid_token(self): userdb=self.rundb.userdb, method="GET", matchdict={"token": token}, + remote_addr="127.0.0.1", ) response = reset_password(request) self.assertEqual(response.location, request.route_url("login")) @@ -265,6 +270,7 @@ def test_reset_password_token_invalid_after_use(self): method="POST", matchdict={"token": token}, params={"password": new_password, "password2": new_password}, + remote_addr="127.0.0.1", ) response = reset_password(request) self.assertEqual(response.location, request.route_url("login")) @@ -276,6 +282,7 @@ def test_reset_password_token_invalid_after_use(self): userdb=self.rundb.userdb, method="GET", matchdict={"token": token}, + remote_addr="127.0.0.1", ) response = reset_password(request) self.assertEqual(response.location, request.route_url("login")) @@ -294,6 +301,7 @@ def test_reset_password_weak_password(self): method="POST", matchdict={"token": token}, params={"password": "short", "password2": "short"}, + remote_addr="127.0.0.1", ) response = reset_password(request) self.assertEqual(response, {"token": token}) @@ -311,6 +319,7 @@ def test_reset_password_mismatch(self): method="POST", matchdict={"token": token}, params={"password": "MismatchPassword123!", "password2": "Different123!"}, + remote_addr="127.0.0.1", ) response = reset_password(request) self.assertEqual(response, {"token": token}) From d9fa8acae8bae8537281e89293aa3279300ae7b9 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 20:04:16 +0100 Subject: [PATCH 20/24] fix flash output --- server/fishtest/emailer.py | 4 +++- server/fishtest/views.py | 4 +--- server/tests/test_users.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/fishtest/emailer.py b/server/fishtest/emailer.py index e9f544cc74..9aa9a68ee1 100644 --- a/server/fishtest/emailer.py +++ b/server/fishtest/emailer.py @@ -19,7 +19,9 @@ def __init__( session=None, ): self.api_key = api_key or os.getenv("FISHTEST_RESEND_API_KEY") - self.from_email = from_email or os.getenv("FISHTEST_RESEND_FROM_EMAIL") + self.from_email = from_email or os.getenv( + "FISHTEST_RESEND_FROM_EMAIL", "fishtest@resend.dev" + ) self.from_name = from_name or os.getenv("FISHTEST_RESEND_FROM_NAME", "Fishtest") self.session = session or requests.Session() missing_settings = [] diff --git a/server/fishtest/views.py b/server/fishtest/views.py index 307bc14894..115bb286e5 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -223,9 +223,7 @@ def forgot_password(request): except Exception as e: print(f"failed to send password reset email to {validated_email}: {e}") - request.session.flash( - "If that email exists, a reset link has been sent.", "info" - ) + request.session.flash("If that email exists, a reset link has been sent.") return {} diff --git a/server/tests/test_users.py b/server/tests/test_users.py index 6ab888c5cc..a25feac3a6 100644 --- a/server/tests/test_users.py +++ b/server/tests/test_users.py @@ -171,7 +171,7 @@ def test_forgot_password_valid_email(self): self.assertGreater(user["password_reset"]["expires_at"], datetime.now(UTC)) self.assertIn( "If that email exists, a reset link has been sent.", - request.session.pop_flash("info")[0], + request.session.pop_flash()[0], ) def test_forgot_password_invalid_email(self): @@ -204,7 +204,7 @@ def test_forgot_password_nonexistent_email(self): self.assertEqual(len(email_sender.sent), 0) self.assertIn( "If that email exists, a reset link has been sent.", - request.session.pop_flash("info")[0], + request.session.pop_flash()[0], ) def test_forgot_password_email_send_error(self): @@ -221,7 +221,7 @@ def test_forgot_password_email_send_error(self): self.assertIn("password_reset", user) self.assertIn( "If that email exists, a reset link has been sent.", - request.session.pop_flash("info")[0], + request.session.pop_flash()[0], ) def test_reset_password_expired_token(self): From ef34da41ab3f57395a7a809cba094641fdf50cb5 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 20:07:25 +0100 Subject: [PATCH 21/24] redirect --- server/fishtest/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/fishtest/views.py b/server/fishtest/views.py index 115bb286e5..f26b5bc83d 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -223,7 +223,10 @@ def forgot_password(request): except Exception as e: print(f"failed to send password reset email to {validated_email}: {e}") - request.session.flash("If that email exists, a reset link has been sent.") + request.session.flash( + "If that email exists, a reset link has been sent, please check your inbox." + ) + return HTTPFound(location=request.route_url("home")) return {} From 4da6b198f8a98304bd9a2cb4bf16968eeedc459c Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 20:14:37 +0100 Subject: [PATCH 22/24] invalidate first open --- server/fishtest/schemas.py | 6 +++++- server/fishtest/userdb.py | 13 +++++++++++++ server/fishtest/views.py | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/server/fishtest/schemas.py b/server/fishtest/schemas.py index 41ea0c8217..4d8acaa7ee 100644 --- a/server/fishtest/schemas.py +++ b/server/fishtest/schemas.py @@ -108,7 +108,11 @@ def size_is_length(pgn_doc): "groups": [str, ...], "tests_repo": union(github_repo, ""), "machine_limit": uint, - "password_reset?": {"token": str, "expires_at": datetime_utc}, + "password_reset?": { + "token": str, + "expires_at": datetime_utc, + "opened_at?": datetime_utc, + }, } kvstore_schema = { diff --git a/server/fishtest/userdb.py b/server/fishtest/userdb.py index b3358d3e60..1a1bcd68d3 100644 --- a/server/fishtest/userdb.py +++ b/server/fishtest/userdb.py @@ -159,6 +159,19 @@ def update_password_with_reset_token(self, user_id, token, new_password): self.clear_cache() return result + def mark_password_reset_opened(self, user_id, token, opened_at): + result = self.users.update_one( + { + "_id": user_id, + "password_reset.token": token, + "password_reset.opened_at": {"$exists": False}, + }, + {"$set": {"password_reset.opened_at": opened_at}}, + ) + if result.modified_count: + self.clear_cache() + return result + def remove_user(self, user, rejector): result = self.users.delete_one({"_id": user["_id"]}) if result.deleted_count > 0: diff --git a/server/fishtest/views.py b/server/fishtest/views.py index f26b5bc83d..bc77e73f0c 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -237,6 +237,13 @@ def forgot_password(request): request_method=("GET", "POST"), ) def reset_password(request): + userid = request.authenticated_userid + if userid: + request.session.flash( + "You are already logged in. Use profile settings to change your password." + ) + return home(request) + token = request.matchdict.get("token", "") if not token: raise HTTPNotFound() @@ -256,6 +263,16 @@ def reset_password(request): ) return HTTPFound(location=request.route_url("login")) + if request.method == "GET": + mark_opened = request.userdb.mark_password_reset_opened(user["_id"], token, now) + if mark_opened.modified_count == 0: + request.session.flash( + "Reset link has already been opened. Please request a new one.", + "error", + ) + return HTTPFound(location=request.route_url("forgot_password")) + return {"token": token} + if request.method == "POST": new_password = request.POST.get("password", "").strip() new_password_verify = request.POST.get("password2", "").strip() From cf6f5adeb5c0e79e204095dc5974917714013af5 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 20:28:34 +0100 Subject: [PATCH 23/24] make reset page one time view only --- server/fishtest/schemas.py | 1 + server/fishtest/templates/reset_password.mak | 1 + server/fishtest/userdb.py | 27 +++++++++++++ server/fishtest/views.py | 42 ++++++++++++++++---- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/server/fishtest/schemas.py b/server/fishtest/schemas.py index 4d8acaa7ee..ca945e8866 100644 --- a/server/fishtest/schemas.py +++ b/server/fishtest/schemas.py @@ -112,6 +112,7 @@ def size_is_length(pgn_doc): "token": str, "expires_at": datetime_utc, "opened_at?": datetime_utc, + "form_token?": str, }, } diff --git a/server/fishtest/templates/reset_password.mak b/server/fishtest/templates/reset_password.mak index c320240c5c..3a60765cf5 100644 --- a/server/fishtest/templates/reset_password.mak +++ b/server/fishtest/templates/reset_password.mak @@ -10,6 +10,7 @@
+
0: diff --git a/server/fishtest/views.py b/server/fishtest/views.py index bc77e73f0c..c7df3f4a93 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -264,31 +264,59 @@ def reset_password(request): return HTTPFound(location=request.route_url("login")) if request.method == "GET": - mark_opened = request.userdb.mark_password_reset_opened(user["_id"], token, now) - if mark_opened.modified_count == 0: + if user.get("password_reset", {}).get("form_token"): request.session.flash( "Reset link has already been opened. Please request a new one.", "error", ) return HTTPFound(location=request.route_url("forgot_password")) - return {"token": token} + form_token = secrets.token_urlsafe(32) + set_form = request.userdb.set_password_reset_form_token( + user["_id"], token, form_token, now + ) + if set_form.modified_count == 0: + request.session.flash( + "Reset link has already been opened. Please request a new one.", + "error", + ) + return HTTPFound(location=request.route_url("forgot_password")) + return {"token": token, "form_token": form_token} if request.method == "POST": + form_token = request.POST.get("form_token", "").strip() + if not form_token: + request.session.flash( + "Reset form is invalid. Please request a new reset link.", + "error", + ) + return HTTPFound(location=request.route_url("forgot_password")) + user = request.userdb.users.find_one( + { + "password_reset.form_token": form_token, + "password_reset.expires_at": {"$gte": now}, + } + ) + if not user: + request.session.flash( + "Reset link has expired or already been used.", + "error", + ) + return HTTPFound(location=request.route_url("forgot_password")) new_password = request.POST.get("password", "").strip() new_password_verify = request.POST.get("password2", "").strip() if new_password != new_password_verify: request.session.flash("Error! Matching verify password required", "error") - return {"token": token} + return {"token": token, "form_token": form_token} strong_password, password_err = password_strength( new_password, user["username"], user["email"] ) if not strong_password: request.session.flash("Error! Weak password: " + password_err, "error") - return {"token": token} + return {"token": token, "form_token": form_token} - update_result = request.userdb.update_password_with_reset_token( + update_result = request.userdb.update_password_with_reset_form_token( user["_id"], - token, + form_token, new_password, ) if update_result.modified_count == 0: From 8ec0346840af0530c3d6f46f9330f17029e41235 Mon Sep 17 00:00:00 2001 From: Disservin Date: Sun, 28 Dec 2025 20:36:14 +0100 Subject: [PATCH 24/24] fix tests --- server/fishtest/views.py | 2 +- server/tests/test_users.py | 53 ++++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/server/fishtest/views.py b/server/fishtest/views.py index c7df3f4a93..426b0fc75f 100644 --- a/server/fishtest/views.py +++ b/server/fishtest/views.py @@ -226,7 +226,7 @@ def forgot_password(request): request.session.flash( "If that email exists, a reset link has been sent, please check your inbox." ) - return HTTPFound(location=request.route_url("home")) + return HTTPFound(location=request.route_url("tests")) return {} diff --git a/server/tests/test_users.py b/server/tests/test_users.py index a25feac3a6..de4c13e088 100644 --- a/server/tests/test_users.py +++ b/server/tests/test_users.py @@ -138,6 +138,7 @@ def setUp(self): self.config.add_route("forgot_password", "/forgot_password") self.config.add_route("reset_password", "/reset_password/{token}") self.config.add_route("login", "/login") + self.config.add_route("tests", "/tests") self.test_user = { "username": "ResetUser", "password": "secret", @@ -170,7 +171,7 @@ def test_forgot_password_valid_email(self): self.assertIn("expires_at", user["password_reset"]) self.assertGreater(user["password_reset"]["expires_at"], datetime.now(UTC)) self.assertIn( - "If that email exists, a reset link has been sent.", + "If that email exists, a reset link has been sent, please check your inbox.", request.session.pop_flash()[0], ) @@ -203,7 +204,7 @@ def test_forgot_password_nonexistent_email(self): forgot_password(request) self.assertEqual(len(email_sender.sent), 0) self.assertIn( - "If that email exists, a reset link has been sent.", + "If that email exists, a reset link has been sent, please check your inbox.", request.session.pop_flash()[0], ) @@ -220,7 +221,7 @@ def test_forgot_password_email_send_error(self): user = self.rundb.userdb.find_by_email(self.test_user["email"]) self.assertIn("password_reset", user) self.assertIn( - "If that email exists, a reset link has been sent.", + "If that email exists, a reset link has been sent, please check your inbox.", request.session.pop_flash()[0], ) @@ -264,12 +265,24 @@ def test_reset_password_token_invalid_after_use(self): user = self.rundb.userdb.find_by_email(self.test_user["email"]) expires_at = datetime.now(UTC) + timedelta(hours=1) self.rundb.userdb.set_password_reset(user, token, expires_at) + get_request = testing.DummyRequest( + userdb=self.rundb.userdb, + method="GET", + matchdict={"token": token}, + remote_addr="127.0.0.1", + ) + get_response = reset_password(get_request) + form_token = get_response["form_token"] new_password = "CorrectHorseBatteryStaple123!@#" request = testing.DummyRequest( userdb=self.rundb.userdb, method="POST", matchdict={"token": token}, - params={"password": new_password, "password2": new_password}, + params={ + "password": new_password, + "password2": new_password, + "form_token": form_token, + }, remote_addr="127.0.0.1", ) response = reset_password(request) @@ -296,15 +309,27 @@ def test_reset_password_weak_password(self): user = self.rundb.userdb.find_by_email(self.test_user["email"]) expires_at = datetime.now(UTC) + timedelta(hours=1) self.rundb.userdb.set_password_reset(user, token, expires_at) + get_request = testing.DummyRequest( + userdb=self.rundb.userdb, + method="GET", + matchdict={"token": token}, + remote_addr="127.0.0.1", + ) + get_response = reset_password(get_request) + form_token = get_response["form_token"] request = testing.DummyRequest( userdb=self.rundb.userdb, method="POST", matchdict={"token": token}, - params={"password": "short", "password2": "short"}, + params={ + "password": "short", + "password2": "short", + "form_token": form_token, + }, remote_addr="127.0.0.1", ) response = reset_password(request) - self.assertEqual(response, {"token": token}) + self.assertEqual(response, {"token": token, "form_token": form_token}) user = self.rundb.userdb.find_by_email(self.test_user["email"]) self.assertIn("password_reset", user) self.assertIn("Error! Weak password:", request.session.pop_flash("error")[0]) @@ -314,15 +339,27 @@ def test_reset_password_mismatch(self): user = self.rundb.userdb.find_by_email(self.test_user["email"]) expires_at = datetime.now(UTC) + timedelta(hours=1) self.rundb.userdb.set_password_reset(user, token, expires_at) + get_request = testing.DummyRequest( + userdb=self.rundb.userdb, + method="GET", + matchdict={"token": token}, + remote_addr="127.0.0.1", + ) + get_response = reset_password(get_request) + form_token = get_response["form_token"] request = testing.DummyRequest( userdb=self.rundb.userdb, method="POST", matchdict={"token": token}, - params={"password": "MismatchPassword123!", "password2": "Different123!"}, + params={ + "password": "MismatchPassword123!", + "password2": "Different123!", + "form_token": form_token, + }, remote_addr="127.0.0.1", ) response = reset_password(request) - self.assertEqual(response, {"token": token}) + self.assertEqual(response, {"token": token, "form_token": form_token}) user = self.rundb.userdb.find_by_email(self.test_user["email"]) self.assertNotEqual(user["password"], "MismatchPassword123!") self.assertIn(