fix(auth): SB3 — keep reset/verify tokens out of URL query strings

Secret email tokens now ride the URL fragment (#reset=/#verify=), never the query
(?reset=/?token=): a fragment isn't sent to the server, so the token can't leak into
proxy/access logs or a Referer header.
- Reset: link → /#reset=; the SPA reads the token from location.hash and POSTs it
  (unchanged /password-reset/confirm).
- Verify: link → /#verify=; new POST /auth/verify (token in body). The legacy GET
  /auth/verify?token= is kept so pre-deploy emails in flight still work until they
  expire. The SPA reads the fragment token, POSTs it, shows ok/invalid.
- Welcome: read secret tokens from the fragment, status flags from the query; strip
  both after capture so nothing lingers in history.
This commit is contained in:
npeter83 2026-07-12 02:48:40 +02:00
parent 9375b46bc8
commit a65915ea11
3 changed files with 45 additions and 9 deletions

View file

@ -587,8 +587,10 @@ def register(
upsert_pending_invite(db, email) # admin-approval gate
if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL)
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer.
# The SPA reads the fragment and POSTs it to /auth/verify (SB3).
background.add_task(
email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}"
email_mod.send_verify_email, email, f"{_app_base()}/#verify={raw}"
)
if settings.admin_email_set:
background.add_task(
@ -598,9 +600,22 @@ def register(
return {"status": "ok"}
@router.post("/verify")
def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
on any invalid/expired/used token."""
user = _consume_token(db, payload.get("token"), "verify")
if user is None:
raise HTTPException(status_code=400, detail="This verification link is invalid or has expired.")
user.email_verified = True
db.commit()
return {"ok": True}
@router.get("/verify")
def verify_email(token: str, db: Session = Depends(get_db)):
"""Confirm an email-verification link, then bounce to a friendly status on the app."""
"""Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
fragment + POST flow above; this bounces to a friendly status for older links until they expire."""
user = _consume_token(db, token, "verify")
if user is None:
return RedirectResponse(url="/?verify=invalid")
@ -662,7 +677,9 @@ def password_reset_request(
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is not None and user.password_hash and not user.is_demo:
raw = _issue_token(db, user, "reset", RESET_TTL)
background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/?reset={raw}")
# Token in the URL FRAGMENT (#), not the query (?): a fragment is never sent to the
# server, so it can't leak into proxy/access logs or a Referer header (SB3).
background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/#reset={raw}")
return {"status": "ok"}