Merge feature/auth-security: SB3 + SA4 (SA3 deferred)

SB3: reset/verify email tokens moved from URL query strings to the fragment (never
sent to the server / proxy logs / Referer); verify GET→POST with a legacy-GET fallback.
SA4: server-side session revocation via per-user session_epoch (migration 0053) — a
signed cookie records its epoch, current_user + the messages WS reject a stale-epoch
cookie. Bumped on password reset (all sessions), password change + a new 'Log out other
sessions' action (both keep the current session). E2E-verified; UAT passed.
SA3 (trusted-proxy for X-Forwarded-For) intentionally deferred to a prod-tested follow-up.
This commit is contained in:
npeter83 2026-07-12 03:25:54 +02:00
commit 2eca56d68a
10 changed files with 215 additions and 12 deletions

View file

@ -0,0 +1,27 @@
"""User.session_epoch for server-side session revocation (SA4)
A monotonic per-user counter. A signed session cookie records the epoch it was minted under;
current_user rejects any cookie whose epoch is stale. Bumped on password reset, password change,
or "log out everywhere" so a stolen/copied client-side cookie dies on those events instead of
staying valid until it expires. Existing rows default to 0 (matching a fresh cookie's absent/0 epoch).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0053_user_session_epoch"
down_revision: Union[str, None] = "0052_plex_show_meta"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("users", "session_epoch")

View file

@ -341,6 +341,7 @@ async def callback(
accounts.append(user.id) accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id request.session["user_id"] = user.id
_remember_epoch(request, user)
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
return RedirectResponse(url="/") return RedirectResponse(url="/")
@ -493,6 +494,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
# can't switch to / act as a previously signed-in account via the multi-account header. # can't switch to / act as a previously signed-in account via the multi-account header.
request.session.clear() request.session.clear()
request.session["user_id"] = demo.id request.session["user_id"] = demo.id
_remember_epoch(request, demo)
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
return {"authenticated": True} return {"authenticated": True}
return {"authenticated": False} return {"authenticated": False}
@ -505,6 +507,7 @@ def _establish_session(request: Request, user: User) -> None:
accounts.append(user.id) accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id request.session["user_id"] = user.id
_remember_epoch(request, user)
def _app_base() -> str: def _app_base() -> str:
@ -587,8 +590,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 +603,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 +680,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"}
@ -680,6 +700,7 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict
if user is None: if user is None:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.") raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
bump_session_epoch(user) # SA4: a reset (often a compromise response) kills all existing sessions.
# Burn any other outstanding reset tokens for this user. # Burn any other outstanding reset tokens for this user.
for row in db.execute( for row in db.execute(
select(AuthToken).where( select(AuthToken).where(
@ -722,6 +743,21 @@ def resolved_user_id(request: Request) -> tuple[int | None, bool]:
return default_id, True return default_id, True
def _remember_epoch(request: Request, user: User) -> None:
"""Record this account's current `session_epoch` in the signed cookie (SA4), so `current_user`
can later reject the cookie if the account's epoch was bumped (password reset/change, or
"log out everywhere"). Per-account so one account's revocation doesn't evict the others."""
epochs = dict(request.session.get("epochs") or {})
epochs[str(user.id)] = user.session_epoch
request.session["epochs"] = epochs
def bump_session_epoch(user: User) -> None:
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
minted before the bump carry a now-stale epoch and are rejected by `current_user`."""
user.session_epoch = (user.session_epoch or 0) + 1
def current_user(request: Request, db: Session = Depends(get_db)) -> User: def current_user(request: Request, db: Session = Depends(get_db)) -> User:
user_id, is_default = resolved_user_id(request) user_id, is_default = resolved_user_id(request)
if not user_id: if not user_id:
@ -734,6 +770,14 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
if is_default: if is_default:
request.session.clear() request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
epochs = request.session.get("epochs") or {}
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
if is_default:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
# Always keep the session default in the switchable list — covers sessions created before # Always keep the session default in the switchable list — covers sessions created before
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty. # multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
default_id = request.session.get("user_id") default_id = request.session.get("user_id")
@ -756,6 +800,24 @@ def optional_current_user(
return None return None
@router.post("/logout-others")
def logout_others(
request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies
are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged
in somewhere" / a suspected session compromise. The demo account (shared) is excluded."""
if user.is_demo:
raise HTTPException(status_code=403, detail="Not available in the demo account.")
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
# session at a newer epoch than the DB (see set_password).
bump_session_epoch(user)
db.commit()
_remember_epoch(request, user)
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
return {"ok": True}
def require_human(user: User = Depends(current_user)) -> User: def require_human(user: User = Depends(current_user)) -> User:
"""Reject the shared demo account from actions that need a real Google/YouTube identity """Reject the shared demo account from actions that need a real Google/YouTube identity
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has
@ -811,7 +873,10 @@ async def link_google(
@router.post("/set-password") @router.post("/set-password")
def set_password( def set_password(
payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db) payload: dict,
request: Request,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Set or change the signed-in account's password. Setting a first password (Google-only """Set or change the signed-in account's password. Setting a first password (Google-only
account) needs no current password the session already proves identity. Changing an account) needs no current password the session already proves identity. Changing an
@ -828,7 +893,12 @@ def set_password(
if not verify_password(payload.get("current_password") or "", user.password_hash): if not verify_password(payload.get("current_password") or "", user.password_hash):
raise HTTPException(status_code=403, detail="Current password is incorrect.") raise HTTPException(status_code=403, detail="Current password is incorrect.")
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
bump_session_epoch(user)
db.commit() db.commit()
_remember_epoch(request, user)
log.info("Password set/changed: uid=%s", user.id) log.info("Password set/changed: uid=%s", user.id)
return {"ok": True} return {"ok": True}

View file

@ -63,6 +63,11 @@ class User(Base, TimestampMixin):
is_suspended: Mapped[bool] = mapped_column( is_suspended: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
# current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those
# events (client-side signed cookies otherwise stay valid until they expire).
session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024)) avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")

View file

@ -381,7 +381,13 @@ async def messages_ws(ws: WebSocket) -> None:
return return
db = SessionLocal() db = SessionLocal()
try: try:
ok = is_messageable_user(db.get(User, uid)) user = db.get(User, uid)
# SA4: honour server-side session revocation on the live channel too — reject a cookie whose
# recorded epoch is behind the account's current one (mirrors current_user). Without this a
# copied cookie could keep receiving pushes after a password reset / "log out everywhere".
epochs = sess.get("epochs") or {}
epoch_ok = user is not None and int(epochs.get(str(uid), 0)) == (user.session_epoch or 0)
ok = epoch_ok and is_messageable_user(user)
finally: finally:
db.close() db.close()
if not ok: if not ok:

View file

@ -308,11 +308,36 @@ function AccessRow({
function SignInMethods({ me }: { me: Me }) { function SignInMethods({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [current, setCurrent] = useState(""); const [current, setCurrent] = useState("");
const [next, setNext] = useState(""); const [next, setNext] = useState("");
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [sessBusy, setSessBusy] = useState(false);
// SA4: sign every OTHER session for this account out (this one stays). For a shared/left-behind
// device or a suspected compromise.
const logoutOthers = async () => {
if (
!(await confirm({
title: t("settings.account.sessions.confirmTitle"),
message: t("settings.account.sessions.confirmBody"),
confirmLabel: t("settings.account.sessions.action"),
danger: true,
}))
)
return;
setSessBusy(true);
try {
await api.logoutOthers();
notify({ level: "success", message: t("settings.account.sessions.done") });
} catch {
notify({ level: "error", message: t("settings.account.sessions.failed") });
} finally {
setSessBusy(false);
}
};
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -422,6 +447,24 @@ function SignInMethods({ me }: { me: Me }) {
</form> </form>
)} )}
</div> </div>
<div className="border-t border-border mt-1 pt-1">
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.sessions.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{t("settings.account.sessions.hint")}
</p>
</div>
<button
onClick={logoutOthers}
disabled={sessBusy}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition disabled:opacity-40"
>
{t("settings.account.sessions.action")}
</button>
</div>
</div>
</Section> </Section>
); );
} }

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ArrowRight, ArrowRight,
@ -259,22 +259,42 @@ 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). Fire ONCE: the token is
// single-use, so a double POST (StrictMode remount / any re-render) would 400 on the second call
// and wrongly flip the banner to "invalid".
const verifyFired = useRef(false);
useEffect(() => {
if (!verifyToken || verifyFired.current) return;
verifyFired.current = true;
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

@ -97,6 +97,15 @@
"saving": "Speichern…", "saving": "Speichern…",
"saved": "Passwort für {{email}} aktualisiert.", "saved": "Passwort für {{email}} aktualisiert.",
"failed": "Das Passwort konnte nicht aktualisiert werden." "failed": "Das Passwort konnte nicht aktualisiert werden."
},
"sessions": {
"title": "Aktive Sitzungen",
"hint": "Diese Kontoanmeldung überall sonst abmelden — praktisch, wenn du auf einem geteilten oder verlorenen Gerät angemeldet geblieben bist. Hier bleibst du angemeldet.",
"action": "Andere Sitzungen abmelden",
"confirmTitle": "Andere Sitzungen abmelden?",
"confirmBody": "Damit wirst du in allen anderen Browsern und auf allen anderen Geräten abgemeldet. Hier bleibst du angemeldet.",
"done": "Von allen anderen Sitzungen abgemeldet.",
"failed": "Andere Sitzungen konnten nicht abgemeldet werden. Bitte versuche es erneut."
} }
}, },
"demo": { "demo": {

View file

@ -97,6 +97,15 @@
"saving": "Saving…", "saving": "Saving…",
"saved": "Password updated for {{email}}.", "saved": "Password updated for {{email}}.",
"failed": "Couldn't update the password." "failed": "Couldn't update the password."
},
"sessions": {
"title": "Active sessions",
"hint": "Sign this account out everywhere else — handy if you left it signed in on a shared or lost device. You stay signed in here.",
"action": "Log out other sessions",
"confirmTitle": "Log out other sessions?",
"confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.",
"done": "Signed out of all other sessions.",
"failed": "Couldn't sign out other sessions. Please try again."
} }
}, },
"demo": { "demo": {

View file

@ -97,6 +97,15 @@
"saving": "Mentés…", "saving": "Mentés…",
"saved": "Jelszó frissítve ehhez: {{email}}.", "saved": "Jelszó frissítve ehhez: {{email}}.",
"failed": "Nem sikerült frissíteni a jelszót." "failed": "Nem sikerült frissíteni a jelszót."
},
"sessions": {
"title": "Aktív munkamenetek",
"hint": "Jelentkeztesd ki ezt a fiókot minden más helyen — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.",
"action": "Kijelentkezés máshonnan",
"confirmTitle": "Kijelentkezés a többi munkamenetből?",
"confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről. Itt bejelentkezve maradsz.",
"done": "Kijelentkeztetve minden más munkamenetből.",
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
} }
}, },
"demo": { "demo": {

View file

@ -1391,6 +1391,11 @@ 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 }),
// SA4: sign every OTHER session for this account out (keeps the current one).
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
// 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(