fix(auth): a cookie that lands after sign-out no longer signs the browser back in

A renewal answers with the session its request started with. Each session now
carries an id; signing out, or signing in as someone else, revokes it
(revoked_sessions, migration 0120), and a cookie carrying a revoked id is
refused. A cookie from before session ids gets one drawn from the cookie
itself. The race after log-out-everywhere fails closed and stays.
This commit is contained in:
2026-11-14 17:56:36 +01:00
parent d265c84c8b
commit 02714e4986
7 changed files with 164 additions and 8 deletions
+47 -2
View File
@@ -1,8 +1,11 @@
from datetime import datetime, timezone
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from starlette.requests import HTTPConnection
@@ -14,7 +17,7 @@ from app import mailtext
from app import sysconfig
from app.config import settings
from app.db import get_db
from app.models import Invite, LogEvent, User
from app.models import Invite, LogEvent, RevokedSession, User
from app.security import decrypt_optional
from app.utils import canonical_email
from app.refusal import refused
@@ -263,6 +266,7 @@ def stamp_session(request: Request, user: User) -> None:
"""Record the account's current `session_epoch` in the signed cookie, so `current_user` can later
reject the cookie once the epoch moves (password reset/change, or "log out everywhere")."""
request.session["epoch"] = user.session_epoch or 0
request.session.setdefault("sid", new_session_id())
request.session["renewed"] = int(datetime.now(timezone.utc).timestamp())
@@ -272,6 +276,13 @@ def stamp_session(request: Request, user: User) -> None:
# after the last renewal writes a fresh stamp, which re-signs the cookie for another 14 days. Only 14
# idle days end it; "log out everywhere" (the epoch) stays the way to revoke it. Once a day, not on
# every request: a Set-Cookie on each response would race between parallel requests for nothing.
#
# A renewal answers with the session its request STARTED with. Landing after a sign-out, that cookie
# would sign the browser back in, so each session carries an id (`sid`) and a sign-out revokes it
# (`revoke_session`). Landing after "log out everywhere" or a password change re-stamped this
# browser's cookie, it carries the old epoch and signs this browser out: that race fails closed (sign
# in again; nothing is reached), and is left so.
SESSION_MAX_AGE = 14 * 86400 # the cookie's life; `main` hands it to SessionMiddleware
_RENEW_AFTER_SECONDS = 86400
@@ -282,6 +293,36 @@ def _renew_session(request: Request) -> None:
request.session["renewed"] = now
def new_session_id() -> str:
return secrets.token_urlsafe(16)
def _session_id(request: Request) -> str:
"""This session's id. A cookie from before session ids gets one drawn from the cookie itself, so
parallel requests carrying it agree on the id a sign-out revokes, and two browsers do not."""
sid = request.session.get("sid")
if isinstance(sid, str):
return sid
raw = request.cookies.get("session", "")
sid = hashlib.sha256(raw.encode()).hexdigest()[:22]
request.session["sid"] = sid
return sid
def revoke_session(db: Session, request: Request) -> None:
"""Revoke this browser's session id, so a cookie carrying it that lands later is refused. Not
committed — the caller commits. A row is dropped once no cookie with its id can still be valid:
a refused cookie is never renewed, so it dies `SESSION_MAX_AGE` after it was signed, at most a
request's time after the revoke (a day of margin)."""
sid = request.session.get("sid")
if not isinstance(sid, str):
return
now = datetime.now(timezone.utc)
cutoff = now - timedelta(seconds=SESSION_MAX_AGE + 86400)
db.execute(delete(RevokedSession).where(RevokedSession.revoked_at < cutoff))
db.execute(pg_insert(RevokedSession).values(sid=sid, revoked_at=now).on_conflict_do_nothing())
# The account a tab was built for, sent with every request it makes (`identityHeaders`, the
# frontend). The cookie alone says who is asking, and one browser holds one account: a sign-in as
# someone else in another tab replaces it under every open tab. A tab still showing the previous
@@ -327,6 +368,10 @@ def _session_user(request: Request, db: Session, user_id: int | None) -> User:
if epoch is None or int(epoch) != (user.session_epoch or 0):
request.session.clear()
raise refused(401, "not_authenticated")
# A session signed out (or replaced by a sign-in as someone else) whose cookie landed late.
if db.get(RevokedSession, _session_id(request)) is not None:
request.session.clear()
raise refused(401, "not_authenticated")
_renew_session(request)
# Attribute this request's log lines to the account. Here, not in the request middleware:
# this is the one place a session becomes an account, and the middleware runs outside both the