feat(auth): reset a forgotten password from an emailed link (milestone 497)
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:
+135
-4
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user