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 upsert_pending_invite(db, email) # admin-approval gate
if email_ok: if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL) 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( 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: if settings.admin_email_set:
background.add_task( background.add_task(
@ -598,9 +600,22 @@ def register(
return {"status": "ok"} 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") @router.get("/verify")
def verify_email(token: str, db: Session = Depends(get_db)): 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") user = _consume_token(db, token, "verify")
if user is None: if user is None:
return RedirectResponse(url="/?verify=invalid") 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() 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: if user is not None and user.password_hash and not user.is_demo:
raw = _issue_token(db, user, "reset", RESET_TTL) 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"} return {"status": "ok"}

View file

@ -259,22 +259,38 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
function AuthCard() { function AuthCard() {
const { t } = useTranslation(); const { t } = useTranslation();
// Snapshot the redirect params ONCE so we can clean them from the address bar below without // Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below
// dismissing the banners/reset-mode they drive (those read this stable snapshot, not the URL). // without dismissing the banners/reset-mode they drive. Secret tokens (reset, verify) ride the
// URL FRAGMENT so they never reach the server / proxy logs / Referer (SB3); plain status flags
// stay in the query string.
const [params] = useState(() => new URLSearchParams(window.location.search)); const [params] = useState(() => new URLSearchParams(window.location.search));
const resetToken = params.get("reset"); const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, "")));
const resetToken = hashParams.get("reset");
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
const bounced = params.get("access"); // Google denied → "requested" | "denied" const bounced = params.get("access"); // Google denied → "requested" | "denied"
const verify = params.get("verify"); // "ok" | "invalid"
const login = params.get("login"); // Google login blocked → "suspended" const login = params.get("login"); // Google login blocked → "suspended"
const deleted = params.get("deleted"); // self-service account deletion just completed const deleted = params.get("deleted"); // self-service account deletion just completed
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
// Strip the query string so the address bar stays clean (http://host/) after we've captured it. // Strip BOTH the query string and the fragment so the address bar stays clean and the token
// doesn't linger in history.
useEffect(() => { useEffect(() => {
if (window.location.search) { if (window.location.search || window.location.hash) {
window.history.replaceState(null, "", window.location.pathname); window.history.replaceState(null, "", window.location.pathname);
} }
}, []); }, []);
// Confirm a fragment verification token by POSTing it (new SB3 flow).
useEffect(() => {
if (!verifyToken) return;
api
.verifyEmail(verifyToken)
.then(() => setVerify("ok"))
.catch(() => setVerify("invalid"));
}, [verifyToken]);
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin"); const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");

View file

@ -1391,6 +1391,9 @@ export const api = {
req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }), req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> => confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }), req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
// Set or change the signed-in account's password (errors shown inline via quiet). // Set or change the signed-in account's password (errors shown inline via quiet).
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> => setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
req( req(