fix(auth): a session in use slides instead of expiring on day 14
Starlette re-signs the session cookie only when the session changes, so a browser in daily use was signed out 14 days after sign-in, mid-episode. The first authenticated request a day after the last renewal now writes a fresh stamp, which re-signs the cookie; only 14 idle days end a session. The epoch stays the revoke.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, Depends, HTTPException, Request
|
||||
from sqlalchemy import delete, func, select
|
||||
@@ -261,6 +263,23 @@ 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["renewed"] = int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
# The signed cookie lives 14 days (`SessionMiddleware`'s max_age, checked again on every read), and
|
||||
# Starlette re-signs it only when the session changes — a signed-in browser in daily use was signed
|
||||
# out on day 14, mid-episode. So the session is a sliding one: the first authenticated request a day
|
||||
# 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.
|
||||
_RENEW_AFTER_SECONDS = 86400
|
||||
|
||||
|
||||
def _renew_session(request: Request) -> None:
|
||||
now = int(datetime.now(timezone.utc).timestamp())
|
||||
renewed = request.session.get("renewed")
|
||||
if not isinstance(renewed, int) or now - renewed >= _RENEW_AFTER_SECONDS:
|
||||
request.session["renewed"] = now
|
||||
|
||||
|
||||
# The account a tab was built for, sent with every request it makes (`identityHeaders`, the
|
||||
@@ -308,6 +327,7 @@ 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")
|
||||
_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
|
||||
# session middleware and this task's context. `request.state` is shared across middleware layers
|
||||
|
||||
Reference in New Issue
Block a user