diff --git a/backend/alembic/versions/0053_user_session_epoch.py b/backend/alembic/versions/0053_user_session_epoch.py new file mode 100644 index 0000000..6f5c838 --- /dev/null +++ b/backend/alembic/versions/0053_user_session_epoch.py @@ -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") diff --git a/backend/app/auth.py b/backend/app/auth.py index 97bb25d..edbb458 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -341,6 +341,7 @@ async def callback( accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id + _remember_epoch(request, user) log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) 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. request.session.clear() request.session["user_id"] = demo.id + _remember_epoch(request, demo) log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) return {"authenticated": True} return {"authenticated": False} @@ -505,6 +507,7 @@ def _establish_session(request: Request, user: User) -> None: accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id + _remember_epoch(request, user) def _app_base() -> str: @@ -587,8 +590,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 +603,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 +680,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"} @@ -680,6 +700,7 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict if user is None: raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.") 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. for row in db.execute( select(AuthToken).where( @@ -722,6 +743,21 @@ def resolved_user_id(request: Request) -> tuple[int | None, bool]: 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: user_id, is_default = resolved_user_id(request) if not user_id: @@ -734,6 +770,14 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: if is_default: request.session.clear() 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 # multi-session existed (their account_ids was never seeded), so the switcher isn't empty. default_id = request.session.get("user_id") @@ -756,6 +800,24 @@ def optional_current_user( 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: """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 @@ -811,7 +873,10 @@ async def link_google( @router.post("/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: """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 @@ -828,7 +893,12 @@ def set_password( if not verify_password(payload.get("current_password") or "", user.password_hash): raise HTTPException(status_code=403, detail="Current password is incorrect.") 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() + _remember_epoch(request, user) log.info("Password set/changed: uid=%s", user.id) return {"ok": True} diff --git a/backend/app/models.py b/backend/app/models.py index d65ee87..9b31e67 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -63,6 +63,11 @@ class User(Base, TimestampMixin): is_suspended: Mapped[bool] = mapped_column( 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)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 53a68e8..420bd58 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -381,7 +381,13 @@ async def messages_ws(ws: WebSocket) -> None: return db = SessionLocal() 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: db.close() if not ok: diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 91c7e1e..0399ea3 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -308,11 +308,36 @@ function AccessRow({ function SignInMethods({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); const [open, setOpen] = useState(false); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [err, setErr] = useState(null); 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) => { e.preventDefault(); @@ -422,6 +447,24 @@ function SignInMethods({ me }: { me: Me }) { )} + +
+
+
+
{t("settings.account.sessions.title")}
+

+ {t("settings.account.sessions.hint")} +

+
+ +
+
); } diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index 56b3ce1..446630a 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowRight, @@ -259,22 +259,42 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose function AuthCard() { const { t } = useTranslation(); - // Snapshot the redirect params ONCE so we can clean them from the address bar below without - // dismissing the banners/reset-mode they drive (those read this stable snapshot, not the URL). + // Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below + // 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 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 verify = params.get("verify"); // "ok" | "invalid" const login = params.get("login"); // Google login blocked → "suspended" 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(() => 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(() => { - if (window.location.search) { + if (window.location.search || window.location.hash) { 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(resetToken ? "reset" : "signin"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index c441032..3425b9f 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -97,6 +97,15 @@ "saving": "Speichern…", "saved": "Passwort für {{email}} aktualisiert.", "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": { diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index cd9e2d2..c6c4ac0 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -97,6 +97,15 @@ "saving": "Saving…", "saved": "Password updated for {{email}}.", "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": { diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index c8dea12..1048ae0 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -97,6 +97,15 @@ "saving": "Mentés…", "saved": "Jelszó frissítve ehhez: {{email}}.", "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": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8b4c534..53e0de2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1391,6 +1391,11 @@ export const api = { req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }), confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> => 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). setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> => req(