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:
+47
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user