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:
parent
9375b46bc8
commit
a65915ea11
3 changed files with 45 additions and 9 deletions
|
|
@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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("");
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue