Source code for responder.ext.sessions

"""Server-side session storage for Responder.

The default cookie sessions store (signed) data in the cookie itself —
fine for small payloads, but capped at ~4KB and impossible to revoke
server-side. These backends keep only an opaque session ID in the cookie
and the data on the server::

    from responder.ext.sessions import MemorySessionBackend

    api = responder.API(session_backend=MemorySessionBackend())

    @api.route("/login", methods=["POST"])
    async def login(req, resp):
        req.session["user"] = "kenneth"

For multi-process deployments, use :class:`RedisSessionBackend`.

**What a session may hold.** Treat session values as JSON-shaped data —
dicts, lists, strings, numbers, booleans, ``None`` — so the same code works
on every backend. :class:`MemorySessionBackend` stores Python objects as-is
(anything goes, but nothing is checked), while the Redis backends serialize:
their default :class:`JSONSessionSerializer` extends plain JSON to also
round-trip ``datetime``, ``date``, ``time``, ``Decimal``, ``UUID``, ``set``,
``frozenset``, and ``bytes`` values. Tuples become lists on the Redis path
(a JSON limitation); anything else needs a custom ``serializer=``.
"""

from __future__ import annotations

import base64
import copy
import datetime as _dt
import decimal
import json
import logging
import os
import secrets
import threading
import time
import uuid as _uuid
from collections import OrderedDict
from http.cookies import SimpleCookie
from typing import Protocol, runtime_checkable

from starlette.concurrency import run_in_threadpool
from starlette.datastructures import MutableHeaders

from ..statics import DEFAULT_SECRET_KEY


@runtime_checkable
class SessionBackend(Protocol):
    """A synchronous server-side session store.

    Optionally implement ``touch(session_id, max_age)`` to slide the TTL of an
    unchanged session without re-serializing it.

    Session data should be treated as JSON-shaped (see the module docstring):
    backends are free to serialize, so code that stores arbitrary Python
    objects works only on stores that keep objects in memory.
    """

    def get(self, session_id: str) -> dict | None: ...
    def set(self, session_id: str, data: dict, max_age: int) -> None: ...
    def delete(self, session_id: str) -> None: ...


@runtime_checkable
class AsyncSessionBackend(Protocol):
    """An async-native server-side session store (awaited directly, no thread).

    Optionally implement ``atouch(session_id, max_age)`` for sliding TTL.
    """

    async def aget(self, session_id: str) -> dict | None: ...
    async def aset(self, session_id: str, data: dict, max_age: int) -> None: ...
    async def adelete(self, session_id: str) -> None: ...

logger = logging.getLogger("responder")

ENV_SECRET_KEY = "RESPONDER_SECRET_KEY"  # noqa: S105 - env var name, not a secret
MIN_KEY_LENGTH = 16


[docs] class SessionConfigError(ValueError): """Raised for an unsafe or contradictory session configuration."""
def resolve_secret_key(secret_key, *, sessions, debug): """Resolve the cookie-session signing key, securely by default. Order: explicit ``secret_key`` → ``RESPONDER_SECRET_KEY`` env → (for ``sessions="auto"``) a random per-process key with a loud warning. The old public ``"NOTASECRET"`` default is rejected outright. ``sessions=True`` with no key is a hard error (strict mode). """ if not secret_key: secret_key = os.environ.get(ENV_SECRET_KEY) or None if secret_key is not None: if secret_key == DEFAULT_SECRET_KEY: raise SessionConfigError( "secret_key='NOTASECRET' is the old public default and is no " "longer accepted — anyone can forge sessions signed with it. " 'Generate a real key: python -c "import secrets; ' 'print(secrets.token_urlsafe(32))" and pass API(secret_key=...) ' "or set RESPONDER_SECRET_KEY." ) if len(secret_key) < MIN_KEY_LENGTH: logger.warning( "Responder session secret_key is only %d chars; use >= %d " "random characters for a secure signature.", len(secret_key), MIN_KEY_LENGTH, ) return secret_key # No key anywhere. if sessions is True: # strict refuse mode raise SessionConfigError( "Cookie sessions are enabled (sessions=True) but no secret_key was " "set. Pass API(secret_key=...) or set RESPONDER_SECRET_KEY, or use " "sessions='auto' to auto-generate an ephemeral per-process key." ) key = secrets.token_urlsafe(32) # sessions == "auto" if debug: logger.warning( "Responder generated an ephemeral session key (debug); sessions " "reset on reload. Set secret_key for stable sessions." ) else: logger.warning( "Responder generated a RANDOM per-process session key because no " "secret_key was set. Sessions are securely signed but do NOT survive " "a restart and are NOT shared across workers (load-balanced users get " "logged out). Set API(secret_key=...) / RESPONDER_SECRET_KEY in " "production, or sessions=False." ) return key class MemorySessionBackend: """In-process session store. Sessions vanish on restart. Keys are bounded: at most ``max_keys`` sessions are kept, with the earliest-expiring entries evicted beyond the cap. This stops an attacker who rotates session cookies from growing process memory without bound — previously an abandoned session was only deleted when ``get()`` was called with that exact ID, so it lived until restart. Expired entries are also swept opportunistically on writes. The ``OrderedDict`` is kept ordered by expiry: only operations that refresh the stored expiry (``set``/``touch``) move an entry to the back. A plain ``get()`` must not — otherwise a nearly-expired but recently-read entry would sit at the back while a live session near the front gets evicted under cap pressure. """ def __init__(self, max_keys: int = 100_000): self._store: OrderedDict[str, tuple[dict, float]] = OrderedDict() self._lock = threading.Lock() self._max_keys = max_keys def _evict(self, now: float) -> None: """Sweep expired entries from the front (earliest expiry), then enforce the cap. The caller must hold the lock. """ while self._store: _, expires = next(iter(self._store.values())) if now > expires: self._store.popitem(last=False) else: break while self._max_keys is not None and len(self._store) > self._max_keys: self._store.popitem(last=False) def get(self, session_id): with self._lock: record = self._store.get(session_id) if record is None: return None data, expires = record if time.time() > expires: del self._store[session_id] return None # No move_to_end here: order == expiry order, and a read does # not extend the expiry (see class docstring). return data def set(self, session_id, data, max_age): now = time.time() with self._lock: self._store[session_id] = (data, now + max_age) self._store.move_to_end(session_id) self._evict(now) def touch(self, session_id, max_age): with self._lock: record = self._store.get(session_id) if record is not None: data, _ = record self._store[session_id] = (data, time.time() + max_age) self._store.move_to_end(session_id) def delete(self, session_id): with self._lock: self._store.pop(session_id, None) # Tag key marking a value the default serializer has type-encoded. Namespaced # to make collisions with real session data vanishingly unlikely. A user dict # that nonetheless carries this key is transparently escaped on encode and # restored on decode (see JSONSessionSerializer._escape), so round-trips stay # lossless and can't be mis-decoded or poisoned. _TYPE_TAG = "__responder.session.type__" class JSONSessionSerializer: """JSON session codec that round-trips common non-JSON types. The default ``serializer`` for :class:`RedisSessionBackend` and :class:`AsyncRedisSessionBackend`. On top of the JSON types it round-trips ``datetime.datetime``, ``datetime.date``, ``datetime.time``, ``decimal.Decimal``, ``uuid.UUID``, ``set``, ``frozenset``, and ``bytes`` — so sessions that work with :class:`MemorySessionBackend` in development keep working when Redis backs them in production. Values of those types are stored as small tagged JSON objects and revived on read. Remaining caveats (inherent to JSON): - tuples are stored as lists; - dict keys must be strings; - other custom objects still raise ``TypeError`` — plug in your own codec via the backends' ``serializer=`` parameter (any object with ``dumps(data) -> str | bytes`` and ``loads(raw) -> dict`` works). """ #: The exact types this codec revives, beyond native JSON. EXTRA_TYPES = ( _dt.datetime, _dt.date, _dt.time, decimal.Decimal, _uuid.UUID, set, frozenset, bytes, ) @staticmethod def _default(obj): # NOTE: datetime before date — datetime is a date subclass. if isinstance(obj, _dt.datetime): return {_TYPE_TAG: "datetime", "value": obj.isoformat()} if isinstance(obj, _dt.date): return {_TYPE_TAG: "date", "value": obj.isoformat()} if isinstance(obj, _dt.time): return {_TYPE_TAG: "time", "value": obj.isoformat()} if isinstance(obj, decimal.Decimal): return {_TYPE_TAG: "decimal", "value": str(obj)} if isinstance(obj, _uuid.UUID): return {_TYPE_TAG: "uuid", "value": str(obj)} if isinstance(obj, frozenset): # Members are encoded recursively by json itself. return {_TYPE_TAG: "frozenset", "value": list(obj)} if isinstance(obj, set): return {_TYPE_TAG: "set", "value": list(obj)} if isinstance(obj, bytes): return { _TYPE_TAG: "bytes", "value": base64.b64encode(obj).decode("ascii"), } raise TypeError( f"Object of type {type(obj).__name__} is not session-serializable; " "store JSON-shaped data, or pass a custom serializer= to the " "session backend." ) # Escape prefix applied to any user dict key that collides with the # reserved tag key. ``_TYPE_TAG`` -> ``_ESC_PREFIX + _TYPE_TAG``, and any # existing run of the prefix in front of the tag gains one more level, so # the transform is reversible for arbitrarily-nested escapes. _ESC_PREFIX = "__esc__" _REVIVERS = { "datetime": _dt.datetime.fromisoformat, "date": _dt.date.fromisoformat, "time": _dt.time.fromisoformat, "decimal": decimal.Decimal, "uuid": _uuid.UUID, "set": set, "frozenset": frozenset, "bytes": lambda v: base64.b64decode(v), } @classmethod def _is_reserved_key(cls, key): """True for the reserved tag key or an already-escaped form of it.""" if not isinstance(key, str): return False stripped = key while stripped.startswith(cls._ESC_PREFIX): stripped = stripped[len(cls._ESC_PREFIX) :] return stripped == _TYPE_TAG @classmethod def _escape(cls, data): """Recursively escape user dict keys that collide with the reserved tag. Called on the *plain* Python data before ``json.dumps`` so a user dict like ``{_TYPE_TAG: "datetime", "value": ...}`` can't be mistaken for an encoding this serializer produces. Our own tag dicts are created later by :meth:`_default` (for non-JSON types), so any reserved key present at this stage is user data and gets one escape-prefix level added. """ if isinstance(data, dict): out = {} for k, v in data.items(): new_key = cls._ESC_PREFIX + k if cls._is_reserved_key(k) else k out[new_key] = cls._escape(v) return out if isinstance(data, list): return [cls._escape(v) for v in data] return data @classmethod def _unescape_key(cls, key): """Strip one escape-prefix level from a reserved key (inverse of :meth:`_escape`).""" if cls._is_reserved_key(key) and key.startswith(cls._ESC_PREFIX): return key[len(cls._ESC_PREFIX) :] return key @classmethod def _object_hook(cls, obj): reviver = cls._REVIVERS.get(obj.get(_TYPE_TAG, "")) if reviver is not None and "value" in obj: try: return reviver(obj["value"]) except (ValueError, TypeError, KeyError): # A malformed tagged value (e.g. a bad ISO string) must not # poison the session with a hard 500 on every read — fall back # to handing the raw dict through untouched. return obj # Reverse the encode-time escaping of user keys that collide with the # reserved tag. Only reserved keys are ever rewritten, so this is a # no-op for the overwhelmingly common case. if any(cls._is_reserved_key(k) for k in obj): return {cls._unescape_key(k): v for k, v in obj.items()} return obj def dumps(self, data: dict) -> str: """Serialize ``data`` to a JSON string.""" return json.dumps(self._escape(data), default=self._default) def loads(self, raw: str | bytes) -> dict: """Deserialize ``raw`` (``str`` or ``bytes``) back to a dict.""" return json.loads(raw, object_hook=self._object_hook) class RedisSessionBackend: """Redis-backed session store, shared across processes. Pass an existing client, or a ``url`` to create one (requires the ``redis`` package). :param serializer: Codec used to store session dicts — any object with ``dumps(data) -> str | bytes`` and ``loads(raw) -> dict``. Defaults to :class:`JSONSessionSerializer`, which round-trips ``datetime``, ``date``, ``time``, ``Decimal``, ``UUID``, ``set``, ``frozenset``, and ``bytes`` on top of plain JSON. """ def __init__( self, client=None, *, url=None, prefix="responder:session:", serializer=None ): if client is None: try: import redis except ImportError as exc: raise ImportError( "redis is required for RedisSessionBackend: pip install redis" ) from exc client = redis.Redis.from_url(url or "redis://localhost:6379/0") self.client = client self.prefix = prefix self.serializer = JSONSessionSerializer() if serializer is None else serializer def get(self, session_id): raw = self.client.get(self.prefix + session_id) if raw is None: return None return self.serializer.loads(raw) def set(self, session_id, data, max_age): key = self.prefix + session_id value = self.serializer.dumps(data) if hasattr(self.client, "set"): self.client.set(key, value, ex=max_age) return self.client.setex(key, max_age, value) def touch(self, session_id, max_age): self.client.expire(self.prefix + session_id, max_age) def delete(self, session_id): self.client.delete(self.prefix + session_id) class AsyncRedisSessionBackend: """Async-native Redis session store (uses ``redis.asyncio``). Pass an existing ``redis.asyncio`` client, or a ``url`` to create one. Awaited directly by the middleware — no thread-pool hop. :param serializer: Codec used to store session dicts — any object with ``dumps(data) -> str | bytes`` and ``loads(raw) -> dict``. Defaults to :class:`JSONSessionSerializer`. """ def __init__( self, client=None, *, url=None, prefix="responder:session:", serializer=None ): if client is None: try: from redis import asyncio as aioredis except ImportError as exc: raise ImportError( "redis is required for AsyncRedisSessionBackend: pip install redis" ) from exc client = aioredis.Redis.from_url(url or "redis://localhost:6379/0") self.client = client self.prefix = prefix self.serializer = JSONSessionSerializer() if serializer is None else serializer async def aget(self, session_id): raw = await self.client.get(self.prefix + session_id) return None if raw is None else self.serializer.loads(raw) async def aset(self, session_id, data, max_age): key = self.prefix + session_id value = self.serializer.dumps(data) if hasattr(self.client, "set"): await self.client.set(key, value, ex=max_age) return await self.client.setex(key, max_age, value) async def atouch(self, session_id, max_age): await self.client.expire(self.prefix + session_id, max_age) async def adelete(self, session_id): await self.client.delete(self.prefix + session_id) class ServerSessionMiddleware: """ASGI middleware storing session data in a backend, keyed by an opaque cookie. A drop-in alternative to cookie-payload sessions.""" def __init__( self, app, backend, cookie_name="responder_session", max_age=14 * 24 * 3600, path="/", same_site="lax", https_only=False, ): self.app = app self.backend = backend self.cookie_name = cookie_name self.max_age = max_age self.path = path self.same_site = same_site self.https_only = https_only def _session_id_from(self, scope): for key, value in scope.get("headers", []): if key == b"cookie": cookie: SimpleCookie = SimpleCookie(value.decode("latin-1")) morsel = cookie.get(self.cookie_name) if morsel is not None: return morsel.value return None def _cookie_header(self, value, max_age): parts = [ f"{self.cookie_name}={value}", f"Path={self.path}", f"Max-Age={max_age}", "HttpOnly", f"SameSite={self.same_site}", ] if self.https_only: parts.append("Secure") return "; ".join(parts) async def _get(self, session_id): if hasattr(self.backend, "aget"): return await self.backend.aget(session_id) return await run_in_threadpool(self.backend.get, session_id) async def _set(self, session_id, data, max_age): if hasattr(self.backend, "aset"): return await self.backend.aset(session_id, data, max_age) return await run_in_threadpool(self.backend.set, session_id, data, max_age) async def _delete(self, session_id): if hasattr(self.backend, "adelete"): return await self.backend.adelete(session_id) return await run_in_threadpool(self.backend.delete, session_id) async def _touch(self, session_id, data, max_age): """Slide the TTL of an unchanged session without re-serializing it.""" if hasattr(self.backend, "atouch"): await self.backend.atouch(session_id, max_age) elif hasattr(self.backend, "touch"): await run_in_threadpool(self.backend.touch, session_id, max_age) else: await self._set(session_id, data, max_age) # no touch -> full re-write @staticmethod def _changed(session, initial): try: return session != initial # dict.__eq__ is deep + order-independent except Exception: return True # uncomparable -> assume dirty, write async def __call__(self, scope, receive, send): if scope["type"] not in ("http", "websocket"): await self.app(scope, receive, send) return session_id = self._session_id_from(scope) initial = await self._get(session_id) if session_id else None had_session = initial is not None # Independent deep copy: the live session must not alias the backend's # stored object, and `initial` stays a pristine baseline for dirty checks. scope["session"] = copy.deepcopy(initial) if had_session else {} # A presented-but-unresolved cookie must not be reused as the stored # ID — mint a fresh one to defeat session fixation. if session_id is not None and not had_session: session_id = None if scope["type"] == "websocket": # Sessions are read-only over WebSockets (no response to attach # a cookie to). await self.app(scope, receive, send) return async def send_wrapper(message): nonlocal session_id, had_session if message["type"] == "http.response.start": session = scope["session"] headers = MutableHeaders(scope=message) # Explicit rotation (regenerate_session) drops the old id even # if it was valid — defeats a planted-but-valid session id. regenerate = scope.get("_session_regenerate", False) if regenerate and session_id is not None: await self._delete(session_id) session_id = None had_session = False if session: if session_id is None: session_id = secrets.token_urlsafe(32) # Persist only when changed; otherwise just slide the TTL. # Either way the cookie Max-Age is refreshed below, so cookie # and backend expiry stay in lock-step. if not had_session or self._changed(session, initial): await self._set(session_id, session, self.max_age) else: await self._touch(session_id, session, self.max_age) headers.append( "Set-Cookie", self._cookie_header(session_id, self.max_age) ) elif had_session: await self._delete(session_id) headers.append("Set-Cookie", self._cookie_header("null", 0)) await send(message) await self.app(scope, receive, send_wrapper) def regenerate_session(req): """Rotate the server-side session ID, keeping the current session data. Call this right after a privilege change (e.g. login) to defeat session fixation: the old ID is discarded and a fresh one is issued. Only affects apps using a server-side ``session_backend``. Usage:: from responder.ext.sessions import regenerate_session @api.route("/login", methods=["POST"]) async def login(req, resp): req.session["user"] = "kenneth" regenerate_session(req) """ req._starlette.scope["_session_regenerate"] = True