feat(auth): reset a forgotten password from an emailed link (milestone 497)
CI / lint (push) Failing after 3s
CI / integration (push) Successful in 1m37s
CI / unit (push) Failing after 1m43s
CI / publish (push) Skipped

Step #5429 "Reset by email: request, link and new-password page" of
milestone 497 "Passwords: change your own, and reset it by email".

- Sign-in page: "Forgot your password?" while a mail server is set.
- /forgot takes a username or email and answers the same either way; only
  an active local account with a real address gets mail. Requests count
  per address on their own throttle, so they never lock out sign-in.
- password_resets (migration 0039): a one-time key, stored as its sha256,
  working for an hour; asking again replaces it.
- /reset/<key>: the new password, then signed straight in; every other
  browser is signed out (sessions_since). Bad keys count as invite keys do.
- Email "[Steward] Reset your password", in the account's own zone.
- Audited: user.password_reset_requested, user.password_reset_by_email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:17:24 -04:00
co-authored by Claude Opus 5.5
parent ea523b8c37
commit 593b583d23
17 changed files with 528 additions and 7 deletions
+135 -4
View File
@@ -324,8 +324,9 @@ async def _inviter_name(db, invite) -> str:
return who.username if who else "An admin"
def _invite_throttled() -> str | None:
"""Why this address may not try an invite key now (too many bad ones), or None."""
def _too_many_tries() -> str | None:
"""Why this address may not try an invite or reset key now (too many bad
ones), or None."""
from quart import current_app
wait_s = login_throttle.retry_after(
client_ip(), current_app.config.get("LOGIN_MAX_FAILURES", 5),
@@ -341,7 +342,7 @@ def _invite_throttled() -> str | None:
async def invite(key: str):
from quart import current_app
from steward.core.invites import find_pending
if wait := _invite_throttled():
if wait := _too_many_tries():
return await _invite_page(key, error=wait, status=429)
async with current_app.db_sessionmaker() as db:
found = await find_pending(db, key)
@@ -363,7 +364,7 @@ async def invite_accept(key: str):
from steward.core.audit import log_audit
from steward.core.invites import find_pending, signup_problem, taken
from steward.settings.users import _hash
if wait := _invite_throttled():
if wait := _too_many_tries():
return await _invite_page(key, error=wait, status=429)
form = await request.form
username = form.get("username") or ""
@@ -402,3 +403,133 @@ async def invite_accept(key: str):
detail={"username": user.username, "role": role, "invite": invite_id,
"invited_by": inviter})
return redirect(url_for("dashboard.index"))
# ── Reset a password by email (milestone 497) ────────────────────────────────
_RESET_GONE = "This reset link has expired or been used. Ask for a new one."
_RESET_SENT = ("If that names a Steward account with a password and an email address, "
"a link to choose a new password is on its way to that address. It works for an hour.")
def _mail_ready() -> bool:
from quart import current_app
return bool(current_app.config.get("SMTP", {}).get("host"))
async def _forgot_page(sent: bool = False, error: str | None = None, status: int = 200):
return await render_template("auth/forgot.html", mail_ready=_mail_ready(), sent=sent,
error=error), status
@auth_bp.get("/forgot")
@public_route("asks for a password-reset email")
async def forgot():
return await _forgot_page()
@auth_bp.post("/forgot")
@public_route("asks for a password-reset email")
async def forgot_post():
"""Email a reset link to the account the form names, if it may have one.
The answer is the same either way, so the page can't be used to find
accounts; every request from an address counts toward its limit."""
from quart import current_app
from steward.auth.throttle import reset_throttle
from steward.core import mail
from steward.core.audit import log_audit
from steward.core.formatting import zone
from steward.core.password_resets import account_for, issue, link
from steward.core.settings import public_base_url
if not _mail_ready():
return await _forgot_page(status=400)
ip = client_ip()
if reset_throttle.retry_after(ip, current_app.config.get("LOGIN_MAX_FAILURES", 5),
current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15) * 60):
return await _forgot_page(error="Too many requests from this address. Try again later.", status=429)
reset_throttle.failed(ip)
name = (await request.form).get("name") or ""
async with current_app.db_sessionmaker() as db:
async with db.begin():
user = await account_for(db, name)
if user is not None:
reset, key = await issue(db, user)
to, username, user_id, tz = user.email, user.username, user.id, user.time_zone
if user is None:
return await _forgot_page(sent=True)
cfg = current_app.config.get("SMTP", {})
subject, text, html = mail.reset_email(username, reset.expires_at,
link(public_base_url(request), key), zone(tz))
try:
await mail.send(cfg, [mail.message(cfg, to, subject, text, html)])
except Exception as exc: # the page still says the same; the log says why
current_app.logger.warning("Password reset email to %s failed: %s", to, exc)
await log_audit(current_app, user_id, username, "user.password_reset_requested", "user", user_id,
detail={"address": ip})
return await _forgot_page(sent=True)
async def _reset_page(key: str, username: str | None = None, error: str | None = None,
status: int = 200):
"""The new-password form while the link works, else one plain message."""
if username is None and error is None:
error = _RESET_GONE
return await render_template("auth/reset.html", key=key, username=username, error=error,
min_password=_min_password()), status
async def _reset_target(db, key: str, lock: bool = False):
"""(link, account) for a working key whose account may still be reset by
email; (None, None) otherwise."""
from steward.core.password_resets import find_pending
found = await find_pending(db, key, lock=lock)
user = await db.get(User, found.user_id) if found else None
if user is None or not user.is_active or user.auth_source != "local":
return None, None
return found, user
@auth_bp.get("/reset/<key>")
@public_route("a password-reset link: the key is the credential")
async def reset(key: str):
from quart import current_app
if wait := _too_many_tries():
return await _reset_page(key, error=wait, status=429)
async with current_app.db_sessionmaker() as db:
_, user = await _reset_target(db, key)
if user is None:
login_throttle.failed(client_ip())
return await _reset_page(key, status=404)
return await _reset_page(key, user.username)
@auth_bp.post("/reset/<key>")
@public_route("a password-reset link: the key is the credential")
async def reset_post(key: str):
"""Set the new password, use the link up, sign out every browser signed
in as the account and sign this one in, in one transaction."""
from quart import current_app
from steward.core.audit import log_audit
from steward.settings.users import _hash, password_problem
if wait := _too_many_tries():
return await _reset_page(key, error=wait, status=429)
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
found, user = await _reset_target(db, key, lock=True)
if user is None:
login_throttle.failed(client_ip())
return await _reset_page(key, status=404)
problem = password_problem(form.get("password") or "", form.get("confirm") or "")
if problem:
return await _reset_page(key, user.username, problem, 400)
now = datetime.now(timezone.utc)
user.password_hash = _hash(form.get("password"))
user.sessions_since = now
found.used_at = now
db.expunge(user)
login_throttle.succeeded(client_ip())
login_user(user)
await log_audit(current_app, user.id, user.username, "user.password_reset_by_email", "user", user.id,
detail={"username": user.username})
return redirect(url_for("dashboard.index"))
+4
View File
@@ -46,3 +46,7 @@ class LoginThrottle:
login_throttle = LoginThrottle()
# Password-reset emails asked for from one address (milestone 497): every
# request counts, matched or not, so it can't be used to find accounts, and
# kept apart from login_throttle so asking for links never locks out sign-in.
reset_throttle = LoginThrottle()