feat(auth): SA4 — server-side session revocation via per-user session epoch

Signed client-side session cookies had no server-side kill switch: logout + password
reset couldn't evict a stolen/copied cookie (valid until expiry). Add User.session_epoch
(migration 0053), record it in the cookie at every login, and reject in current_user any
cookie whose recorded epoch is behind the account's current one.
- Bump the epoch on: password reset (kills ALL sessions — a reset is a compromise response),
  password change + a new 'Log out other sessions' action (both re-stamp the CURRENT cookie
  so the caller stays signed in, evicting only the others).
- Per-account epoch map in the session so one account's revocation doesn't evict the other
  signed-in accounts in the same browser wallet.
- Missing epoch (pre-SA4 cookie) is treated as 0, so the first bump revokes grandfathered
  sessions too.
- New POST /auth/logout-others + a Settings → Account 'Active sessions' button (trilingual).
This commit is contained in:
npeter83 2026-07-12 03:00:16 +02:00
parent a65915ea11
commit 95d1549570
8 changed files with 155 additions and 1 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:
@ -697,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(
@ -739,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:
@ -751,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")
@ -773,6 +800,22 @@ 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.")
bump_session_epoch(user)
_remember_epoch(request, user)
db.commit()
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
@ -828,7 +871,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
@ -845,6 +891,10 @@ 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.
bump_session_epoch(user)
_remember_epoch(request, user)
db.commit() db.commit()
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

@ -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

@ -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

@ -1394,6 +1394,8 @@ export const api = {
// Confirm an email-verification token the SPA read from the URL fragment (SB3). // Confirm an email-verification token the SPA read from the URL fragment (SB3).
verifyEmail: (token: string): Promise<{ ok: boolean }> => verifyEmail: (token: string): Promise<{ ok: boolean }> =>
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }), 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(