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
|
||||
|
||||
@@ -122,6 +122,56 @@ def test_deleting_the_account_signs_the_browser_out(client, make_user):
|
||||
assert "user_id" not in _session(client)
|
||||
|
||||
|
||||
_DAY = 86400
|
||||
_PROTECTED = "/api/me/notifications" # any route behind `current_user` that answers 200 offline
|
||||
|
||||
|
||||
def _signed_at(monkeypatch, at: int, payload: dict) -> str:
|
||||
"""A session cookie carrying `payload`, signed as if at the unix time `at`."""
|
||||
with monkeypatch.context() as m: # not `undo()`: that would also undo the fixtures' patches
|
||||
m.setattr(TimestampSigner, "get_timestamp", lambda self: at)
|
||||
return _cookie(payload)
|
||||
|
||||
|
||||
def _hold(client, cookie: str) -> None:
|
||||
"""Make `cookie` the browser's only session cookie (a plain `set` would sit beside the one the
|
||||
server set, under another domain)."""
|
||||
client.cookies.clear()
|
||||
client.cookies.set("session", cookie)
|
||||
|
||||
|
||||
def test_a_session_in_use_does_not_expire_after_the_cookie_lifetime(client, make_user, monkeypatch):
|
||||
# The cookie's signature is checked against a 14-day life, and Starlette re-signs it only when
|
||||
# the session changes. A browser in daily use must still stay signed in: a session that has not
|
||||
# been renewed for a day is renewed by the next authenticated request.
|
||||
make_user(email="slide@example.com", password=_PW)
|
||||
assert _login(client, "slide@example.com", _PW).status_code == 200
|
||||
now = int(TimestampSigner(str(settings.secret_key)).get_timestamp())
|
||||
payload = {**_session(client), "renewed": now - 2 * _DAY}
|
||||
old = _signed_at(monkeypatch, now - 13 * _DAY, payload)
|
||||
|
||||
_hold(client, old)
|
||||
r = client.get(_PROTECTED)
|
||||
assert r.status_code == 200
|
||||
renewed = r.cookies.get("session")
|
||||
assert renewed and renewed != old # the request re-signed the cookie
|
||||
|
||||
# Two days on, the cookie signed 13 days ago is past its 14 days; the renewed one is not.
|
||||
monkeypatch.setattr(TimestampSigner, "get_timestamp", lambda self: now + 2 * _DAY)
|
||||
_hold(client, old)
|
||||
assert client.get(_PROTECTED).status_code == 401
|
||||
_hold(client, renewed)
|
||||
assert client.get(_PROTECTED).status_code == 200
|
||||
|
||||
|
||||
def test_a_session_renewed_today_is_not_re_signed_on_every_request(client, make_user):
|
||||
make_user(email="slide2@example.com", password=_PW)
|
||||
assert _login(client, "slide2@example.com", _PW).status_code == 200
|
||||
r = client.get(_PROTECTED)
|
||||
assert r.status_code == 200
|
||||
assert "set-cookie" not in r.headers
|
||||
|
||||
|
||||
def _session(client) -> dict:
|
||||
"""The decoded payload of the client's session cookie."""
|
||||
raw = client.cookies.get("session")
|
||||
|
||||
Reference in New Issue
Block a user