diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f20ac4f..1e9f7b3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -35,7 +35,7 @@ jobs: uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - name: Sync dependencies - run: uv sync --locked --group dev --extra jwt + run: uv sync --locked --group dev # `tests` is linted here too. pre-commit only sees staged files, so test # code that was never touched again drifts; that is how the suite came to diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e253a31..e35838a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,12 +101,12 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - # The extras are redundant — the dev group already pins redis, pymemcache, - # PyJWT and orjson — but they are named anyway. This is the one job where + # The extras are redundant — the dev group already pins redis, pymemcache + # and orjson — but they are named anyway. This is the one job where # a missing backend package would turn the gate below into a formality, # so it does not rely on a dev-group entry staying put. - name: Sync dependencies - run: uv sync --locked --group dev --extra jwt --extra redis --extra memcached + run: uv sync --locked --group dev --extra redis --extra memcached # The gate. A release is the one run where a red test result arrives too # late to matter, so everything CI checks elsewhere is checked here too, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1256128..15d62a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,7 +54,7 @@ jobs: uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - name: Sync dependencies - run: uv sync --locked --extra jwt + run: uv sync --locked - name: Run tests run: uv run coverage run -m pytest && uv run coverage report diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86f11ee..31fb8f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,6 +57,5 @@ repos: - orjson - types-orjson - pymemcache>=4.0.0 - - PyJWT>=2.9.0 args: [--strict] exclude: ^(docs/|scripts/) diff --git a/CLAUDE.md b/CLAUDE.md index 1a520ce..1322df6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Guidance for Claude Code in this repository. It lists only what the code, `pypro ## Project -`fastapi_cachex` (PyPI `fastapi-cachex`): HTTP caching, application cache and a distributed lock for FastAPI, on pluggable backends (memory, Redis, Memcached). The 0.4 line is current (0.4.0 released 2026-10-02). `release.yml` releases only from `master`, so `master` stays 0.4.x-compatible: merging breaking 0.5.0 work (milestone `0.5.0`) ends 0.4.x patch releases. Sessions and OAuth state (`fastapi_cachex.session`, `fastapi_cachex.state`) are deprecated (#420) and get security fixes only until 0.5.0 removes them (#421). +`fastapi_cachex` (PyPI `fastapi-cachex`): HTTP caching, application cache and a distributed lock for FastAPI, on pluggable backends (memory, Redis, Memcached). The 0.4 line is current (0.4.0 released 2026-10-02). `release.yml` releases only from `master`, so `master` stays 0.4.x-compatible: merging breaking 0.5.0 work (milestone `0.5.0`) ends 0.4.x patch releases. 0.5.0 removes sessions and OAuth state (`fastapi_cachex.session`, `fastapi_cachex.state`, deprecated in 0.4.0 by #420; removed by #421); `docs/MIGRATING_0_5.md` covers the move. ## Commands @@ -20,11 +20,11 @@ Use `uv` for everything (`uv sync --group dev --all-extras`, `uv run ...`). - HTTP cache keys are `http:v2|method|host|path|query` (`HTTP_KEY_FORMAT_TAG`, `CACHE_KEY_SEPARATOR = "|"`), built and parsed by `CacheKey` in `cache_key.py`. Method, host, path and extra components go through `escape_key_component` so client input cannot inject the separator; a query over 200 bytes becomes `sha256:`. A format change bumps the tag. Anything that builds or parses keys (`clear_path`, `routes.py`, backends) must go through `CacheKey`. - `@cache` fails open by default: a backend error is logged and treated as a miss, or the response is served unstored. Only GET is stored; HEAD reads the GET entry (key built as GET) and is never stored. -- Backend lookup: `@cache` and the `CacheBackend` / `AppCache` dependencies fall back to a `MemoryBackend` when none is set. `CacheLock`, `StateManager` and a directly built `CacheManager(...)` call `BackendProxy.get()` and raise; the monitoring routes and `invalidate()` treat a missing backend as empty. The proxies' lazy creation goes through `ProxyBase.get_or_create` (per-class lock; sync dependencies run in threads). +- Backend lookup: `@cache` and the `CacheBackend` / `AppCache` dependencies fall back to a `MemoryBackend` when none is set. `CacheLock` and a directly built `CacheManager(...)` call `BackendProxy.get()` and raise; the monitoring routes and `invalidate()` treat a missing backend as empty. The proxies' lazy creation goes through `ProxyBase.get_or_create` (per-class lock; sync dependencies run in threads). - Memcached cannot enumerate keys. `clear_pattern`, `get_all_keys` and every `CacheManager.clear*` are no-ops with a `RuntimeWarning`, while `MemcachedBackend.clear()` issues `flush_all` and wipes the whole server. - The atomic primitives on `BaseCacheBackend` (`increment`, `get_and_delete`, `set_if_absent`, `delete_if_equals`, `expire_if_equals`, `delete_many`) have non-atomic fallbacks. Every built-in backend must override them atomically; see "Atomic backend primitives" in `docs/BACKENDS.md`. `tests/backends/*_contract.py` covers TTL validation, counters and `clear_pattern`; the other primitives are tested in each backend's own test file, so a change needs all three. - `validate_ttl` / `validate_delta` run before any I/O in every backend; floats and bools raise `TypeError`. -- Redis and Memcached keys carry `key_prefix` (default `fastapi_cachex:`); memory has no prefix. `CacheManager` (`cache:`), `StateManager` (`oauth_state:`) and `CacheLock` (`lock:`) add their own prefixes on top. +- Redis and Memcached keys carry `key_prefix` (default `fastapi_cachex:`); memory has no prefix. `CacheManager` (`cache:`) and `CacheLock` (`lock:`) add their own prefixes on top. ## Tests @@ -38,4 +38,4 @@ Use `uv` for everything (`uv sync --group dev --all-extras`, `uv run ...`). - Changelog: add a `changelog.d/.
.md` fragment and never edit `CHANGELOG.md`. The format is in `changelog.d/README.md`. - Docs changes go into both `docs/` and `i18n/zh-TW/docs/`. Terms follow `i18n/zh-TW/GLOSSARY.md`. - Never edit `version` in `pyproject.toml` by hand. Releases run through the `release.yml` workflow (`docs/DEVELOPMENT.md#releasing`). -- Breaking changes need a runtime warning in a release first and land only in the next minor, with a section in that minor's migration guide (`docs/MIGRATING_0_4.md` for 0.4.0; 0.5.0 gets a new `docs/MIGRATING_0_5.md` with its zh-TW copy and nav entry). +- Breaking changes need a runtime warning in a release first and land only in the next minor, with a section in that minor's migration guide (`docs/MIGRATING_0_4.md` for 0.4.0, `docs/MIGRATING_0_5.md` for 0.5.0, each with its zh-TW copy and nav entry). diff --git a/README.md b/README.md index f745668..b481f42 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,6 @@ A high-performance caching extension for FastAPI: a server-side response cache w and `@cached` for a plain function, keyed on its arguments. - **Backends** — in-memory, Redis and Memcached, with atomic counters, one-shot values and locks. -- **Sessions and OAuth state (deprecated)**: signed session tokens and one-time - OAuth state tokens. Both are deprecated in 0.4.0 and removed in 0.5.0; see - [where to move](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/#session-state-deprecated). ## Installation @@ -38,15 +35,14 @@ uv add fastapi-cachex ``` Everything in the core package works with the in-memory backend. The other -backends and the optional session transports ship as extras: +backends ship as extras: | Extra | Install | Pulls in | Needed for | |-------|---------|----------|------------| | `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` | | `memcached` | `uv add "fastapi-cachex[memcached]"` | `pymemcache` | `MemcachedBackend` | -| `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` | -Extras combine: `uv add "fastapi-cachex[redis,jwt]"`. +Extras combine: `uv add "fastapi-cachex[redis,memcached]"`. ## Quick Start @@ -96,7 +92,8 @@ async def report(cache: AppCache): > [!WARNING] > The default cache key carries no user identity. Cache authenticated endpoints > with `private=True` or a per-user key builder plus `cache_authorized=True` -> (requests with `Authorization` or a session otherwise bypass the backend) — see +> (requests with `Authorization` or non-empty `request.session` data otherwise +> bypass the backend) — see > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints). ## Documentation @@ -106,13 +103,14 @@ a code block that includes one of the runnable examples renders on the site, while GitHub shows only its `--8<--` include line (the sentence before each block links the example file). +- [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/) — what 0.5.0 removes and how to upgrade from 0.4.x - [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to upgrade from 0.3.x +- [Migrating from fastapi-cache2](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_FROM_FASTAPI_CACHE2/) — its API mapped onto this one, and the behaviour that differs - [When to use it](https://fastapi-cachex.readthedocs.io/en/latest/COMPARISON/) — how it compares with fastapi-cache2, cashews, aiocache and a CDN, and when another one fits better - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager` - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives - [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion -- Deprecated, removed in 0.5.0: [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/), [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) (one-shot OAuth/CSRF state tokens) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/) - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request - [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/) diff --git a/SECURITY.md b/SECURITY.md index ae4358d..35a9a32 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,9 +13,6 @@ the problem is still there. A fix ships as a new 0.4.x patch release; earlier releases are not patched. -`fastapi_cachex.session` and `fastapi_cachex.state` are deprecated in 0.4.0. -They get security fixes during 0.4.x and none after 0.5.0 removes them. - ## Reporting a vulnerability Please do **not** open a public issue, pull request or discussion for a @@ -27,9 +24,9 @@ reporting instead: The report is visible only to you and the maintainers until an advisory is published. -Examples of what counts: a way to forge, reuse or steal a session or OAuth -state token, to read or poison another client's cached response, or to make -the library leak data it was given to protect. A bug in your own application's +Examples of what counts: a way to read or poison another client's cached +response, to make `@cache` store or serve a response it should have bypassed, +or to make the library leak data it was given to protect. A bug in your own application's use of the library, or in a dependency that FastAPI-CacheX does not work around, is usually better reported to that project. @@ -39,8 +36,8 @@ The more of these a report has, the faster it can be confirmed: - The FastAPI-CacheX version, the Python version, and the backend in use (memory, Redis or Memcached) with its server version. -- The affected component, for example `@cache`, `CacheManager`, the session - middleware, `StateManager` or `CacheLock`, and the relevant configuration. +- The affected component, for example `@cache`, `CacheManager` or + `CacheLock`, and the relevant configuration. - Steps to reproduce, ideally a minimal FastAPI app or test case. - What an attacker can achieve, and under which conditions. - Any fix or mitigation you have in mind. diff --git a/changelog.d/421.changed.2.md b/changelog.d/421.changed.2.md new file mode 100644 index 0000000..196055e --- /dev/null +++ b/changelog.d/421.changed.2.md @@ -0,0 +1,6 @@ +**A third-party backend `delete()` that returns `None` now counts as not +removed.** The non-atomic fallbacks on `BaseCacheBackend` (`delete_many()`, +`get_and_delete()`, `delete_if_equals()`) read `delete()`'s result with +`bool()`, so `None` is `False` and no `FutureWarning` is emitted; 0.4.x +counted it as removed. Return a `bool` from `delete()`, as 0.4.0 requires. See +[Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/#backend-delete-none). diff --git a/changelog.d/421.changed.md b/changelog.d/421.changed.md new file mode 100644 index 0000000..6724514 --- /dev/null +++ b/changelog.d/421.changed.md @@ -0,0 +1,5 @@ +**Lower dependency floors: `itsdangerous` and `starlette` are no longer +required directly, and `fastapi>=0.128.2` replaces `fastapi>=0.133.0`.** Only +the removed session middleware needed `itsdangerous` and `starlette>=1.0.0`; +the core now runs on whichever Starlette the installed FastAPI allows, down to +0.40.0. The `lowest` tox env tests that oldest stack. diff --git a/changelog.d/421.removed.2.md b/changelog.d/421.removed.2.md new file mode 100644 index 0000000..9f08fb2 --- /dev/null +++ b/changelog.d/421.removed.2.md @@ -0,0 +1,8 @@ +**`@cache` no longer counts a session loaded by the removed session +middleware as a credential.** A request bypasses the shared backend for an +`Authorization` header or a non-empty `request.session` from any session +middleware, as before; a session that `FastAPICacheXSessionMiddleware` loaded +no longer counts, since the middleware is gone. A `Cookie` header alone still +does not bypass the cache, and `X-Session-Token` in `@cache(vary=[...])` is +still keyed on its SHA-256 digest, so keys do not change. +See [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/#cache-session-token). diff --git a/changelog.d/421.removed.md b/changelog.d/421.removed.md new file mode 100644 index 0000000..f2332aa --- /dev/null +++ b/changelog.d/421.removed.md @@ -0,0 +1,9 @@ +**Sessions and OAuth state are removed, and so is the `jwt` extra.** +`fastapi_cachex.session` and `fastapi_cachex.state`, deprecated in 0.4.0 +(#420), are gone: `FastAPICacheXSessionMiddleware`, `SessionManager`, +`SessionConfig`, the session dependencies, the token serializers, +`StateManager` and their proxies and exceptions. Importing either package +raises `ModuleNotFoundError`, and their names on `fastapi_cachex` raise +`AttributeError` instead of warning. The `jwt` extra, which only installed +`PyJWT` for JWT session tokens, is removed too. See +[Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/#session-state-removed). diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index 02e949d..54d7b16 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -111,13 +111,12 @@ cached wherever it is called. From [`examples/app_cache.py`](https://github.com/ deduplication. An expired key counts as free; a key holding an undecodable value does not, even though `get()` treats it as a miss. - Keys live under their own `cache:`-prefixed namespace by default, separate from - the HTTP route cache and OAuth state, so `clear()`/`clear_prefix()` never touch + the HTTP route cache and the locks, so `clear()`/`clear_prefix()` never touch unrelated cache entries. - The prefix is matched as a plain string prefix. A manager with `key_prefix="cache:"` therefore also clears the entries of one with `key_prefix="cache:users:"`, and an empty `key_prefix` makes `clear()` remove - everything in the backend, including HTTP responses, locks, OAuth states and - sessions. Give each manager a prefix that does not start with another's. + everything in the backend, including HTTP responses and locks. Give each manager a prefix that does not start with another's. - `clear_pattern(pattern)` treats only `pattern` as a glob; `key_prefix` is always matched literally. With a prefix free of glob metacharacters (`*`, `?`, `[`, `]`, `\`) it hands `key_prefix + pattern` to the backend's @@ -143,7 +142,7 @@ cached wherever it is called. From [`examples/app_cache.py`](https://github.com/ > `get()`/`set()`/`add()`/`delete()`/`has()` work normally. Use Redis or the in-memory > backend if you need bulk clearing. Do not fall back to the backend's own `clear()` > on Memcached: `MemcachedBackend.clear()` issues `flush_all` and wipes the whole -> server, HTTP responses, sessions, locks and other applications' keys included. +> server, HTTP responses, locks and other applications' keys included. ### Group invalidation on Memcached {#group-invalidation-on-memcached} diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index bc14090..753b0e5 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -1,6 +1,6 @@ # Backends -Every cache — HTTP responses, `CacheManager` values, sessions and OAuth states — +Every cache — HTTP responses, `CacheManager` values and `CacheLock` locks — lives in one backend, registered once at startup with `BackendProxy.set()`. ## Choosing a backend @@ -170,8 +170,8 @@ after about 0.25 s. The costs: - `socket_timeout` also bounds slow replies. Set both timeouts above your normal Redis latency, including for the largest entries you cache. - The settings apply to every command the backend sends, not only to `@cache`. - `CacheManager`, `StateManager`, `CacheLock` and sessions, which raise backend - errors instead of failing open, see those errors sooner. + `CacheManager` and `CacheLock`, which raise backend errors instead of + failing open, see those errors sooner. `RedisConfig` has no `retry` field, so pass it to the constructor. @@ -336,7 +336,6 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): `exptime=-1` (retrying if another writer replaced the value in between). If writers keep replacing it for 16 attempts in a row, Memcached raises `CacheXError` rather than report the key as missing. - `StateManager.consume_state`, `StateManager.delete_state`, `CacheManager.delete` and `invalidate()` are built on it. - `set_if_absent(key, value, ttl=None) -> bool` — stores `value` only when `key` does not exist (an expired key counts as absent) and reports whether it @@ -356,8 +355,7 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): same bytes with the new exptime (`TOUCH` takes no CAS token). - `set_if_equals(key, expected, value, ttl=None) -> bool` — stores `value` only while `key` still holds `expected`: a compare-and-set that fails if anything - changed, deleted or expired the key since the caller read it. Sessions save - through it (see [Session writes](MIGRATING_0_4.md#session-writes)). Memory + changed, deleted or expired the key since the caller read it. Memory compares under its lock, Redis compares in Python then runs a Lua script (`GET` compare + `SET`, with `EX` when `ttl` is set), and Memcached uses `GETS` + a `CAS` write of the new value. @@ -365,15 +363,15 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): All six have a non-atomic fallback on `BaseCacheBackend`, so a third-party backend that only implements the abstract methods keeps working; override them to get real atomicity. The fallbacks rely on `delete()` returning whether the key held -an entry; a `delete()` that still returns `None`, as in 0.3.x, warns and counts -as removed until 0.5.0 (see [delete() return value](MIGRATING_0_4.md#backend-delete)). +an entry; since 0.5.0 a `delete()` that still returns `None`, as in 0.3.x, +counts as not removed (see [Backend `delete()` returning `None`](MIGRATING_0_5.md#backend-delete-none)). Complete runnable example: [`examples/rate_limit.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/rate_limit.py). ## TTL values -Every `ttl` argument (`set`, `set_if_absent`, `set_if_equals`, `increment`, and the `CacheManager`, -`CacheLock` and `StateManager` methods and defaults built on them) is either `None`, meaning +Every `ttl` argument (`set`, `set_if_absent`, `set_if_equals`, `increment`, and the `CacheManager` +and `CacheLock` methods and defaults built on them) is either `None`, meaning the entry never expires, or a number of seconds from 1 up to `MAX_TTL` (2**31 - 1, about 68 years), as an `int` or a `datetime.timedelta` of whole seconds (`timedelta(minutes=5)` is stored as `300`). The checks run before any diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 0902601..048230a 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -26,13 +26,13 @@ no Cache-Control header) no-store? ── yes → run the handler, neither read nor write the cache, │ respond with Cache-Control: no-store ↓ no -private, no positive ttl, or Authorization/session without public/cache_authorized? +private, no positive ttl, or Authorization/session data without public/cache_authorized? ── yes → run the handler; compare If-None-Match to decide 304 or 200 │ (the shared backend is neither read nor written and the key - │ builder does not run; for Authorization or a session, + │ builder does not run; for Authorization or session data, │ Cache-Control says private instead of public) ↓ no -(cache_authorized with Authorization or a session: the backend is used below, +(cache_authorized with Authorization or session data: the backend is used below, but every answer still says private instead of public) ↓ Build the cache key: key_builder (default http:v2|method|host|path|query_params), @@ -120,9 +120,9 @@ A custom `key_builder` can add components after the query string with appends one `name=value` component per listed request header after whatever the key builder returns, and adds the names to the response's `Vary` header (see [Varying on request headers](HTTP_CACHING.md#varying-on-request-headers)). -For the credential headers `Authorization`, `Proxy-Authorization`, `Cookie` -and `X-Session-Token` a non-empty value is written as `sha256:`, -so no token appears in the key. +For the credential headers `Authorization`, `Proxy-Authorization` and +`Cookie` a non-empty value is written as `sha256:`, so no token +appears in the key. Query parameters are **sorted** by name (a stable sort: repeated values of one name keep the order sent), so `?page=1&limit=10` and `?limit=10&page=1` share @@ -153,9 +153,9 @@ The decorator arguments control both the server-side behaviour and the # Normal caching behaviour @cache(ttl=3600) # Cache for 1 hour (also used as the max-age value) -@cache(ttl=3600, public=True) # Allow shared caches, also for Authorization/session requests +@cache(ttl=3600, public=True) # Allow shared caches, also for Authorization/session-data requests @cache(private=True) # Private only; never touches the shared backend -@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # Authorization/session requests use the backend, answered private +@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # Authorization/session-data requests use the backend, answered private @cache(ttl=3600, immutable=True) # Content never changes # Header-only directives (they do not change server-side behaviour) @@ -205,7 +205,7 @@ The header value is built once per decorated route: > 2. A custom `key_builder` that includes the identity, together with > `cache_authorized=True` — when you really do want a per-user server-side > cache. Without `cache_authorized`, a request with an `Authorization` header -> or a session bypasses the backend (see below). With it, the answer is +> or non-empty `request.session` data bypasses the backend (see below). With it, the answer is > still sent with `private`, since a shared cache downstream cannot see the > identity in the key. > @@ -253,7 +253,7 @@ if no_store: return await render() # no read, no write bypass = private or not ttl -# Authorization header, a session the middleware loaded, or non-empty request.session +# Authorization header, or non-empty request.session credential = None if private or public else request_credential(request) header = private_header if credential else decorator_header # for every answer below if bypass or (credential and not cache_authorized): @@ -323,14 +323,14 @@ return response > [!NOTE] > **Responses that belong to one caller are never stored.** Following RFC 9111 > §3.5, a request with an `Authorization` header bypasses the backend (no read, -> no write), and so does one with a session (loaded by the session middleware -> from any token transport, or a non-empty `request.session`), unless the route is `public=True` or opts in with +> no write), and so does one with a non-empty `request.session` (from any +> session middleware), unless the route is `public=True` or opts in with > `cache_authorized=True` (for a `key_builder` that includes the verified > identity). On a render, a response whose own `Cache-Control` contains > `private` or `no-store` (whole directive, any case), or that sets a cookie, > is served but not written. A `private`/`no-store` header from the handler is > sent unchanged instead of the decorator's. A cookie response, and the answer -> to any `Authorization` or session request (bypassed or, with +> to any `Authorization` or session-data request (bypassed or, with > `cache_authorized`, served from the backend), are sent (200 or 304) with `private` > in place of `public` and the decorator's other directives kept (`private, > no-cache` on a `no_cache` route), so a downstream shared cache does not @@ -519,7 +519,7 @@ Which backend to pick is covered in [Backends](BACKENDS.md#choosing-a-backend). | `no_store=True` | The cache is neither read nor written; the endpoint runs every time | | `no_cache=True` | The endpoint runs every time to recompute the ETag; a match with the client's `If-None-Match` still returns 304, and the cache is updated when the ETag changes | | `private=True` | The **shared backend** is neither read nor written; `Cache-Control: private` is still sent and the ETag is compared against fresh content | -| Request with `Authorization` or a session | The backend is neither read nor written, as with `private=True`, and `Cache-Control` has `private` instead of `public`, unless the route has `public=True` or `cache_authorized=True` (`must_revalidate=True` is not enough); with `cache_authorized=True` the backend is used but `Cache-Control` still has `private` | +| Request with `Authorization` or non-empty `request.session` | The backend is neither read nor written, as with `private=True`, and `Cache-Control` has `private` instead of `public`, unless the route has `public=True` or `cache_authorized=True` (`must_revalidate=True` is not enough); with `cache_authorized=True` the backend is used but `Cache-Control` still has `private` | | Handler sends `Cache-Control: private`/`no-store` | Returned with the handler's header intact, not written, and any existing entry is left untouched | | Response sets a cookie | Returned with `private` instead of `public` in `Cache-Control`, not written, and any existing entry is left untouched | | No `ttl` (or `ttl=0`) | The backend is neither read nor written, as with `private=True`; the endpoint runs every time and the ETag is compared against fresh content | diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 117c456..c46bccc 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -92,6 +92,8 @@ Points to know when comparing: per-user endpoint must put the user into the key itself. - Its latest release is from July 2024. +To switch, see [Migrating from fastapi-cache2](MIGRATING_FROM_FASTAPI_CACHE2.md). + ## cashews [cashews](https://github.com/Krukov/cashews) is a general caching toolkit for diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 16db871..ac8aab9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -126,7 +126,7 @@ git checkout fastapi_cachex/ ``` Anything still green is a test that was not testing. A sweep of 25 such -mutations across the backends, the cache decorator and the session layer found +mutations across the backends, the cache decorator and the (since removed) session layer found three, including one that claimed to prove a forged `X-Forwarded-For` cannot satisfy IP binding and would have stayed green with the check disabled entirely. Worth doing whenever a test is written for something security-relevant. @@ -161,7 +161,9 @@ uv run tox -e py310 # only run for Python 3.10 The `py3*` environments install the versions in `uv.lock`, which are the newest ones. The `lowest` environment instead resolves every direct dependency of the package, extras included, to the lower bound in `pyproject.toml` -(`uv_resolution = lowest-direct`) and runs the suite on Python 3.10. It is not in +(`uv_resolution = lowest-direct`) and runs the suite on Python 3.10. Starlette +is not a direct dependency, so `tox.ini` lists it in the env's `deps` at the +oldest release the `fastapi` floor accepts; raise the two together. It is not in `env_list`; the **Lowest dependencies** workflow runs it with live servers. ```bash @@ -229,7 +231,7 @@ uv run mypy fastapi_cachex --strict - Make sure all functions have type annotations - Use `Type | None` for parameters that could be None (the codebase uses PEP 604 unions, not `Optional`) -- Write forward references as quoted annotations (`"SessionManager"`), importing the name under +- Write forward references as quoted annotations (`"BaseCacheBackend"`), importing the name under `if TYPE_CHECKING:` when it is only needed for typing. Most modules do this; only a couple use `from __future__ import annotations` - Keep `fastapi_cachex/py.typed` in place; it is what makes the installed package typed for users diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 4bf4c03..1567eec 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -67,7 +67,7 @@ header, and the server-side cache behaves the same with or without them. | `no-cache` | `no_cache=True` | :white_check_mark: | The handler runs on every request; the response is still stored, and a matching `If-None-Match` gets a 304. | | `no-store` | `no_store=True` | :white_check_mark: | Nothing is read or stored, and no ETag is set. | | `private` | `private=True` | :white_check_mark: | The backend is bypassed; the handler runs on every request, and ETag revalidation still works. | -| `public` | `public=True` | :white_check_mark: | Requests with `Authorization` or a session still use the backend (otherwise they bypass it). | +| `public` | `public=True` | :white_check_mark: | Requests with `Authorization` or session data still use the backend (otherwise they bypass it). | | `immutable` | `immutable=True` | :white_check_mark: | None (header only). | | `must-revalidate` | `must_revalidate=True` | :white_check_mark: | None (header only). | | `stale-while-revalidate` | `stale="revalidate", stale_ttl=N` | :white_check_mark: | None (header only): the server-side cache never serves stale content. | @@ -91,7 +91,7 @@ A bare `@cache()`, with neither `ttl` nor a directive, stores nothing and has no `Cache-Control` of its own. It adds an ETag and answers a matching `If-None-Match` with `304`, and keeps the handler's own `Cache-Control` (or sends none). As on every route, a response that sets a cookie or answers a -request with `Authorization` or a session is still sent with `private`. +request with `Authorization` or session data is still sent with `private`. ### The request's `Cache-Control` is ignored @@ -167,7 +167,7 @@ meaningful for the `Range` request that produced it. A response that belongs to one caller is never stored either (#296): -- **The request carries `Authorization` or a session.** As RFC 9111 §3.5 requires of a +- **The request carries `Authorization` or session data.** As RFC 9111 §3.5 requires of a shared cache, the backend is bypassed, as with `private=True`: nothing is read or written, the handler runs, and `If-None-Match` is compared against the fresh render. The response (and a 304) is sent with `private` in place @@ -185,15 +185,15 @@ A response that belongs to one caller is never stored either (#296): backend anyway, but its response to such a request still gets `private` (before 0.3.9 it was sent without it, #362); `private=True` routes send it already. - A request has a session when `FastAPICacheXSessionMiddleware` (deprecated, - removed in 0.5.0) loaded one for it, from the token header, a - bearer token or the session cookie, with or without a user, or when - `request.session` is non-empty under any session middleware, Starlette's - included. A token that resolves to no session (forged, expired) does not - count, so it cannot be used to skip the cache. Before 0.3.9 only - `Authorization` did, and a plain `@cache` on a route that read the session - served one visitor's response to the next (#319). The first bypass on each - route that reads the backend is logged at `WARNING` (see + A request has session data when `request.session` is non-empty under any + session middleware, such as Starlette's `SessionMiddleware`. An empty + session does not count, and neither does a `Cookie` header on its own. + Before 0.3.9 only `Authorization` did, and a plain `@cache` on a route + that read the session served one visitor's response to the next (#319). + Before 0.5.0 a session that the removed `FastAPICacheXSessionMiddleware` + loaded (from `X-Session-Token`, a bearer token or its cookie) counted too; + see [Migrating to 0.5.0](MIGRATING_0_5.md#cache-session-token). + The first bypass on each route that reads the backend is logged at `WARNING` (see [Requests with credentials](#requests-with-credentials)). - **The handler's own `Cache-Control` contains `private` or `no-store`** (as whole directives, in any case). The response is served but not stored, @@ -329,12 +329,12 @@ methods get no header, as they get no `Cache-Control`. ### Requests with credentials A single-page app that sends `Authorization` on every request, or a site where -every visitor has a session, gets no cache hits at all on a plain `@cache` +every visitor has session data, gets no cache hits at all on a plain `@cache` route: each request bypasses the backend (see above). Pick the option that matches what the handler returns: - **The response is the same for every user** (a product list, a public - article): set `public=True`. Requests with `Authorization` or a session then + article): set `public=True`. Requests with `Authorization` or session data then read and write the backend like any other. Note that `public=True` also changes the header sent downstream to `Cache-Control: public, ...`, which tells a CDN or reverse proxy that it may store the response even though the @@ -352,7 +352,7 @@ So that a 0% hit rate does not go unnoticed, the first request that bypasses a route because of a credential is logged once at `WARNING` on the `fastapi_cachex.cache` logger, naming the route template (such as `'/items/{item_id}'`), the credential that caused it (an `Authorization` -header, a session token, or non-empty `request.session` data) and the two +header or non-empty `request.session` data) and the two options above: ```text @@ -429,8 +429,8 @@ async def report(): return await build_report() ``` -This only covers `@cache`. `invalidate()`, `CacheManager`, `CacheLock` and the -deprecated `StateManager` and sessions still raise backend errors to the caller. +This only covers `@cache`. `invalidate()`, `CacheManager` and `CacheLock` still +raise backend errors to the caller. ## Cache keys @@ -626,7 +626,7 @@ The key is not secret: it is listed by `get_all_keys()`, shown by the `/cached-records` and `/cached-hits` monitoring routes, and stored as-is in the Redis or Memcached keyspace. So for the headers that carry credentials, `Authorization`, `Proxy-Authorization`, `Cookie` and `X-Session-Token` (the -deprecated session subsystem's default `header_name`), matched in any case, the component +token header of the session middleware removed in 0.5.0), matched in any case, the component holds the full hex SHA-256 of the value (trimmed and joined as above) instead of the value: @@ -638,13 +638,13 @@ The same token always gives the same digest, so it hits its own entry, and two tokens give two entries. A missing or empty credential header is not hashed: it stays `authorization=`, like any other empty header, so every anonymous caller shares one entry and the key still shows that it is the anonymous one. -Every other header, including a session header configured under another name, +Every other header, including a custom token header such as `X-API-Key`, stays readable; if yours carries a secret, key on it through a `key_builder` (hashing it yourself) rather than `vary`. `vary=["Authorization"]` does not lift the rule for authorized requests (see [Authenticated endpoints](#authenticated-endpoints)): a request with an -`Authorization` header (or a session) still bypasses the backend unless the route is +`Authorization` header (or session data) still bypasses the backend unless the route is `public=True` or passes `cache_authorized=True`. Without either, only the anonymous `authorization=` entry is ever stored. @@ -662,7 +662,7 @@ these is what you want instead: - `private=True`, which leaves per-visitor responses to the browser cache. The per-caller rules still apply: a request carrying a cookie is cached (only -`Authorization` or a session triggers the bypass), but a response that sets a cookie is +`Authorization` or session data triggers the bypass), but a response that sets a cookie is never stored and is sent with `private`, so a route that refreshes a session cookie on every request stores nothing. If you do want `vary=["Cookie"]`, silence the warning with the standard filter, before the module defining the @@ -729,9 +729,10 @@ request it is given selects. > do want a server-side cache per user. Leave `private` unset: `private=True` > bypasses the backend, so the key builder would never be used. Pass > `cache_authorized=True` when callers authenticate with an `Authorization` -> header or a session: without it such requests bypass the backend too. +> header or session data: without it such requests bypass the backend too. > -> If callers authenticate with a cookie that no session middleware loads (for +> If callers authenticate with a cookie that no session middleware loads into +> `request.session` (for > example a token cookie your own dependency reads), nothing triggers the bypass: a plain `@cache` serves the first > caller's response to everyone, so use one of the two options above. @@ -763,14 +764,14 @@ def per_user_key(request: Request) -> str: @cache(ttl=60, key_builder=per_user_key, cache_authorized=True) async def my_dashboard(user: CurrentUser): # Sent as `Cache-Control: private, max-age=60` to a request with - # `Authorization` or a session: only this backend and the user's browser + # `Authorization` or session data: only this backend and the user's browser # keep a copy. return build_dashboard(user) ``` The per-user entry lives only in your backend. A shared cache in front of the app (CDN, reverse proxy) sees only the URL, so every response to a request with -`Authorization` or a session carries `private`, even with `cache_authorized`. +`Authorization` or session data carries `private`, even with `cache_authorized`. When identity comes from a cookie of your own instead, nothing marks the request as credentialed and the decorator's header goes out unchanged: set `private=True` (option 1), or send `Vary: Cookie` yourself if a shared cache @@ -792,7 +793,7 @@ may store it. The key builder runs only when `@cache` reads or writes the backend, so it is not called for `no_store=True`, `private=True`, routes without a `ttl`, or requests -with `Authorization` or a session on a route without `public=True` or `cache_authorized=True`. Before 0.3.8 +with `Authorization` or session data on a route without `public=True` or `cache_authorized=True`. Before 0.3.8 it was, only to feed a debug log. Keep it free of side effects. The key builder must be a sync function that returns a `str`; it is called @@ -954,7 +955,7 @@ add_routes( `media_type` instead. Both routes list only route entries (keys in the `http:v2|method|host|path|query` -format); `CacheManager`, session, state and lock keys are skipped, and so are +format); `CacheManager` and lock keys are skipped, and so are keys from a `key_builder` that does not use `build_cache_key()`. > [!WARNING] diff --git a/docs/JWT_CLAIMS.md b/docs/JWT_CLAIMS.md deleted file mode 100644 index 6b4cfc0..0000000 --- a/docs/JWT_CLAIMS.md +++ /dev/null @@ -1,479 +0,0 @@ -# JWT Claims: Implementation Notes and Extension Guide - -> [!WARNING] -> **Deprecated.** `fastapi_cachex.session` is deprecated in 0.4.0 and removed in 0.5.0 ([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420)). Importing it emits a `FutureWarning`. [Migrating to 0.4.0](MIGRATING_0_4.md#session-state-deprecated) says where to move. - -## Overview - -FastAPI-CacheX's JWT token serializer implements a minimal set of JWT claims to carry session tokens securely. This document explains: - -1. Why we do not implement the full set of JWT claims (such as `jti` and `nbf`) -2. The design considerations behind the current implementation -3. How to extend the serializer with custom claims - -The implementation lives in [`fastapi_cachex/session/token_serializers.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/session/token_serializers.py). JWT support requires the optional extra: `pip install "fastapi-cachex[jwt]"`. - -## JWT Claims in the Current Implementation - -### Implemented Standard Claims - -`JWTTokenSerializer` implements the following JWT claims: - -| Claim | Name | Required | Verified | Description | -|-------|------|----------|----------|-------------| -| `sid` | Session ID | ✅ | ✅ | Custom claim that maps to the server-side session | -| `iat` | Issued At | ✅ | ✅ | Time the token was issued (RFC 7519); rejected if it lies in the future (beyond `jwt_leeway`) | -| `exp` | Expiration | ✅ | ✅ | Token expiry: the session's `expires_at` (so it follows sliding expiration and never passes `absolute_timeout`), falling back to `iat + session_ttl` | -| `iss` | Issuer | ⚠️ | ✅ | Token issuer (optional; only issued and verified when `jwt_issuer` is set) | -| `aud` | Audience | ⚠️ | ✅ | Intended audience (optional; only issued and verified when `jwt_audience` is set) | - -### Related `SessionConfig` Fields - -| Field | Default | Description | -|-------|---------|-------------| -| `token_format` | `"simple"` | Set to `"jwt"` to use `JWTTokenSerializer` | -| `secret_key` | (required) | Signing key, at least 32 characters; used both to sign and to verify the JWT | -| `jwt_algorithm` | `"HS256"` | Signing algorithm; must be one of the supported values (`none` is rejected) | -| `jwt_issuer` | `None` | Expected `iss`; issued and verified when set | -| `jwt_audience` | `None` | Expected `aud`; issued and verified when set | -| `jwt_leeway` | `0` | Leeway in seconds for `exp`/`iat` validation | -| `session_ttl` | `3600` | Session lifetime in seconds; used for `exp` when the session has no `expires_at` | - -> [!NOTE] -> **Asymmetric algorithms:** `jwt_algorithm` accepts `HS*`, `RS*`, `ES*`, `PS*` and `EdDSA`, but the built-in serializer signs and verifies with the single `secret_key` string, so it only supports the HMAC algorithms (`HS256`, `HS384`, `HS512`). Building a `SessionManager` with an asymmetric algorithm and no custom serializer raises `ValueError`. To use one, pass a custom `token_serializer` that encodes with a private key and decodes with the matching public key (see [Extension Guide](#extension-guide-adding-custom-claims)). - -### Standard Claims That Are Not Implemented - -The following optional claims defined by RFC 7519 are **not implemented**: - -| Claim | Name | Purpose | Why it is not implemented | -|-------|------|---------|---------------------------| -| `jti` | JWT ID | Unique token identifier, prevents replay attacks | The stateful session model already handles this through server-side state | -| `nbf` | Not Before | Time the token becomes valid | Sessions normally take effect immediately; no delayed activation is needed | -| `sub` | Subject | Subject identifier (usually the user ID) | A custom `sid` claim is clearer for representing a session ID | - -## Design Rationale - -### Stateful Session vs Stateless JWT - -FastAPI-CacheX uses a **stateful session** model, which is fundamentally different from a purely stateless JWT: - -``` -┌─────────────────────────────────────────────────────────┐ -│ FastAPI-CacheX Session Model (Stateful) │ -├─────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Client │ JWT │ Server │ │ Redis/ │ │ -│ │ │ ──────> │ │ ────> │ Cache │ │ -│ │ │ (sid) │ │ lookup │ │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -│ │ -│ The JWT carries only the session ID (sid) │ -│ The actual session data is stored server-side │ -│ Revocable instantly (delete the session from cache) │ -└─────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────┐ -│ Traditional Stateless JWT (NOT used by CacheX) │ -├─────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────┐ │ -│ │ Client │ JWT │ Server │ │ -│ │ │ ──────> │ │ │ -│ │ │ (all) │ │ │ -│ └──────────┘ └──────────┘ │ -│ │ -│ The JWT contains all user info and permissions │ -│ The server is stateless and cannot revoke tokens │ -│ Revocation requires jti + a blacklist │ -└─────────────────────────────────────────────────────────┘ -``` - -A valid JWT signature is necessary but not sufficient: after decoding, `SessionManager.get_session()` still loads the session from the backend and rejects it if it is missing, not active, expired, past `absolute_timeout`, or fails IP/User-Agent binding checks. Any decoding failure is raised as `SessionTokenError`, which the session middleware (`FastAPICacheXSessionMiddleware`) treats as "no session". - -### Why a Stateful Session - -#### ✅ Advantages - -1. **Instant revocation** - - `SessionManager.delete_session()` (or `invalidate_session()`) takes effect immediately - - No token blacklist to maintain - - No need for a `jti` claim and a blacklist system - -2. **Protection of sensitive data** - - Session data (including user info) is stored server-side - - The JWT contains only minimal information (the session ID) - - Reduces the impact of a leaked JWT - -3. **Flexible session management** - - Supports sliding expiration: when a session is renewed, the middleware sends a new token with an updated `exp` through the transport the request used: the response header named by `header_name` (`X-Session-Token` by default), or `Set-Cookie` for a cookie - - Supports updating session data in real time - - Supports features such as flash messages - -4. **Small tokens** - - The JWT only needs to carry `sid` and timestamps - - Less network overhead - - Well suited to the frequent requests of API-first architectures - -#### ⚠️ Trade-offs - -1. **Requires backend storage** - - Needs a Redis, Memcached, or Memory backend - - Horizontal scaling requires a shared cache (such as a Redis cluster) - -2. **Every request needs a cache lookup** - - Adds one cache lookup per request - - But modern cache systems (Redis) are very fast (sub-millisecond) - -### Why Some Claims Are Not Needed - -#### `jti` (JWT ID) - -**Purpose**: generate a unique ID for every JWT, used for: - -- Token blacklists -- Preventing token replay attacks -- Tracking individual tokens - -**Why it is not needed**: - -```python -# A stateless JWT needs jti + a blacklist -jwt_payload = {"jti": "uuid-1234", "user_id": "123", ...} -# To revoke: add the jti to a blacklist and check it on every verification - -# FastAPI-CacheX stateful session -jwt_payload = {"sid": "session-abc123"} -# To revoke: delete the session from the cache directly -await session_manager.delete_session("session-abc123") -# On the next request the cache lookup fails and the request is rejected automatically -``` - -#### `nbf` (Not Before) - -**Purpose**: specify when a token becomes valid, used for: - -- Issuing tokens in advance for future use -- Tolerating clock skew - -**Why it is not needed**: - -- A session normally takes effect as soon as it is created -- If delayed activation is required, it belongs in the application logic -- The `jwt_leeway` setting already handles clock skew for `exp` and `iat` - -#### `sub` (Subject) - -**Purpose**: identify the subject of the token (usually the user ID) - -**Why `sid` is used instead**: - -- `sub` usually denotes an **immutable** user identifier -- `sid` denotes a **mutable** session identifier -- `SessionManager.regenerate_session_id()` changes `sid`, while `user_id` stays the same -- `sid` makes the semantics clearer - -## Extension Guide: Adding Custom Claims - -If your application needs additional JWT claims, write your own serializer and pass an instance to `SessionManager` through its `token_serializer` argument. Any object with `to_string(token) -> str` and `from_string(token_str) -> SessionToken` methods (the `TokenSerializer` protocol) will do; `from_string()` should raise `ValueError` for invalid tokens, which `SessionManager` converts into `SessionTokenError`. - -The base class below does what the built-in `JWTTokenSerializer` does and leaves two hooks for the extra claims. It keeps its own copy of the settings, read from the public `SessionConfig` fields, instead of reaching into `JWTTokenSerializer`'s private attributes, which may change in any release. Like the built-in serializer, it follows `token.expires_at` in `to_string()`, so `exp` keeps up with sliding expiration. It is the `serializer` part of [`examples/session_jwt_claims.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt_claims.py). - - -```python ---8<-- "examples/session_jwt_claims.py:serializer" -``` - - -PyJWT verifies the signature, `exp`, `iat` and (when present) `nbf` by default, and `iss`/`aud` when `issuer`/`audience` are given. Two checks of the built-in serializer are not repeated here: it rejects an asymmetric `jwt_algorithm` and rejects (`ValueError`) a `secret_key` shorter than the HMAC output. This class signs with `secret_key` too, so keep an `HS*` algorithm; for an asymmetric one, hold the private and public keys in the class and use them in `jwt.encode()` and `jwt.decode()`. - -### Example 1: Adding `jti` and `nbf` - -```python -import uuid -from typing import Any - -from fastapi_cachex.session.models import SessionToken - - -class ExtendedJWTSerializer(CustomClaimsJWTSerializer): - """Adds the jti and nbf claims.""" - - required_claims = ("jti", "nbf") - - def extra_claims(self, token: SessionToken) -> dict[str, Any]: - return { - "jti": str(uuid.uuid4()), # Unique token ID - "nbf": int(token.issued_at.timestamp()), # Not before = issued at - } -``` - -### Example 2: Adding Multi-Tenant Custom Claims - -From [`examples/session_jwt_claims.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt_claims.py): - - -```python ---8<-- "examples/session_jwt_claims.py:multi-tenant" -``` - - -### Using a Custom Serializer - -#### Option 1: Pass it to `SessionManager` (recommended) - -From [`examples/session_jwt_claims.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt_claims.py): - - -```python ---8<-- "examples/session_jwt_claims.py:setup" -``` - - -When `token_serializer` is given it overrides the built-in choice made from `token_format`. Keep `token_format="jwt"` anyway: with `"simple"`, `SessionManager` additionally performs its own HMAC signature check on the parsed token, which a JWT-based serializer does not provide. - -The example uses `MemoryBackend` so it runs without a server; any backend works, for example the Redis setup of [Backends](BACKENDS.md#closing-a-backend). - -#### Option 2: Subclass `SessionManager` (advanced) - -```python -from fastapi_cachex.backends.base import BaseCacheBackend -from fastapi_cachex.session import SessionConfig, SessionManager - - -class MultiTenantSessionManager(SessionManager): - """SessionManager with multi-tenant support.""" - - def __init__( - self, backend: BaseCacheBackend, config: SessionConfig, tenant_id: str - ) -> None: - super().__init__( - backend, - config, - token_serializer=MultiTenantJWTSerializer( - config=config, tenant_id=tenant_id - ), - ) - - -# Usage -manager = MultiTenantSessionManager(backend, config, tenant_id="acme-corp") -``` - -Do not replace the serializer by assigning a private attribute after construction; pass it through the `token_serializer` argument so the manager uses it for both issuing and parsing tokens. - -## Complete Application Example - -[`examples/session_jwt_claims.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt_claims.py) puts the pieces above together into a runnable app: - - -```python ---8<-- "examples/session_jwt_claims.py" -``` - - -## Security Considerations - -### 1. Token Size - -Adding more claims increases the size of the JWT, which affects: - -- Network overhead -- Cookie size limits (if the token is stored in a cookie, e.g. with `FastAPICacheXSessionMiddleware`) -- Performance - -**Recommendation**: add only the claims you need and avoid putting large amounts of data in the JWT. - -### 2. Sensitive Data - -Do not store sensitive data (such as passwords or credit card numbers) in a JWT: - -- A JWT can be decoded (it is base64url-encoded) -- Even with a signature, the contents are readable -- Store sensitive data in the server-side session instead - -### 3. Claim Validation - -Always validate custom claims in `from_string()`: - -```python -# ❌ Bad: no validation -payload = jwt.decode(token_str, self.secret, algorithms=[self.algorithm]) -tenant_id = payload.get("tenant_id") # May be missing or invalid - -# ✅ Good: strict validation -payload = jwt.decode( - token_str, - self.secret, - algorithms=[self.algorithm], - options={"require": ["sid", "iat", "exp", "tenant_id"]}, -) -if payload["tenant_id"] != self.tenant_id: - raise ValueError("Invalid tenant_id") -``` - -### 4. Key Rotation - -To support key rotation, you can use the `kid` (Key ID) header parameter. The following is a sketch built on `CustomClaimsJWTSerializer`; building `payload` and `kwargs` works as in its `to_string()` and `from_string()`: - -```python -class KeyRotationJWTSerializer(CustomClaimsJWTSerializer): - def __init__( - self, config: SessionConfig, keys: dict[str, str], current_key_id: str - ) -> None: - super().__init__(config) - # Key ID -> secret. Keep a retired key until its tokens have expired. - self.keys = keys - self.current_key_id = current_key_id - - def to_string(self, token: SessionToken) -> str: - # Sign with the current key and name it in the header - return jwt.encode( - payload, - self.keys[self.current_key_id], - algorithm=self.algorithm, - headers={"kid": self.current_key_id}, - ) - - def from_string(self, token_str: str) -> SessionToken: - # Read kid from the (not yet verified) header and pick the matching key - kid = jwt.get_unverified_header(token_str).get("kid") - key = self.keys.get(kid) - if key is None: - msg = "Unknown key ID" - raise ValueError(msg) - - payload = jwt.decode(token_str, key, algorithms=[self.algorithm], **kwargs) - # ... -``` - -## Testing Recommendations - -Add tests for your custom serializer: - -```python -import jwt -import pytest - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session import SessionConfig, SessionManager, SessionUser -from fastapi_cachex.session.exceptions import SessionTokenError - - -@pytest.mark.asyncio -async def test_custom_claims_included(): - """Custom claims are included in the JWT and the token round-trips.""" - backend = MemoryBackend() - config = SessionConfig(secret_key="a" * 32, token_format="jwt") - - serializer = MultiTenantJWTSerializer( - config=config, - tenant_id="test-tenant", - api_version="v1", - ) - manager = SessionManager(backend, config, token_serializer=serializer) - - user = SessionUser(user_id="u1", username="alice") - session, token = await manager.create_session(user=user) - - # The token can be decoded and carries the custom claim - claims = jwt.decode(token, options={"verify_signature": False}) - assert claims["tenant_id"] == "test-tenant" - - # get_session returns (session, renewed_token) - retrieved, _renewed = await manager.get_session(token) - assert retrieved.session_id == session.session_id - - -@pytest.mark.asyncio -async def test_custom_claims_validated(): - """A token whose custom claims fail validation is rejected.""" - backend = MemoryBackend() - config = SessionConfig(secret_key="a" * 32, token_format="jwt") - - # Create a token with tenant_id="tenant-1" - manager1 = SessionManager( - backend, - config, - token_serializer=MultiTenantJWTSerializer(config, tenant_id="tenant-1"), - ) - _session, token = await manager1.create_session(user=SessionUser(user_id="u1")) - - # Try to validate it with tenant_id="tenant-2" (must fail) - manager2 = SessionManager( - backend, - config, - token_serializer=MultiTenantJWTSerializer(config, tenant_id="tenant-2"), - ) - - # The serializer's ValueError surfaces as SessionTokenError - with pytest.raises(SessionTokenError, match="Invalid tenant_id"): - await manager2.get_session(token) -``` - -## FAQ - -### Q: Why isn't `jti` implemented by default? - -A: `jti` is mainly used to revoke stateless JWTs (via a blacklist). FastAPI-CacheX uses stateful sessions, so a token can be revoked by deleting the server-side session data directly; no separate blacklist mechanism is needed. - -### Q: Do I need `nbf`? - -A: In most cases, no. `nbf` is for tokens that are issued in advance but become valid later. If your application needs this, we recommend handling it in the application logic (for example, recording the activation time in `session.data`) rather than at the JWT level. - -### Q: Can I add claims without writing code? - -A: Not currently; custom claims require a custom `token_serializer`, such as the classes in the [Extension Guide](#extension-guide-adding-custom-claims). A future version might add a configuration option such as the hypothetical one below (it does not exist today, and `SessionConfig` rejects unknown fields): - -```python -SessionConfig( - token_format="jwt", - jwt_custom_claims={"tenant_id": "acme", "version": "v1"}, -) -``` - -That would add complexity, though. The current design offers enough flexibility while keeping the code simple. - -### Q: Do custom claims affect performance? - -A: Only slightly. JWT encoding/decoding performance depends mainly on: - -1. The signing algorithm (HS256 is fast) -2. Token size (more claims = larger token) -3. Network transfer (larger tokens) - -As long as you don't add large amounts of data, the impact is negligible. - -### Q: How do I include user permissions in the JWT? - -A: We don't recommend putting permissions in the JWT. FastAPI-CacheX uses stateful sessions, so you should: - -```python -# ✅ Recommended: store them in the server-side session -session.user.roles = ["admin", "editor"] -session.user.permissions = ["read", "write", "delete"] -await manager.update_session(session) - -# ❌ Not recommended: putting them in JWT claims -# Permission changes cannot take effect immediately unless every existing token is revoked -``` - -## References - -- [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) -- [PyJWT Documentation](https://pyjwt.readthedocs.io/) -- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) -- [FastAPI-CacheX Session Documentation](SESSION.md) - -## Summary - -FastAPI-CacheX's JWT implementation focuses on the **stateful session** use case and provides: - -- ✅ **Implemented**: the basic JWT claims (`sid`, `iat`, `exp`, `iss`, `aud`) -- ✅ **Implemented**: signature verification and expiry checks -- ✅ **Implemented**: an extensible design (via subclassing and the `token_serializer` argument) -- ⚠️ **Not implemented**: `jti`, `nbf`, `sub` (these are not required for stateful sessions) -- 🔧 **Extensible**: developers can easily add custom claims (see the examples in this document) - -This design strikes a good balance between security, performance, and flexibility. If your application has special requirements, refer to the extension examples in this document. diff --git a/docs/LOCK.md b/docs/LOCK.md index 6a20e15..5525ad6 100644 --- a/docs/LOCK.md +++ b/docs/LOCK.md @@ -41,7 +41,7 @@ Complete runnable example: [`examples/cache_lock.py`](https://github.com/allen00 - **TTL Expiration**: If a task takes longer than its `ttl` and fails to renew, the lock entry expires in the backend and becomes free. Another process or container can then acquire the lock while the original code is still running. Subsequent calls to `extend()` or `release()` by the original holder will safely return `False` without throwing an error. Always choose a `ttl` longer than the expected work, or call `extend()` periodically during long-running operations. - **TTL Renewal (`extend`)**: `extend(ttl)` updates the key's TTL only while the lock is still owned by this holder instance, preventing race conditions on expired locks. - **One Instance per Acquisition Rule**: A single `CacheLock` instance tracks its active ownership state. Re-entering or sharing a single `CacheLock` instance across concurrent tasks raises a `RuntimeError`. Instantiate a new `CacheLock` instance for each acquisition. -- **Namespace**: Lock keys live under their own `lock:` prefix by default (e.g. `lock:report:123`), separate from `cache:` and `oauth_state:`. +- **Namespace**: Lock keys live under their own `lock:` prefix by default (e.g. `lock:report:123`), separate from `CacheManager`'s `cache:`. - **Backend**: Without `backend=`, a lock uses the backend registered with `BackendProxy.set()`. Unlike `@cache`, it does not fall back to a `MemoryBackend`: with no backend registered, `acquire()` raises `BackendNotFoundError`. A lock only excludes processes that share its backend, so a per-process `MemoryBackend` only coordinates tasks within one process. > [!NOTE] diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index 8eee85d..30db062 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -52,6 +52,9 @@ Every warning below names the setting to change and links to its issue. `FutureW `fastapi_cachex.session` and `fastapi_cachex.state` are deprecated in 0.4.0 and removed in 0.5.0 ([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420), [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421)). FastAPI-CacheX is narrowing to HTTP and application caching. Session handling and OAuth state are security-sensitive, and libraries built for them maintain them better. 0.3.9 did not announce this, so both packages keep working throughout 0.4.x and get security fixes only. +> [!NOTE] +> 0.5.0 has removed both packages; see [Migrating to 0.5.0](MIGRATING_0_5.md#session-state-removed). The table below still applies. + Importing either package, or reading one of their names from `fastapi_cachex` (such as `fastapi_cachex.SessionConfig`), emits a `FutureWarning` that points at the importing line. `import fastapi_cachex` on its own does not warn, and neither do `@cache`, `CacheManager`, `CacheLock` or the backends. The session and state names are no longer in `fastapi_cachex.__all__`, so `from fastapi_cachex import *` stops providing them. Until you migrate, import them by name. Where to move: @@ -170,7 +173,7 @@ async def profile(session: AuthenticatedSession): ... ### SessionMiddleware {#session-middleware} -The header-only `SessionMiddleware` is removed ([#69](https://github.com/allen0099/FastAPI-CacheX/issues/69)); 0.3.x already emits a `DeprecationWarning`. Use `FastAPICacheXSessionMiddleware`, which also reads the header and `Authorization: Bearer` token and adds `request.session`. It also sends a session cookie to clients that sent no token, so set the cookie options as in [Session cookie](#session-cookie). See [Session management](SESSION.md#migration-sessionmiddleware-fastapicachexsessionmiddleware). +The header-only `SessionMiddleware` is removed ([#69](https://github.com/allen0099/FastAPI-CacheX/issues/69)); 0.3.x already emits a `DeprecationWarning`. Use `FastAPICacheXSessionMiddleware`, which also reads the header and `Authorization: Bearer` token and adds `request.session`. It also sends a session cookie to clients that sent no token, so set the cookie options as in [Session cookie](#session-cookie). See [Session management in the 0.4.1 docs](https://github.com/allen0099/FastAPI-CacheX/blob/v0.4.1/docs/SESSION.md#migration-sessionmiddleware-fastapicachexsessionmiddleware). ```python # Before @@ -340,7 +343,7 @@ await backend.clear_pattern("GET|||*") # "http:v2|GET|*" with 0.4.0's key forma ### delete() return value {#backend-delete} -`BaseCacheBackend.delete()` returns whether a key was removed in 0.4.0, instead of `None` ([#71](https://github.com/allen0099/FastAPI-CacheX/issues/71)), and the non-atomic fallbacks on the base class use the result: `delete_many()` counts the keys that existed rather than the ones attempted, and `get_and_delete()` and `delete_if_equals()` let a caller win only if its delete removed the key. A third-party backend that still declares `-> None` fails type checking; at runtime the fallbacks count `None` as removed, as 0.3.x did, and emit a `FutureWarning`. 0.5.0 treats `None` as `False`. 0.3.9 does not warn: a subclass cannot declare `-> bool` there without a type error against the 0.3.x base class. +`BaseCacheBackend.delete()` returns whether a key was removed in 0.4.0, instead of `None` ([#71](https://github.com/allen0099/FastAPI-CacheX/issues/71)), and the non-atomic fallbacks on the base class use the result: `delete_many()` counts the keys that existed rather than the ones attempted, and `get_and_delete()` and `delete_if_equals()` let a caller win only if its delete removed the key. A third-party backend that still declares `-> None` fails type checking; at runtime the fallbacks count `None` as removed, as 0.3.x did, and emit a `FutureWarning`. 0.5.0 treats `None` as `False` (see [Migrating to 0.5.0](MIGRATING_0_5.md#backend-delete-none)). 0.3.9 does not warn: a subclass cannot declare `-> bool` there without a type error against the 0.3.x base class. ```python # Before diff --git a/docs/MIGRATING_0_5.md b/docs/MIGRATING_0_5.md new file mode 100644 index 0000000..ee63f08 --- /dev/null +++ b/docs/MIGRATING_0_5.md @@ -0,0 +1,119 @@ +# Migrating to 0.5.0 {#migrating-to-050} + +0.5.0 contains breaking changes, collected in the [0.5.0 milestone](https://github.com/allen0099/FastAPI-CacheX/issues?q=milestone%3A0.5.0). This page lists every one of them, what to change, and whether 0.4.x already warns about it. The largest is the removal of sessions and OAuth state, which 0.4.0 deprecated. + +## Before you upgrade {#before-you-upgrade} + +Upgrade to the latest 0.4.x release first and run your test suite with the library's warnings turned into errors: + +```bash +python -W error::FutureWarning -m pytest +``` + +or, with pytest's own setting: + +```toml +[tool.pytest.ini_options] +filterwarnings = [ + "error::FutureWarning", +] +``` + +0.4.x emits a `FutureWarning` when `fastapi_cachex.session` or `fastapi_cachex.state` is imported, and when a third-party backend's `delete()` returns `None`. Once 0.4.x runs without them, the changes with a warning in the "Warned in 0.4.x" column are done; the rest of this page covers what no warning can detect. + +## Summary {#summary} + +| Change | Issue | Warned in 0.4.x | Section | +|--------|-------|-----------------|---------| +| Sessions and OAuth state removed | [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421) | `FutureWarning` | [Sessions and OAuth state](#session-state-removed) | +| `jwt` extra removed | [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421) | No | [jwt extra](#jwt-extra) | +| `@cache` no longer recognises sessions loaded by the removed middleware | [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421) | No | [Session tokens and @cache](#cache-session-token) | +| `itsdangerous` and `starlette` are no longer direct requirements; lower `fastapi` floor | [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421) | No | [Dependencies](#dependencies) | +| A backend `delete()` that returns `None` counts as not removed | [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421) | `FutureWarning` | [delete() returning None](#backend-delete-none) | + +## Sessions and OAuth state are removed {#session-state-removed} + +`fastapi_cachex.session` and `fastapi_cachex.state`, deprecated in 0.4.0 ([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420)), are removed ([#421](https://github.com/allen0099/FastAPI-CacheX/issues/421)). That takes with it `FastAPICacheXSessionMiddleware`, `SessionManager`, `SessionConfig`, the session dependencies (`AuthenticatedSession`, `OptionalSession`, `get_session` and the rest), the token serializers, `StateManager`, their proxies and their exceptions. Importing either package now raises `ModuleNotFoundError`, and reading one of the names from `fastapi_cachex` (such as `fastapi_cachex.SessionConfig`) raises `AttributeError`; neither warns first any more. + +Where to move is unchanged from 0.4.0: see [Sessions and OAuth state are deprecated](MIGRATING_0_4.md#session-state-deprecated) in the 0.4.0 guide. In short, Starlette's `SessionMiddleware` for signed-cookie sessions, a server-side session library for sessions that must live on the server, the access tokens of your authentication stack for APIs, and your OAuth client library for `state`. `@cache`, `CacheManager`, `CacheLock` and the backends are not affected. + +Sessions and states already in the backend need no cleanup; they expire on their own TTL. To remove them at once on Redis or the memory backend, clear their prefixes (the defaults were `session:` and `oauth_state:`): + +```python +await backend.clear_pattern("session:*") +await backend.clear_pattern("oauth_state:*") +``` + +Check first that none of your own keys starts with either prefix. Memcached cannot enumerate keys, so there they just expire. + +The 0.4.x documentation of the removed packages stays readable in the repository at the [v0.4.1 tag](https://github.com/allen0099/FastAPI-CacheX/tree/v0.4.1/docs). + +### jwt extra {#jwt-extra} + +The `jwt` extra only pulled in `PyJWT` for `SessionConfig(token_format="jwt")`, so it is removed. `uv add "fastapi-cachex[jwt]"` (or pip) now only warns about an unknown extra and installs without `PyJWT`. If your own code uses `PyJWT`, depend on it directly: + +```bash +# Before +uv add "fastapi-cachex[redis,jwt]" + +# After +uv add "fastapi-cachex[redis]" PyJWT +``` + +## Session tokens and @cache {#cache-session-token} + +`@cache` bypasses the shared backend for a request with credentials, and sends its answer with `private` (see [HTTP caching](HTTP_CACHING.md#authenticated-endpoints)). Two kinds of credential are left: + +- an `Authorization` header; +- a non-empty `request.session`, from any session middleware, such as Starlette's `SessionMiddleware`. + +What 0.4.x also counted, and 0.5.0 no longer does: + +- A session that `FastAPICacheXSessionMiddleware` loaded, from its `X-Session-Token` header, a bearer token or its cookie, even with no data in it. The middleware is gone, so the check went with it. + +A `Cookie` header on its own still does not bypass the cache, as before. `X-Session-Token` in `@cache(vary=[...])` is still keyed on a SHA-256 digest of its value, like `Authorization`, `Proxy-Authorization` and `Cookie`, so keys do not change. If your replacement for the session middleware identifies callers by a header or cookie that `@cache` cannot see, a plain `@cache` on such a route serves the first caller's response to everyone. Use `private=True`, or a `key_builder` that puts the verified identity into the key together with `cache_authorized=True`, and, for a custom token header, key on it through the `key_builder` (hashing it yourself) rather than `vary`: + +```python +# Before: a session the middleware loaded from X-Session-Token bypassed the backend +@cache(ttl=60, vary=["X-Session-Token"], cache_authorized=True) + +# After: hash the token yourself, or better, key on the verified user id +def per_user_key(request: Request) -> str: + return build_cache_key(request, request.state.user_id) + + +@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) +``` + +0.4.x does not warn: it cannot tell what will replace the session middleware. + +## Dependencies {#dependencies} + +The core needs fewer and older packages ([#421](https://github.com/allen0099/FastAPI-CacheX/issues/421)): + +- `itsdangerous` is no longer a requirement. Only the session middleware needed it. If you move to Starlette's `SessionMiddleware`, which imports it, add `itsdangerous` to your own dependencies. +- `starlette` is no longer a direct requirement. 0.4.x required `starlette>=1.0.0` for the session middleware; 0.5.0 uses whichever version your `fastapi` allows. +- The `fastapi` floor drops from `0.133.0` to `0.128.2`, the oldest release the test suite passes on (with the oldest Starlette it accepts, 0.40.0). `pydantic>=2.7.0` is unchanged. + +Nothing needs to change unless something else in your project relied on `fastapi-cachex` to install `itsdangerous` or a recent `starlette`; pin those yourself. + +## delete() returning None {#backend-delete-none} + +Since 0.4.0, `BaseCacheBackend.delete()` returns whether the key was removed (see [delete() return value](MIGRATING_0_4.md#backend-delete)). The non-atomic fallbacks on the base class (`delete_many()`, `get_and_delete()` and `delete_if_equals()`) still counted a `None` from a third-party `delete()` as removed, as 0.3.x did, and emitted a `FutureWarning`. 0.5.0 reads the result with `bool()`, so `None` now counts as not removed, without a warning: `delete_many()` returns `0` for it, and `get_and_delete()` and `delete_if_equals()` report that the caller did not win, although the key is gone. `CacheManager.delete()` is built on `get_and_delete()`, so it returns `False` too. + +Built-in backends are not affected. A third-party backend fixes this by returning a `bool`: + +```python +# Before +class MyBackend(BaseCacheBackend): + async def delete(self, key: str) -> None: + await self._client.delete(key) + + +# After +class MyBackend(BaseCacheBackend): + async def delete(self, key: str) -> bool: + return await self._client.delete(key) > 0 +``` + +0.4.x emits a `FutureWarning` whenever a fallback receives `None`. diff --git a/docs/MIGRATING_FROM_FASTAPI_CACHE2.md b/docs/MIGRATING_FROM_FASTAPI_CACHE2.md new file mode 100644 index 0000000..1b7982c --- /dev/null +++ b/docs/MIGRATING_FROM_FASTAPI_CACHE2.md @@ -0,0 +1,136 @@ +# Migrating from fastapi-cache2 {#migrating-from-fastapi-cache2} + +[fastapi-cache2](https://github.com/long2ice/fastapi-cache) (imported as `fastapi_cache`) is the most installed FastAPI cache; its latest release, 0.2.2, is from July 2024. This page maps its API onto FastAPI-CacheX, lists the behaviour that differs, and names what has no equivalent. It describes fastapi-cache2 0.2.2 and FastAPI-CacheX 0.5.0. If you are still deciding whether to switch, see [When to use it](COMPARISON.md). + +FastAPI-CacheX needs Python 3.10 or newer and FastAPI 0.128.2 or newer. + +## A route, before and after {#before-and-after} + +With fastapi-cache2: + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi_cache import FastAPICache +from fastapi_cache.backends.redis import RedisBackend +from fastapi_cache.decorator import cache +from redis import asyncio as aioredis + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + redis = aioredis.from_url("redis://localhost") + FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache") + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/items/{item_id}") +@cache(expire=60) +async def read_item(item_id: int) -> dict[str, int]: + return {"item_id": item_id} +``` + +With FastAPI-CacheX (`uv add "fastapi-cachex[redis]"`): + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from fastapi_cachex import BackendProxy, cache +from fastapi_cachex.backends import AsyncRedisCacheBackend + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + backend = AsyncRedisCacheBackend(host="localhost", key_prefix="fastapi-cache:") + BackendProxy.set(backend) + try: + yield + finally: + BackendProxy.set(None) + await backend.aclose() + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/items/{item_id}") +@cache(ttl=60) +async def read_item(item_id: int) -> dict[str, int]: + return {"item_id": item_id} +``` + +The decorator order is the same in both: the route decorator first, `@cache` directly above the function. The full Redis setup is in [Backends](BACKENDS.md#redis). + +## API mapping {#api-mapping} + +| fastapi-cache2 | FastAPI-CacheX | Notes | +|----------------|----------------|-------| +| `FastAPICache.init(backend, prefix=...)` | `BackendProxy.set(backend)` | The prefix is the backend's `key_prefix` (default `fastapi_cachex:`). Without a backend, `@cache` falls back to a `MemoryBackend` and logs a warning. | +| `InMemoryBackend()` | `MemoryBackend()` | `MemoryBackend(max_entries=...)` caps it with LRU eviction. | +| `RedisBackend(redis)` | `AsyncRedisCacheBackend(host=..., port=..., password=..., db=...)` | It builds its own client from these settings rather than taking one. | +| `MemcachedBackend(aiomcache.Client(...))` | `MemcachedBackend(servers=["host:port"])` | Uses `pymemcache` (the `memcached` extra). Memcached cannot enumerate keys, so clearing by path or pattern does nothing there. | +| `DynamoBackend` | — | No DynamoDB backend. | +| `@cache(expire=60)` | `@cache(ttl=60)` | `ttl` also takes a `timedelta`. Without `ttl` nothing is stored. | +| `FastAPICache.init(expire=...)` | — | No global default for `@cache`; give each route its `ttl`. `CacheManager(default_ttl=...)` has one for the application cache. | +| `@cache(namespace="items")` | — | Keys are built from the request (see [Keys](#keys)); clear a group of routes by path or pattern instead. | +| `@cache(key_builder=f)` | `@cache(key_builder=f)` | The function takes only the `Request` and returns a `str`. Build it with `build_cache_key(request, *components)` (see [Adding components to the key](HTTP_CACHING.md#adding-components-to-the-key)). | +| `@cache(coder=...)`, `JsonCoder`, `PickleCoder` | — | The rendered response body is stored as is (see [Storage](#storage)). | +| `FastAPICache.clear(namespace=...)` | `await backend.clear_path(path, include_params=True)` or `clear_pattern(...)` | On the backend from the `CacheBackend` dependency or `BackendProxy.get()`. See [Clearing the cache](HTTP_CACHING.md#clearing-the-cache). | +| `FastAPICache.clear(key=...)` | `await invalidate(request)` | Rebuilds the key of the request and deletes it. | +| `FastAPICache.clear()` | `await backend.clear()` | On Memcached this flushes the whole server. | +| `X-FastAPI-Cache: HIT` / `MISS` (`cache_status_header=`) | `@cache(debug_header=True)` sends `X-Cache: HIT`, `MISS` or `BYPASS` | Off by default. | +| `@cache` on a function that is not an endpoint | `@cached(ttl=60)`, or `CacheManager.get_or_set()` | Keyed on the arguments, like fastapi-cache2. See [Caching a function](APP_CACHE.md#caching-a-function). | +| `FastAPICache.init(enable=False)` | — | There is no switch that turns caching off. In tests, set a fresh `MemoryBackend` for each test. | + +## Behaviour that differs {#behaviour-that-differs} + +### Keys come from the request, not the arguments {#keys} + +fastapi-cache2 hashes the function's module, name and arguments. FastAPI-CacheX keys on the request: `http:v2|method|host|path|query`, with the query parameters sorted. In practice: + +- Two hosts serving the same app get separate entries, and so do two query strings that FastAPI parses into the same arguments (`?page=1` and `?page=01`). +- A response that depends on a request header needs that header in the key: `@cache(vary=["Accept-Language"])`, or a `key_builder`. fastapi-cache2 would have needed a custom key builder too, unless the header was a function argument. +- Keys from fastapi-cache2 are not read. The first request after the switch is a miss; the old keys expire on their own TTL, or clear them under the old prefix. + +### Requests with credentials bypass the cache {#credentials} + +fastapi-cache2 caches a request with an `Authorization` header like any other, so a per-user endpoint has to put the user into its key. FastAPI-CacheX does not read or write the shared backend for a request with `Authorization` or a non-empty `request.session`, and answers it with `Cache-Control: private`. If you relied on fastapi-cache2 caching such routes: + +- When the response is the same for every user, set `@cache(ttl=60, public=True)`. +- When it is per user, set `cache_authorized=True` with a `key_builder` that puts the verified user into the key. See [Requests with credentials](HTTP_CACHING.md#requests-with-credentials). + +A route that sets a cookie is never stored either. + +### The client's `Cache-Control` is ignored {#request-cache-control} + +fastapi-cache2 skips the cache for a request with `Cache-Control: no-store` and re-renders for `no-cache`. FastAPI-CacheX ignores the request's `Cache-Control`, so no client can send every request to your handler. A matching `If-None-Match` still gets a `304`. + +### Storage {#storage} + +fastapi-cache2 stores the return value with a coder (JSON by default, pickle optional) and decodes it back into the endpoint's return annotation. FastAPI-CacheX stores the response FastAPI rendered: its body bytes, status code, media type and headers, in a JSON envelope. Nothing is pickled, so an entry read from a shared Redis cannot run code, and any response class can be cached, including `HTMLResponse` and `PlainTextResponse`. A `StreamingResponse` or `FileResponse` is served but not stored. + +The application cache (`CacheManager`, `@cached`) stores JSON values; see [JSON round-trip](APP_CACHE.md#json-round-trip) for what comes back. + +### Headers {#headers} + +Both libraries send `Cache-Control: max-age` and a weak `ETag` and answer a matching `If-None-Match` with `304`. FastAPI-CacheX also writes the other directives from the decorator's arguments (`no_cache`, `no_store`, `private`, `public`, `immutable`, `must_revalidate`, `stale`), sends `Age` on a hit, and answers `HEAD` from the cached `GET`. The handler does not need a `Response` parameter for any of this. See [Cache-Control directives](HTTP_CACHING.md#cache-control-directives). + +### Only GET is stored {#methods} + +Both libraries cache only `GET`. On a route that also accepts `HEAD`, FastAPI-CacheX answers `HEAD` from the `GET` entry. + +## What has no equivalent {#no-equivalent} + +- A DynamoDB backend. +- `namespace=`, and clearing by namespace. Clear by path, by pattern, or with `invalidate()`. +- Coders, and decoding the cached value back into the return annotation. +- A global default `expire`, and `enable=False`. +- Python 3.8 and 3.9, and FastAPI releases older than 0.128.2. diff --git a/docs/SESSION.md b/docs/SESSION.md deleted file mode 100644 index a6a8fb7..0000000 --- a/docs/SESSION.md +++ /dev/null @@ -1,657 +0,0 @@ -# Session Management Extension - -> [!WARNING] -> **Deprecated.** `fastapi_cachex.session` is deprecated in 0.4.0 and removed in 0.5.0 ([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420)). Importing it emits a `FutureWarning`. [Migrating to 0.4.0](MIGRATING_0_4.md#session-state-deprecated) says where to move. - -FastAPI-CacheX Session Management provides complete user session handling, including signed -tokens, sliding expiration, and optional IP/User-Agent binding. Session contents always live in -the cache backend; the client only holds a single signed token. - -**How the token travels with `FastAPICacheXSessionMiddleware`:** - -| Token source | Response side | -|--------------|---------------| -| Custom header (default `X-Session-Token`) / `Authorization: Bearer` / **cookie** (default name `__Host-session`) | Routed by source: a request that sent a header or bearer token (even one that no longer resolves) gets its token in the response header; otherwise (a cookie, or no token at all) it gets `Set-Cookie` | - -The header-only `SessionMiddleware`, deprecated since 0.3.1, was **removed in 0.4.0**; see -[Migration](#migration-sessionmiddleware-fastapicachexsessionmiddleware). - -The six `cookie_*` settings of `SessionConfig` (`cookie_name`, `cookie_max_age`, `cookie_path`, -`cookie_same_site`, `cookie_https_only`, `cookie_domain`) are **read only by -`FastAPICacheXSessionMiddleware`**; setting them has no effect when `SessionManager` is used -without it, although `SessionConfig` still validates them (see [Cookie defaults](#cookie-defaults)). - -Complete runnable examples: [`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py) and [`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py). - -## Features - -- ✅ **Session lifecycle management**: create, read, update, delete, invalidate -- ✅ **Security**: - - HMAC-SHA256 token signing - - IP address binding (optional) - - User-Agent binding (optional) - - Session ID regeneration after login -- ✅ **Multiple token sources**: custom header, `Authorization: Bearer`, cookie - (cookies are supported by `FastAPICacheXSessionMiddleware` only) -- ✅ **Optional JWT format**: use a JWT as the session token (requires the `jwt` extra) -- ✅ **Sliding expiration**, plus an optional absolute timeout -- ✅ **Flash messages**: pass messages across requests -- ✅ **Multiple backends**: Redis, Memcached, in-memory -- ✅ **API-first or browser-based architectures**: the client can keep the token itself - (header/bearer), or leave it to the browser as a cookie (`FastAPICacheXSessionMiddleware`) - -## Quick Start - -### 1. Installation - -Session management is built into FastAPI-CacheX: - -```bash -uv add fastapi-cachex -``` - -To enable the JWT token format: - -```bash -uv add "fastapi-cachex[jwt]" -``` - -### 2. Basic Usage - -This is [`examples/session_api.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_api.py): an API client logs in, -keeps the token it gets back and sends it on later requests. - - -```python ---8<-- "examples/session_api.py" -``` - - -The example also registers the manager on `SessionManagerProxy`. With the manager -there, the middleware can pick it up instead of taking it as an argument. When -`config` is omitted, the middleware uses `session_manager.config`: - -```python -app.add_middleware(FastAPICacheXSessionMiddleware) # picked up from the proxy -``` - -`get_session` (and its alias `require_session`) raises `401 Authentication required` with a -`WWW-Authenticate: Bearer` header when the request carries no valid session. A token that is -malformed, forged, expired, invalidated or fails a binding check is never an error at the -middleware level: the request simply proceeds without a session. - -The session object the dependencies return is the backend `Session` model. A session created by -`FastAPICacheXSessionMiddleware` from `request.session` (see the Migration section below) is -anonymous, so `session.user` is `None`. - -`get_session` accepts such a session, so it only proves the request carries *a* session, not -that anyone logged in. Any visitor who reaches a route that writes to `request.session` (a cart, -a CSRF value) gets one. Guard routes that need a logged-in user with `require_user_session` (or -its annotated form `AuthenticatedSession`), which also answers `401` when `session.user` is -`None`, as `/profile` above does. `/logout` only deletes the session, so `SessionDep` (the -annotated form of `get_session`) is enough there, and `/public` uses `OptionalSession` -(`get_optional_session`), which gives `None` instead of answering `401`. - -`UserSessionDep` is the same as `AuthenticatedSession`: it answers `401` for an anonymous -session. Before 0.4.0 it was an alias of `SessionDep` and admitted anonymous sessions; use -`SessionDep` where that is what a route needs (see -[Migrating to 0.4.0](MIGRATING_0_4.md#user-session-dep)). - -Under `FastAPICacheXSessionMiddleware`, log a user in with `await login(request, user)`. It -attaches the `SessionUser` that `require_user_session` / `AuthenticatedSession` check, under a -new session ID, and the middleware sends the token; see -[Regenerate the Session ID After Login](#5-regenerate-the-session-id-after-login). The `/login` -above instead hands an API client its token in the body: `create_session(user=...)` sets -`session.user` too, but the middleware sends nothing for a session it did not load or start. -Keys written to `request.session` (`request.session["user_id"] = ...`) are application data: -the library does not treat them as a login, so `AuthenticatedSession` still answers `401` for -such a session. `session.user` itself is read-only: assigning it raises `AttributeError`, so -apart from a `Session` built with one, a session gets a user only from `login()` or -`create_session(user=...)`. Log out with -`await logout(request)`. - -### 3. Full Example (Redis Backend) - -This is [`examples/session_redis.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_redis.py). Like -[`examples/redis_backend.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/redis_backend.py), it reads the Redis -settings from `REDIS_HOST`, `REDIS_PORT`, `REDIS_DB` and `REDIS_PASSWORD`. - - -```python ---8<-- "examples/session_redis.py" -``` - - -Changes made to a `Session` object inside a handler (flash messages, `session.data`) are only -persisted when you call `session_manager.update_session(session)`. The save is conditional: it -stores the session only while the backend still holds what this object last read or wrote, and -returns `False` otherwise. A session that another request deleted, invalidated or rotated in the -meantime is not brought back, and of two requests that change the same session at the same time, -the first save wins (see [Session writes](MIGRATING_0_4.md#session-writes)). - -`delete_user_sessions()` and `clear_expired_sessions()` enumerate every key in the backend via -`get_all_keys()` and load each session under `backend_key_prefix`, so their cost grows with the -size of the backend. On the Memcached backend, which cannot enumerate keys, they find nothing and -return `0` (with a `RuntimeWarning` from the backend). - -`clear_expired_sessions()` removes every session that can no longer be used: those past their -`expires_at`, and those no longer `ACTIVE` (invalidated with `invalidate_session()`, or marked -expired by an earlier read) that would otherwise stay in the backend until their TTL runs out. -Both methods delete what they find with a single `backend.delete_many()` call. - -## Migration: SessionMiddleware → FastAPICacheXSessionMiddleware - -`SessionMiddleware`, deprecated since 0.3.1, was removed in 0.4.0. Use -`FastAPICacheXSessionMiddleware` instead: - -- **`SessionMiddleware`** (a `BaseHTTPMiddleware`, removed): passed the token in a custom header - (default `X-Session-Token`) and/or `Authorization: Bearer`, suited to API-first architectures - where the client manages the token. Cookie transport was not supported. -- **`FastAPICacheXSessionMiddleware`** (a pure ASGI middleware): compatible with Starlette's - built-in `SessionMiddleware`, exposing the same dict-like `request.session`. It passes the signed - session token in a cookie (default cookie name `__Host-session`), while the session contents are stored - in the backend (the cache backend of the `SessionManager`) rather than encoded into the cookie - itself as Starlette's own implementation does. Token resolution is "header first, cookie - second": it reads the custom header (default `X-Session-Token`) and/or `Authorization: Bearer` - first and only falls back to the cookie when neither is present, so clients that used - `X-Session-Token` with `SessionMiddleware` keep working unchanged. The response side is routed - by source too: when the request sent a header or bearer token (even one that no longer - resolves), a new or renewed token is sent back in the `header_name` response header and no - `Set-Cookie` is emitted; a token that arrived in a cookie (or a brand-new anonymous session for a - request without a token) uses `Set-Cookie`. - -`FastAPICacheXSessionMiddleware` puts the loaded `Session` object into `request.state` as -`SessionMiddleware` did, so the session dependencies `get_session`, `get_optional_session`, -`require_session` and `require_user_session` work without any changes: - -```python -from fastapi import Depends -from fastapi_cachex.session import FastAPICacheXSessionMiddleware, require_user_session - -app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config -) - - -@app.get("/me") -async def me(session=Depends(require_user_session)): - return {"user_id": session.user.user_id} -``` - -### `request.session` with `FastAPICacheXSessionMiddleware` - -`request.session` is a view of the backend session's `data` dict: - -- Writing to `request.session` when no session was loaded creates a new **anonymous** session - (`SessionManager.create_anonymous_session()`, with IP/User-Agent bindings applied as - configured) and sends its token back through the request's transport. -- Modifying it on a loaded session saves the new contents to the backend via `update_session()`, - replacing `Session.data` with the dict's contents. If another request deleted, invalidated or - rotated the session, or saved it first, while this one ran, the save is dropped and logged. The - response sends no session token, except a renewed one that this request already stored for a - session that is still valid. -- Clearing it (`request.session.clear()`) on a loaded session logs out: the backend session is - deleted even if its data was already empty, and a cookie client also receives a `Set-Cookie` - that expires the cookie. Keys written after `clear()` in the same request go into a new - anonymous session under a new ID. `await logout(request)` does the same, but deletes the - backend session at once instead of when the response is sent, and `get_session` finds no - session for the rest of the request. -- Removing the last key with `del` or `pop()` is not a logout. A session with a user is saved - with empty data; an anonymous one holds nothing and is deleted, as with `clear()`. -- Logging in by writing to `request.session` keeps the session ID the request arrived with. - With Starlette's middleware the cookie *is* the session, so the login response replaces - whatever cookie was planted; here the cookie only names a server-side record, and a planted - one would be logged in along with the victim. Log in with `await login(request, user)`, which - gives the session a new ID and attaches the user (see - [Regenerate the Session ID After Login](#5-regenerate-the-session-id-after-login)). -- Any access to `request.session`, or a read through the session dependencies (`get_session`, - `get_optional_session` and those built on them, such as `AuthenticatedSession`), adds `Vary` - for every request header read to find the token: - the headers checked in `token_source_priority` order (`header_name`, and `Authorization` when - bearer tokens are enabled) up to the one that carried the token. `Cookie` is added only when - no header carried a token, because only then is the cookie read. -- A response that carries a session token (a new session, a sliding renewal, a regenerated ID) - or a `Set-Cookie` that expires the session cookie is never cacheable. The middleware sets - `Cache-Control: private, no-store`, replacing whatever the route set (a `@cache(public=True)` - route included), and adds the same `Vary` names as above even when the handler never touched - `request.session`. Otherwise a CDN or reverse proxy could store the token and hand it to the - next visitor. Responses without a token keep their headers. -- `@cache` does not read or write its backend for a request that arrived with a session (one - the middleware loaded, from any transport, with or without a user, or a non-empty - `request.session`), and answers it with `private`, as for `Authorization`. `public=True` - shares the route across sessions; `cache_authorized=True` with a `key_builder` that includes - the session's user caches per user, still answered with `private`. See - [Authenticated endpoints](HTTP_CACHING.md#authenticated-endpoints). - -The cookie is always `HttpOnly`; `Secure`, `SameSite`, `Domain`, `Path` and `Max-Age` follow the -`cookie_*` settings (`cookie_max_age=None`, or `0`, omits `Max-Age`). - -## Configuration - -### SessionConfig - -`SessionConfig` is a Pydantic model that rejects unknown fields (`extra="forbid"`), so a typo in -a field name raises a `ValidationError`. The values below are the defaults, except `secret_key`, -which is required. - -```python -SessionConfig( - # Session lifetime - session_ttl=3600, # session TTL (seconds) - absolute_timeout=None, # hard cap measured from created_at (seconds); None = no cap - sliding_expiration=True, # sliding expiration - sliding_threshold=0.5, # 0.0-1.0; renew once less than this fraction of the TTL remains - # Token sources (API-first architecture) - token_format="simple", # "simple" (default) or "jwt" - header_name="X-Session-Token", - token_source_priority=[ - "header", - "bearer", - ], # "cookie" only as the last entry (see below) - # JWT (used when token_format == "jwt") - jwt_algorithm="HS256", # "none" is rejected - jwt_issuer=None, # if set, iss is written and verified on parsing - jwt_audience=None, # if set, aud is written and verified on parsing - jwt_leeway=0, # tolerance in seconds for exp/iat checks (nbf is neither issued nor verified) - # Security - secret_key="...", # required: at least 32 characters - ip_binding=False, # IP binding - user_agent_binding=False, # User-Agent binding - trusted_proxies=[], # trusted reverse proxy addresses (see "Client IP and reverse proxies") - # Backend - backend_key_prefix="session:", - # Cookies (read only by FastAPICacheXSessionMiddleware) - cookie_name="__Host-session", # see "Cookie defaults" below - cookie_max_age=14 - * 24 - * 60 - * 60, # None = no Max-Age (cookie ends with the browser session) - cookie_path="/", - cookie_same_site="lax", # "lax" / "strict" / "none" ("none" needs cookie_https_only=True) - cookie_https_only=True, # the Secure flag: the cookie is only sent over HTTPS - cookie_domain=None, # None = no Domain attribute -) -``` - -#### Cookie defaults {#cookie-defaults} - -The session cookie is named `__Host-session` and carries the `Secure` flag by default. Browsers accept a `__Host-` cookie only when it is `Secure`, has `Path=/` and no `Domain`, and never from a subdomain, which removes the usual way to plant a session cookie (session fixation). - -A `Secure` cookie is not sent over plain HTTP. For local development without TLS, name the cookie without the prefix and drop the flag: `cookie_name="session", cookie_https_only=False`. - -`SessionConfig` raises a `ValidationError` for a cookie browsers would refuse: a `__Host-` name with `cookie_https_only=False`, a `cookie_path` other than `/` or a `cookie_domain`, and a `__Secure-` name without `cookie_https_only=True`. The default name has the `__Host-` prefix, so changing only one of those settings raises too; change `cookie_name` with it. Upgrading from 0.3.x changes the cookie name, which logs every cookie session out once; see [Migrating to 0.4.0](MIGRATING_0_4.md#session-cookie). - -Sessions expire after `session_ttl` seconds. With `sliding_expiration`, each request that finds -less than `session_ttl * sliding_threshold` seconds remaining extends the expiry to a full -`session_ttl` again and issues a renewed token, which the middleware sends back to the client -(response header or `Set-Cookie`, see the table above). Header/bearer clients should replace their -stored token when the response carries the `header_name` header. `absolute_timeout` ends the -session that many seconds after it was created, regardless of sliding renewals: the expiry, -the backend TTL and a JWT's `exp` never go past `created_at + absolute_timeout`, and once -the expiry reaches that cap no further renewed tokens are issued. - -#### `token_source_priority` and the session cookie - -`FastAPICacheXSessionMiddleware` first reads the header sources in `token_source_priority` order, -and only falls back to the cookie when none of them yields a token. The response side follows the -token's source (header in, header out; cookie in, `Set-Cookie` out). - -The cookie is read whether or not the list names it. `"cookie"` is accepted only as the last -entry, which is where it is read anyway, so listing it changes nothing; any other position raises a -`ValidationError`. (0.3.9 announced that 0.4.0 would make the list name every token source; that -change was dropped when sessions were deprecated. See -[Migrating to 0.4.0](MIGRATING_0_4.md#token-source-priority).) - -`use_bearer_token` is deprecated and removed in 0.5.0 with this package ([#377](https://github.com/allen0099/FastAPI-CacheX/issues/377)): -passing it emits a `DeprecationWarning`. Instead of `use_bearer_token=False`, leave `"bearer"` out -of the list (`token_source_priority=["header", "cookie"]`); `use_bearer_token=True` is the -default and can simply be dropped. - -**Header/bearer clients** should store the token in `localStorage` or `sessionStorage` and send -it as `Authorization: Bearer ` or `X-Session-Token: `. **Cookie clients** (browsers) -do not need to handle the token themselves, but beware of CSRF: the browser attaches cookies -automatically, so combine `cookie_same_site` with your own CSRF protection. - -### Using the JWT Token Format - -With `token_format="jwt"`, session tokens are issued as JWTs carrying these claims: - -- `sid`: session ID (custom claim, maps to the server-side session) -- `iat`: issued-at time (epoch seconds) -- `exp`: expiry time — the session's current `expires_at` (so it moves with sliding renewal), - falling back to `iat + session_ttl` -- `iss`/`aud`: written when configured, and verified on parsing - -Example configuration: - -```python -config = SessionConfig( - secret_key="your-secret-key-at-least-32-characters", - token_format="jwt", - jwt_algorithm="HS256", - jwt_issuer="your-issuer", - jwt_audience="your-audience", -) -``` - -`jwt_algorithm` must be one of `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, -`ES384`, `ES512`, `PS256`, `PS384`, `PS512` or `EdDSA`; anything else (including `none`) raises a -`ValidationError`. The built-in serializer signs and verifies with the same `secret_key`, so it -only supports `HS256`, `HS384` and `HS512`: with an asymmetric algorithm, `SessionManager` raises -`ValueError` unless you pass a custom `token_serializer` that holds the key pair. - -An HMAC key must be at least as long as the hash output (RFC 7518 §3.2): 32 bytes for `HS256`, -48 for `HS384` and 64 for `HS512`, counted after UTF-8 encoding. `secret_key` only has to be 32 -characters, so with `HS384` or `HS512` a shorter key makes `SessionManager` raise `ValueError` -when it builds the built-in serializer (a custom `token_serializer` holds its own key). Use a longer key, for example `secrets.token_urlsafe(64)`, or -`HS256`. - -Security notes: - -- The server keeps **stateful** sessions (the JWT is only a credential carrying the `sid`), so no - sensitive data needs to go into the token -- Parsing verifies the signature and the required claims (`sid`/`iat`/`exp`, plus `iss`/`aud` - when configured) -- Use HTTPS and a key rotation strategy in production (an advanced scheme with `kid` and - multiple keys is a possible future extension) - -**Advanced topics**: for the design of the JWT claims, why optional claims such as `jti`/`nbf` are -not implemented, and how to add custom claims, see the -**[JWT Claims implementation notes and extension guide](JWT_CLAIMS.md)**. - -## Security Best Practices - -### 1. Secret Key - -```python -import secrets - -# Generate a secure secret key -secret_key = secrets.token_urlsafe(32) - -config = SessionConfig(secret_key=secret_key) -``` - -`secret_key` is stored as a `SecretStr` and must be at least 32 characters long (at least 48 -bytes for `jwt_algorithm="HS384"` and 64 for `"HS512"`; see the JWT section above). Load it from the -environment or a secret store rather than hard-coding it; changing it invalidates every token -issued so far. - -### 2. HTTPS Only - -Always transport tokens over HTTPS in production. For cookie clients, keep the cookie `Secure`, as it is by default: - -```python -config = SessionConfig( - secret_key="...", - cookie_name="__Host-session", # the default; browsers refuse it without Secure, Path=/, no Domain - cookie_https_only=True, # the default; adds the Secure flag to the session cookie -) -``` - -**Client-side notes**: - -- Only send the token over HTTPS -- The session cookie set by `FastAPICacheXSessionMiddleware` is always `HttpOnly`, so page scripts - cannot read it; a token kept in `localStorage`/`sessionStorage` is readable by scripts, so guard - against XSS -- Avoid passing the token in URLs - -### 3. Client IP and Reverse Proxies - -The "client IP" used by `ip_binding` and for audit logging **trusts only the directly connected -peer address by default**; `X-Forwarded-For` and `X-Real-IP` are ignored, because anyone can send -those headers. - -When deploying behind a reverse proxy, put the proxy's address into `trusted_proxies`: - -```python -config = SessionConfig( - secret_key="...", - ip_binding=True, - trusted_proxies=["10.0.0.8"], # the hop that connects directly -) -``` - -Entries can be single addresses or CIDR ranges, for load balancers that connect from a subnet: - -```python -config = SessionConfig( - secret_key="...", - ip_binding=True, - trusted_proxies=["10.0.0.0/8", "2001:db8::/32"], -) -``` - -The client address is then the **rightmost `X-Forwarded-For` entry that is not listed in -`trusted_proxies`**: proxies append to the header, so the leftmost entry is whatever the caller -chose to send and cannot be trusted. When the header arrives on several lines, they are read as -one comma-separated chain. If every entry in the chain is a trusted proxy, the direct -peer address is used. `X-Real-IP` is written by the proxy itself and has no chain to walk, so it is -used only when `X-Forwarded-For` yields no usable value. - -> [!NOTE] -> An IPv4 peer reported in IPv4-mapped form (`::ffff:10.0.0.8`, as dual-stack sockets do) matches -> IPv4 entries. Entries that are not IP addresses (for example TestClient's `testclient`) match -> only an identical peer string, and an entry containing `/` that is not a valid CIDR range is -> rejected when the config is created. - -The middleware applies this logic when it checks a binding, but `create_session()` binds whatever -`ip_address` you pass it. Behind a trusted proxy, `request.client.host` is the proxy's address, -which never matches, so the binding check fails on the next request. Pass the address the -middleware derives instead, either through the `ClientIPDep` dependency or by calling -`get_client_ip()` with the same config: - -```python -from fastapi_cachex.session import get_client_ip -from fastapi_cachex.session.dependencies import ClientIPDep, SessionManagerDep - - -@app.post("/login") -async def login(manager: SessionManagerDep, client_ip: ClientIPDep): - session, token = await manager.create_session(user, ip_address=client_ip) - return {"token": token} - - -# Outside a route, with the SessionConfig you gave the middleware: -client_ip = get_client_ip(request, config) -``` - -### 4. IP Binding (Optional) - -Improves security but can hurt the user experience (for example when the client's IP changes): - -```python -config = SessionConfig( - secret_key="...", - ip_binding=True, # bind the session to the client IP -) -``` - -The binding is recorded when the session is created, from the `ip_address` passed to -`create_session()` (or `user_agent` for `user_agent_binding`). If the value is missing at creation -time, a warning is logged and the session is created unbound. A request whose address does not -match the bound one (or has no address) is treated as having no session. - -### 5. Regenerate the Session ID After Login - -Prevents session fixation. The token a client arrives with may have been planted by -someone else (from a sibling subdomain, say); a login that keeps it hands that person a -logged-in session. Under `FastAPICacheXSessionMiddleware`, `login()` gives the session a new ID -and attaches the user in one call: - -```python -from fastapi import Request - -from fastapi_cachex.session import SessionUser, login - - -# Credentials is the body model from Basic Usage -@app.post("/login") -async def log_in(credentials: Credentials, request: Request): - ... # verify credentials.password - await login(request, SessionUser(user_id=credentials.username)) - return {"ok": True} -``` - -What happens to the session the request arrived with depends on whose it is: - -- **Anonymous** (a visitor's cart, say): it keeps its data under a new ID and gets the user. -- **The same `user_id`** (a re-login): the same, and the `SessionUser` you pass replaces the - stored one, so changed roles or metadata take effect. -- **A different user's**: it is deleted, along with anything written to `request.session` - earlier in the request, and `login()` starts a new session. None of the previous user's data - (a cart, an `elevated` flag) reaches the new user. -- **None** (a new visitor, or a token that did not resolve): `login()` creates a session with - the user, bound to the client IP and User-Agent as configured. - -To carry over only some of the data, list the keys: `login(request, user, keep=["cart"])` -drops every other key, both from the loaded session and from what was written to -`request.session` earlier in the request; `keep=[]` drops them all. Keys written after the call -are kept. `keep` must be a collection of keys, so a string raises `TypeError`. - -In every case the old token no longer resolves. The middleware then saves the session, keys -written to `request.session` after the call included (and before it, unless the loaded session -was a different user's), and sends its token through the transport the request used: the response header for a header or `Authorization: Bearer` token, otherwise -an HttpOnly `Set-Cookie` with every `cookie_*` attribute. Like every response that carries a -token, it gets `Cache-Control: private, no-store`. A later request with that token passes -`require_user_session` and `AuthenticatedSession`. `login()` returns the session, which -`get_session` also returns for the rest of the request. - -A request that carried no token at all gets only the cookie, which page scripts cannot read. Do -not copy the token into a response header or the body of a browser login. An API client that -logs in without a token needs it in the body: return `manager.issue_token(session)` for the -session `login()` returned, or issue the token from a separate endpoint, as -[`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py) does. The complete browser version is -[`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py). - -To log out, call `await logout(request)`. It deletes the session from the backend at once, so -its token stops resolving even before the response is sent, and a cookie client gets its cookie -expired. It returns `True`, or `False` when no session was loaded or started in the request (a -token that did not resolve included). Keys written to -`request.session` after it go into a new anonymous session, and a `login()` after it starts a -new session. - -Within one request, `request.session.clear()` after `login()` is a logout: the new session is -deleted and no token is sent (a cookie client gets its cookie expired). `clear()` before -`login()` logs the loaded session out, and `login()` then starts a new session instead of -rotating it. Without `FastAPICacheXSessionMiddleware`, `login()` and `logout()` raise -`RuntimeError`, because nothing would send the token or expire the cookie; create the session -with `create_session(user=...)` and return its token, and end it with `delete_session()`. - -`request.session["user_id"] = "123"` is not a login. It is application data, which -`require_user_session` and `AuthenticatedSession` do not recognise, and it keeps the session ID -the request arrived with. - -To change the ID without logging in (after a privilege change, say), call -`await rotate_session_id(request)`: - -```python -from fastapi_cachex.session import rotate_session_id -from fastapi_cachex.session.dependencies import AuthenticatedSession - - -@app.post("/sudo") -async def sudo(request: Request, session: AuthenticatedSession): - ... # check the password again - await rotate_session_id(request) - request.session["elevated"] = True - return {"ok": True} -``` - -`rotate_session_id()` calls `SessionManager.regenerate_session_id()` on the request's -session, which deletes the backend record under the old ID and saves the session under a -new ID, keeping its data, user, `created_at` and expiry. The middleware sees the new ID -and sends a token for it through the transport the request used: `Set-Cookie` for a -cookie, the response header for a header token. After that the old token no longer -resolves to a session. For a new visitor there is no session to rotate, so it returns -`False`. If another request deleted, invalidated or rotated the session while this one ran, -nothing is saved or sent and it answers `401`; `login()` starts a new session for the user instead (see -[Session writes](MIGRATING_0_4.md#session-writes)). - -A handler that already holds the request's session object can call -`await manager.regenerate_session_id(session)` directly. Get it from `get_optional_session` and -skip the call when it is `None`; `SessionDep` answers `401` to a visitor who has no session yet. -Unlike `rotate_session_id()`, a direct call raises `SessionNotFoundError` or `SessionInvalidError` -when another request ended the session meanwhile, so catch `SessionError` and answer as for a -missing session. - -Outside a middleware, load the session with the same bindings the middleware would pass, and hand -the returned token to the client yourself: - -```python -session, _ = await manager.get_session( - current_token, ip_address=client_ip, user_agent=user_agent -) -session, new_token = await manager.regenerate_session_id(session) -``` - -## SessionManager at a glance - -`SessionManager(backend, config, token_serializer=None)` handles the whole -lifecycle: `create_session()` / `create_anonymous_session()` return -`(session, token)`; `get_session()` returns `(session, renewed_token)`, where -`renewed_token` is set only when sliding expiration renewed the token and should -be sent back to the client. `get_session()` raises a `SessionError` subclass on -failure: `SessionTokenError` (malformed token; for a JWT also a bad signature, -an expired `exp` or a wrong `iss`/`aud`), `SessionSecurityError` (bad `simple` -signature or binding mismatch), `SessionNotFoundError`, `SessionInvalidError` -(session not active) or `SessionExpiredError` (TTL or absolute timeout exceeded). -Since 0.3.8, `SessionError` derives from `CacheXError`, so `except CacheXError` -catches session errors too. - -`get_session()` writes to the backend only when sliding expiration renewed the session, so a -request that only reads its session costs a single backend read. The returned -session's `last_accessed` is the current time, but the stored value is updated -only when the session is next written (created, modified, renewed or -regenerated). Pass `touch=True` to save it on every lookup. Before 0.3.8 every -lookup saved the session. - -Every method with its signature is in the generated -[Session API reference](api/session.md). - -## Dependencies - -```python -from fastapi_cachex.session import ( - get_session, # authentication required (401 when there is no session) - get_optional_session, # optional authentication (None when there is no session) - require_session, # alias of get_session - require_user_session, # 401 also when the session has no user - get_session_manager, # the SessionManager registered by the middleware - login, # not a dependency: await it to log a user in under a new session ID - rotate_session_id, # not a dependency: await it for a new session ID -) - -# Type annotations -from fastapi_cachex.session.dependencies import ( - OptionalSession, # Session | None - RequiredSession, # Session - SessionDep, # Session - UserSessionDep, # same as AuthenticatedSession since 0.4.0 - AuthenticatedSession, # Session with a user (require_user_session) - SessionManagerDep, # SessionManager -) -``` - -`get_session_manager` returns the manager the middleware stored on `app.state` when it handled -its first request; it responds with `500` if no session middleware has run yet. Using it avoids -importing the manager into your route modules. - -```python -from fastapi_cachex.session import SessionUser -from fastapi_cachex.session.dependencies import SessionManagerDep - - -# Credentials is the body model from Basic Usage -@app.post("/login") -async def login(credentials: Credentials, manager: SessionManagerDep): - ... # verify credentials.password - user = SessionUser(user_id=credentials.username) - session, token = await manager.create_session(user=user) - return {"token": token} -``` - -`get_session` and `get_optional_session` also declare an `HTTPBearer` security scheme -(`SessionBearer`), so Swagger UI shows an **Authorize** button; the token itself is still read by -the middleware. diff --git a/docs/STATE.md b/docs/STATE.md deleted file mode 100644 index 2612f6c..0000000 --- a/docs/STATE.md +++ /dev/null @@ -1,177 +0,0 @@ -# State Management Extension - -> [!WARNING] -> **Deprecated.** `fastapi_cachex.state` is deprecated in 0.4.0 and removed in 0.5.0 ([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420)). Importing it emits a `FutureWarning`. [Migrating to 0.4.0](MIGRATING_0_4.md#session-state-deprecated) says where to move. - -`fastapi_cachex.state` provides **one-time state tokens** for OAuth / OIDC authorization -flows. Before starting the authorization, generate a random state and store it in the cache -backend. When the callback comes back, **consume** it. A consumed state cannot be used a -second time. - -A state protects the flow against CSRF (RFC 6749 §10.12) only when it is **bound to the -browser that started the flow**. Storage alone is not enough: an attacker can start a flow -in their own browser and send the victim to the callback with the attacker's state and -code, which logs the victim in to the attacker's account. Pass a `binding` (a random nonce -you also set as a cookie) when creating the state and the same value when consuming it, as -in the quick start below. - -States live on the same backend as the HTTP cache but under their own key prefix -(`oauth_state:` by default), so namespaced operations such as `CacheManager.clear_prefix()` -leave them alone. A backend-wide `clear()` (for example `BackendProxy.get().clear()`) -does remove them, because it clears everything under the backend's namespace. - -Everything in this guide can also be imported from the top-level `fastapi_cachex` package. - -The quick start below is the complete, runnable [`examples/oauth_state.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/oauth_state.py). - -## Quick start - -This is [`examples/oauth_state.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/oauth_state.py): - - -```python ---8<-- "examples/oauth_state.py" -``` - - -If the provider posts the callback (`response_mode=form_post`), a `SameSite=Lax` cookie is -not sent with that cross-site POST; use `samesite="none"` (which requires `secure=True`) -for the binding cookie. Starting a second login in another tab overwrites the cookie, so -the first tab's callback is then rejected; the user simply logs in again. - -## StateManager - -```python -from fastapi_cachex.state import StateManager - -states = StateManager( - backend=None, # None means use BackendProxy.get() - key_prefix="oauth_state:", # key prefix - default_ttl=600, # default: 10 minutes -) -``` - -With `backend=None` the backend is resolved **when the `StateManager` is constructed**, -not on each call. If `BackendProxy.set(...)` has not been called yet, the constructor -raises `BackendNotFoundError`. Configure the backend first. - -### `create_state(ttl=None, metadata=None, *, binding=None) -> str` - -Generates a state string with `secrets.token_urlsafe(32)` (256 bits of entropy), stores it -in the backend and returns it. `metadata` is an arbitrary JSON-serializable dict stored -alongside the state (for example, the path to redirect to after authorization). When `ttl` -is omitted, `default_ttl` is used. The same TTL is applied both as the backend TTL and as -the state's `expires_at`. - -`binding` ties the state to the client that starts the flow: a random nonce you also set -as a cookie, or any other secret only that client presents on the callback. Only its -SHA-256 is stored. An empty string raises `ValueError`, since a missing cookie read as `""` -would bind every such client to the same value. - -### `consume_state(state, *, binding=None) -> StateData` - -**One-time consumption.** The entry is retrieved and removed with the backend's atomic -`get_and_delete()`, so when several concurrent calls present the same state **only one** -gets it. A replayed callback cannot pass a second time. - -| Situation | Behavior | -|------|------| -| Missing, already consumed, or already evicted by the backend TTL | `InvalidStateError` | -| Created with a binding and consumed with a different one or none, or created without a binding and consumed with one | `InvalidStateError` (the entry has been deleted too) | -| Retrieved but past its `expires_at` | `StateExpiredError` (the entry has been deleted too, nothing is left behind) | -| Retrieved but the content is not valid `StateData` JSON | `StateDataError` (the entry has been deleted too) | -| Otherwise | Returns `StateData` | - -In the common case the backend TTL removes an expired state first, so an expired state -usually shows up as `InvalidStateError` instead of `StateExpiredError`; catch both. On -Redis and Memcached, a stored value that cannot be decoded into a cache entry at all is -treated as a miss by the backend, which also surfaces as `InvalidStateError`. - -### `validate_state(state) -> bool` - -Read-only check that does not consume the state: returns `True` when the state exists, -can be parsed and has not expired, otherwise `False`. It does not raise state exceptions. - -> [!WARNING] -> `validate_state()` does **not** consume the state, so on its own it does not prevent replay. -> The real protection is `consume_state()`. Use `validate_state()` only for non-security -> decisions such as "probe first, then decide what UI to show". - -### `get_state_metadata(state) -> dict | None` - -Also non-consuming. Returns the `metadata` stored at creation, or `None` when the state -is missing, expired or cannot be parsed. - -### `delete_state(state) -> bool` - -Deletes a state manually (for example, when the user cancels the authorization). Returns -whether the state existed. It uses the same atomic `get_and_delete()`, so it returns `True` -to at most one caller even when racing with `consume_state()`. - -## StateData - -```python -class StateData(BaseModel): - state: str # the state string itself - created_at: datetime # creation time (UTC) - expires_at: datetime # expiry time (UTC) - metadata: dict[str, Any] # metadata attached at creation - binding_hash: str | None # SHA-256 of the binding, None for an unbound state -``` - -`expires_at` is a logical expiry stored inside the data, independent of the backend TTL. -When the backend TTL runs out, the entry disappears. `expires_at` makes sure an entry the -backend still holds but that is logically expired is rejected as well. - -## Dependency injection and proxy - -```python -from fastapi_cachex.state import StateManagerDep, StateManagerProxy, get_state_manager - - -# 1. Use the type annotation directly (most common) -@app.get("/login") -async def login(states: StateManagerDep): ... - - -# 2. Custom instance (e.g. a different prefix or TTL): register it at startup -# and dependency injection will return it -StateManagerProxy.set(StateManager(key_prefix="csrf:", default_ttl=300)) -``` - -When no instance has been registered, `get_state_manager()` (the dependency behind -`StateManagerDep`) lazily creates a default `StateManager` on first use, backed by -`BackendProxy`'s backend, and registers it. It does not fall back to a `MemoryBackend`: -if no backend has been set, the request fails with `BackendNotFoundError`. - -## Exceptions - -``` -CacheXError -└── StateError - ├── InvalidStateError # missing, already consumed, or binding mismatch - ├── StateExpiredError # expired - └── StateDataError # malformed content -``` - -## Notes - -- **The backend must be shared across processes.** For multi-worker deployments use Redis or - Memcached. With `MemoryBackend` a state only exists in the process that created it, so an - authorization callback that lands on a different worker fails. -- **The one-time guarantee comes from the backend's atomic operation.** `get_and_delete()` is - `GETDEL` on Redis (requires Redis server 6.2 or newer), `gets` followed by - `cas(..., exptime=-1)` on Memcached (retrying if another writer replaced the value in between), - and a `pop` under the lock on the memory backend. If writers keep replacing the value for - 16 attempts in a row, Memcached raises `CacheXError` rather than report the state as missing. - A custom backend that implements only the abstract methods falls back to - `BaseCacheBackend`'s non-atomic version, so a concurrent replay could succeed on both - sides. Override `get_and_delete()` in that case. -- Do not store sensitive data in a state. `metadata` is stored as plain-text JSON in the - cache backend. -- **Logs never contain the state itself.** Log lines from `fastapi_cachex.state.manager` - identify a state by `state_ref`, the first 12 hex characters of its SHA-256, which you can - compute from a known state to match it. An unknown, expired or differently bound state - rejected by `consume_state()` is logged at INFO, since it is routine client input - (`validate_state()` and `get_state_metadata()` log a missing or expired state at DEBUG); - malformed stored data is logged once at WARNING. diff --git a/docs/api/session.md b/docs/api/session.md deleted file mode 100644 index 7a7dd9e..0000000 --- a/docs/api/session.md +++ /dev/null @@ -1,24 +0,0 @@ -# Session - -> [!WARNING] -> **Deprecated.** `fastapi_cachex.session` is deprecated in 0.4.0 and removed in 0.5.0 ([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420)). See [Migrating to 0.4.0](../MIGRATING_0_4.md#session-state-deprecated). - -See the [session management guide](../SESSION.md) for how the pieces fit together. - -::: fastapi_cachex.session.middleware.FastAPICacheXSessionMiddleware - -::: fastapi_cachex.session.config.SessionConfig - -::: fastapi_cachex.session.manager.SessionManager - -::: fastapi_cachex.session.proxy.SessionManagerProxy - options: - inherited_members: true - -::: fastapi_cachex.session.models.Session - -::: fastapi_cachex.session.models.SessionUser - -::: fastapi_cachex.session.dependencies - -::: fastapi_cachex.session.exceptions diff --git a/docs/api/state.md b/docs/api/state.md deleted file mode 100644 index 82aa805..0000000 --- a/docs/api/state.md +++ /dev/null @@ -1,18 +0,0 @@ -# State - -> [!WARNING] -> **Deprecated.** `fastapi_cachex.state` is deprecated in 0.4.0 and removed in 0.5.0 ([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420)). See [Migrating to 0.4.0](../MIGRATING_0_4.md#session-state-deprecated). - -See the [state management guide](../STATE.md) for the OAuth flow these support. - -::: fastapi_cachex.state.manager.StateManager - -::: fastapi_cachex.state.models.StateData - -::: fastapi_cachex.state.proxy.StateManagerProxy - options: - inherited_members: true - -::: fastapi_cachex.state.dependencies - -::: fastapi_cachex.state.exceptions diff --git a/examples/README.md b/examples/README.md index 44a2de4..8f5bae2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,22 +2,12 @@ Each file here is a complete FastAPI app that shows one feature of FastAPI-CacheX. They use only the public API and the in-memory backend (except -`redis_backend.py` and `session_redis.py`), so they run without any server. - -The `session_*.py` and `oauth_state.py` examples use `fastapi_cachex.session` -and `fastapi_cachex.state`, which are deprecated in 0.4.0 and removed in 0.5.0 -([where to move](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/#session-state-deprecated)). +`redis_backend.py`), so they run without any server. | Example | What it shows | Needs | |---------|---------------|-------| | [`http_cache.py`](http_cache.py) | `@cache` with a TTL, ETag and `304 Not Modified`, `no_cache` and `private` routes, `clear_path()` after an update, the monitoring routes behind an admin check | — | | [`app_cache.py`](app_cache.py) | `CacheManager.get_or_set()` for an expensive call, `add()` as an idempotency check, the `AppCache` dependency | — | -| [`session_login.py`](session_login.py) | `FastAPICacheXSessionMiddleware` with cookies: an anonymous session, login with `login()`, `AuthenticatedSession`, logout with `request.session.clear()` | — | -| [`session_api.py`](session_api.py) | Sessions for an API client that keeps its own token: returned by `/login`, sent back as `Authorization: Bearer` or `X-Session-Token`; `AuthenticatedSession`, `OptionalSession`, logout with `delete_session()` | — | -| [`session_redis.py`](session_redis.py) | Sessions on Redis with IP binding and sliding expiration, flash messages, `update_session()` and logout on every device with `delete_user_sessions()` | `redis` extra, a Redis server | -| [`session_jwt.py`](session_jwt.py) | Sessions with `token_format="jwt"` for API clients (`Authorization: Bearer`), revoked on logout | `jwt` extra | -| [`session_jwt_claims.py`](session_jwt_claims.py) | A custom JWT serializer passed as `token_serializer`, adding `tenant_id` and `api_version` claims and rejecting other tenants' tokens | `jwt` extra | -| [`oauth_state.py`](oauth_state.py) | One-time OAuth `state` values with `StateManager`, bound to the browser that started the flow | — | | [`cache_lock.py`](cache_lock.py) | `CacheLock`, waiting (`async with`) and non-blocking (`acquire(blocking=False)`) | — | | [`rate_limit.py`](rate_limit.py) | A fixed-window rate limiter on `backend.increment()`, answering `429` with `Retry-After` | — | | [`redis_backend.py`](redis_backend.py) | `AsyncRedisCacheBackend` configured from environment variables in the lifespan and closed on shutdown | `redis` extra, a Redis server | @@ -32,30 +22,27 @@ uv run --with "fastapi-cli[standard]" fastapi dev examples/http_cache.py ``` Then open . The dev group already includes the -`jwt` and `redis` extras. `fastapi dev` comes from `fastapi-cli`, which is not +`redis` extra. `fastapi dev` comes from `fastapi-cli`, which is not a dependency of this project, hence `--with`. Uvicorn works too: ```bash uv run --with uvicorn uvicorn examples.http_cache:app --reload ``` -`redis_backend.py` and `session_redis.py` read `REDIS_HOST`, `REDIS_PORT`, -`REDIS_DB` and `REDIS_PASSWORD`; point them at a server you can write to. +`redis_backend.py` reads `REDIS_HOST`, `REDIS_PORT`, `REDIS_DB` and +`REDIS_PASSWORD`; point it at a server you can write to. Some files mark parts with `# --8<-- [start:name]` / `# --8<-- [end:name]` comments. The documentation site includes those parts (or the whole file) in its guides, so the code shown there is the code tested here. In your own project, install the extras an example needs, for example -`uv add "fastapi-cachex[jwt]"`, and copy the file. +`uv add "fastapi-cachex[redis]"`, and copy the file. ## Secrets -The session examples read their signing key from `SESSION_SECRET_KEY`. When it -is unset they warn and sign with a random key made up for that run, so sessions -end when the process restarts and are not shared between workers. The -monitoring routes in `http_cache.py` stay closed until `CACHE_ADMIN_TOKEN` is -set. Always set real, random values outside local development: +The monitoring routes in `http_cache.py` stay closed until `CACHE_ADMIN_TOKEN` +is set. Always set a real, random value outside local development: ```bash python -c "import secrets; print(secrets.token_urlsafe(48))" diff --git a/examples/oauth_state.py b/examples/oauth_state.py deleted file mode 100644 index 0746e1c..0000000 --- a/examples/oauth_state.py +++ /dev/null @@ -1,79 +0,0 @@ -"""One-time OAuth ``state`` values with ``StateManager``. - -``/login`` stores a random state bound to a nonce cookie and redirects to the -provider; ``/callback`` consumes it. A state works once, and only in the -browser that started the flow, which is what protects the callback against -CSRF (RFC 6749, section 10.12). - -The provider here is a placeholder: this app never talks to it. -Run it from a checkout (see ``examples/README.md``):: - - uv run --with "fastapi-cli[standard]" fastapi dev examples/oauth_state.py -""" - -import secrets -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from urllib.parse import urlencode - -from fastapi import FastAPI -from fastapi import HTTPException -from fastapi import Request -from fastapi.responses import RedirectResponse - -from fastapi_cachex import BackendProxy -from fastapi_cachex import StateManagerDep -from fastapi_cachex.backends import MemoryBackend -from fastapi_cachex.state import StateError - -backend = MemoryBackend() -BackendProxy.set(backend) - - -@asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - """Stop the memory backend's cleanup task on shutdown.""" - yield - await backend.aclose() - - -app = FastAPI(lifespan=lifespan) - -AUTHORIZE_URL = "https://provider.example.com/authorize" -BINDING_COOKIE = "oauth_binding" - - -@app.get("/login") -async def login(states: StateManagerDep) -> RedirectResponse: - """Create a state bound to this browser and send it to the provider.""" - nonce = secrets.token_urlsafe(32) - state = await states.create_state( - ttl=600, binding=nonce, metadata={"next": "/dashboard"} - ) - query = urlencode({"state": state, "client_id": "example-client"}) - response = RedirectResponse(f"{AUTHORIZE_URL}?{query}") - # Lax, not Strict: the callback is a cross-site navigation from the provider. - # Browsers accept Secure cookies on http://localhost. - response.set_cookie( - BINDING_COOKIE, nonce, max_age=600, httponly=True, secure=True, samesite="lax" - ) - return response - - -@app.get("/callback") -async def callback( - request: Request, state: str, code: str, states: StateManagerDep -) -> RedirectResponse: - """Accept the state once, and only from the browser that started the flow.""" - try: - data = await states.consume_state( - state, binding=request.cookies.get(BINDING_COOKIE) - ) - except StateError as exc: # unknown, expired, reused or another browser's - raise HTTPException(status_code=400, detail="Invalid state") from exc - - # Exchange `code` for tokens at the provider and create a session here. - _ = code - response = RedirectResponse(str(data.metadata.get("next", "/"))) - response.delete_cookie(BINDING_COOKIE) - return response diff --git a/examples/session_api.py b/examples/session_api.py deleted file mode 100644 index 12c1cb2..0000000 --- a/examples/session_api.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Server-side sessions for an API client that keeps its own token. - -``/login`` returns the session token in the response body; the client stores -it and sends it back as ``Authorization: Bearer `` or in the -``X-Session-Token`` header. ``/profile`` needs a logged-in user, ``/public`` -works with or without a session, and ``/logout`` deletes the session so the -token stops working. For browsers, prefer the HttpOnly cookie of -``session_login.py``. - -Run it from a checkout (see ``examples/README.md``):: - - uv run --with "fastapi-cli[standard]" fastapi dev examples/session_api.py -""" - -import os -import secrets -import warnings -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from fastapi import HTTPException -from pydantic import BaseModel - -from fastapi_cachex import BackendProxy -from fastapi_cachex import SessionManagerProxy -from fastapi_cachex.backends import MemoryBackend -from fastapi_cachex.session import FastAPICacheXSessionMiddleware -from fastapi_cachex.session import SessionConfig -from fastapi_cachex.session import SessionManager -from fastapi_cachex.session import SessionUser -from fastapi_cachex.session.dependencies import AuthenticatedSession -from fastapi_cachex.session.dependencies import OptionalSession -from fastapi_cachex.session.dependencies import SessionDep - -backend = MemoryBackend() -BackendProxy.set(backend) - - -def session_secret_key() -> str: - """Return SESSION_SECRET_KEY, or a random key for this run with a warning.""" - key = os.environ.get("SESSION_SECRET_KEY") - if key: - return key - warnings.warn( - "SESSION_SECRET_KEY is not set, so this run signs sessions with a " - "random key: they end when the process restarts and are not shared " - "between workers. Set SESSION_SECRET_KEY to a random value of at least " - "32 characters, e.g. the output of " - '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.', - UserWarning, - stacklevel=2, - ) - return secrets.token_urlsafe(48) - - -config = SessionConfig( - # At least 32 characters, from the environment; see session_secret_key(). - secret_key=session_secret_key(), - session_ttl=3600, # 1 hour - # The default cookie (__Host-session with the Secure flag) needs HTTPS. - # This example runs over plain HTTP, so it names the cookie without the - # __Host- prefix and drops Secure; in production, leave both out. - cookie_name="session", - cookie_https_only=False, -) -session_manager = SessionManager(backend, config) -# Register it on the proxy too: from 0.4.0, get_session_manager (and -# SessionManagerDep, ClientIPDep) find the manager there only. -SessionManagerProxy.set(session_manager) - - -@asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - """Stop the memory backend's cleanup task on shutdown.""" - yield - await backend.aclose() - - -app = FastAPI(lifespan=lifespan) -app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=session_manager, config=config -) - -# Demo accounts only. Store password hashes (argon2, bcrypt) in a real app. -DEMO_USERS = {"alice": "alice-demo-password"} - - -class Credentials(BaseModel): - """Login form. - - Credentials arrive in the JSON request body, never in the query string, - which ends up in browser history and access logs. - """ - - username: str - password: str - - -@app.post("/login") -async def login(credentials: Credentials) -> dict[str, str]: - """Check the password and return the token of a new session.""" - expected = DEMO_USERS.get(credentials.username) - if expected is None or not secrets.compare_digest(credentials.password, expected): - raise HTTPException(status_code=401, detail="Wrong username or password") - user = SessionUser( - user_id=credentials.username, username=credentials.username, roles=["user"] - ) - _, token = await session_manager.create_session(user=user) - # The client stores the token and sends it on later requests in the - # Authorization or X-Session-Token header. - return {"token": token} - - -@app.get("/profile") -async def get_profile(session: AuthenticatedSession) -> dict[str, object]: - """Requires a session with a user; ``401`` otherwise, anonymous ones included.""" - assert session.user is not None # guaranteed by AuthenticatedSession # noqa: S101 - return { - "user_id": session.user.user_id, - "username": session.user.username, - "roles": session.user.roles, - } - - -@app.get("/public") -async def public_endpoint(session: OptionalSession) -> dict[str, str]: - """Answers with or without a session.""" - if session is not None and session.user is not None: - return {"message": f"Hello, {session.user.username}!"} - return {"message": "Hello, guest!"} - - -@app.post("/logout") -async def logout(session: SessionDep) -> dict[str, bool]: - """Delete the session: its token stops working at once.""" - await session_manager.delete_session(session.session_id) - return {"logged_out": True} diff --git a/examples/session_jwt.py b/examples/session_jwt.py deleted file mode 100644 index 81334a8..0000000 --- a/examples/session_jwt.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Sessions carried as JWTs, for API clients. - -With ``token_format="jwt"`` the session token is a signed JWT. The session -itself still lives in the backend, so logging out revokes the token at once. -The client sends it as ``Authorization: Bearer ``. - -Needs the ``jwt`` extra: ``uv add "fastapi-cachex[jwt]"``. -Run it from a checkout (see ``examples/README.md``):: - - uv run --with "fastapi-cli[standard]" fastapi dev examples/session_jwt.py -""" - -import os -import secrets -import warnings -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from fastapi import HTTPException -from fastapi import Request -from pydantic import BaseModel - -from fastapi_cachex import BackendProxy -from fastapi_cachex import FastAPICacheXSessionMiddleware -from fastapi_cachex import SessionConfig -from fastapi_cachex import SessionManager -from fastapi_cachex import SessionManagerProxy -from fastapi_cachex import SessionUser -from fastapi_cachex.backends import MemoryBackend -from fastapi_cachex.session.dependencies import AuthenticatedSession -from fastapi_cachex.session.dependencies import ClientIPDep - -backend = MemoryBackend() -BackendProxy.set(backend) - - -def session_secret_key() -> str: - """Return SESSION_SECRET_KEY, or a random key for this run with a warning.""" - key = os.environ.get("SESSION_SECRET_KEY") - if key: - return key - warnings.warn( - "SESSION_SECRET_KEY is not set, so this run signs sessions with a " - "random key: they end when the process restarts and are not shared " - "between workers. Set SESSION_SECRET_KEY to a random value of at least " - "32 characters, e.g. the output of " - '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.', - UserWarning, - stacklevel=2, - ) - return secrets.token_urlsafe(48) - - -config = SessionConfig( - # HS256 wants a key of at least 32 bytes (HS384: 48, HS512: 64), or - # SessionManager warns; see session_secret_key(). - secret_key=session_secret_key(), - token_format="jwt", - jwt_algorithm="HS256", - # Optional: issued as `iss`/`aud` and checked on every request. - jwt_issuer="https://api.example.com", - jwt_audience="example-clients", - # The middleware also accepts a cookie. The default one (__Host-session - # with the Secure flag) needs HTTPS; this example runs over plain HTTP, so - # it names the cookie without the __Host- prefix and drops Secure. - cookie_name="session", - cookie_https_only=False, -) -session_manager = SessionManager(backend, config) -# From 0.4.0 ClientIPDep finds the manager only through the proxy; 0.3.9 reads -# the middleware's and warns (FutureWarning) when the proxy holds another one. -SessionManagerProxy.set(session_manager) - - -@asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - """Stop the memory backend's cleanup task on shutdown.""" - yield - await backend.aclose() - - -app = FastAPI(lifespan=lifespan) -app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=session_manager, config=config -) - -# Demo accounts only. Store password hashes (argon2, bcrypt) in a real app. -DEMO_USERS = {"alice": "alice-demo-password"} - - -class Credentials(BaseModel): - """Login form.""" - - username: str - password: str - - -@app.post("/token") -async def issue_token( - credentials: Credentials, client_ip: ClientIPDep -) -> dict[str, str]: - """Check the password and return a bearer token for a new session.""" - expected = DEMO_USERS.get(credentials.username) - if expected is None or not secrets.compare_digest(credentials.password, expected): - raise HTTPException(status_code=401, detail="Wrong username or password") - _, token = await session_manager.create_session( - user=SessionUser(user_id=credentials.username), - ip_address=client_ip, - ) - return {"access_token": token, "token_type": "bearer"} - - -@app.get("/me") -async def me(session: AuthenticatedSession) -> dict[str, str]: - """Requires ``Authorization: Bearer `` for a session with a user.""" - assert session.user is not None # guaranteed by AuthenticatedSession # noqa: S101 - return {"user": session.user.user_id} - - -@app.post("/logout") -async def logout(request: Request) -> dict[str, bool]: - """Delete the session: the JWT stops working even before it expires.""" - request.session.clear() - return {"logged_out": True} diff --git a/examples/session_jwt_claims.py b/examples/session_jwt_claims.py deleted file mode 100644 index dff1301..0000000 --- a/examples/session_jwt_claims.py +++ /dev/null @@ -1,213 +0,0 @@ -"""JWT sessions with custom claims, checked on every request. - -``CustomClaimsJWTSerializer`` issues and verifies the same claims as the -built-in JWT serializer and leaves two hooks for extra ones. -``MultiTenantJWTSerializer`` uses them to put ``tenant_id`` and ``api_version`` -into every token and to reject tokens issued for another tenant. The serializer -is handed to ``SessionManager`` through ``token_serializer``. - -Needs the ``jwt`` extra: ``uv add "fastapi-cachex[jwt]"``. Any backend works; -see ``redis_backend.py`` for Redis. Run it from a checkout (see -``examples/README.md``):: - - uv run --with "fastapi-cli[standard]" fastapi dev examples/session_jwt_claims.py -""" - -# --8<-- [start:serializer] -import os -import secrets -import warnings -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from datetime import datetime -from datetime import timezone -from typing import Any - -import jwt -from fastapi import FastAPI -from fastapi import HTTPException -from pydantic import BaseModel - -from fastapi_cachex.backends import MemoryBackend -from fastapi_cachex.session import FastAPICacheXSessionMiddleware -from fastapi_cachex.session import SessionConfig -from fastapi_cachex.session import SessionManager -from fastapi_cachex.session import SessionUser -from fastapi_cachex.session.dependencies import AuthenticatedSession -from fastapi_cachex.session.models import SessionToken - - -class CustomClaimsJWTSerializer: - """JWT serializer with the built-in claims plus extra ones from subclasses.""" - - # Claims that from_string() requires besides sid, iat and exp. - required_claims: tuple[str, ...] = () - - def __init__(self, config: SessionConfig) -> None: - """Copy the JWT settings from the public ``SessionConfig`` fields.""" - self.secret = config.secret_key.get_secret_value() - self.algorithm = config.jwt_algorithm # must be HS256, HS384 or HS512 - self.issuer = config.jwt_issuer - self.audience = config.jwt_audience - self.leeway = config.jwt_leeway - self.session_ttl = config.session_ttl - - def extra_claims(self, token: SessionToken) -> dict[str, Any]: # noqa: ARG002 - """Return the claims to add to a new token.""" - return {} - - def check_claims(self, payload: dict[str, Any]) -> None: - """Raise ValueError if the extra claims of a verified token are wrong.""" - - def to_string(self, token: SessionToken) -> str: - """Encode a SessionToken as a signed JWT.""" - iat = int(token.issued_at.timestamp()) - if token.expires_at is not None: - exp = int(token.expires_at.timestamp()) - else: - exp = iat + self.session_ttl - - payload: dict[str, Any] = {"sid": token.session_id, "iat": iat, "exp": exp} - if self.issuer: - payload["iss"] = self.issuer - if self.audience: - payload["aud"] = self.audience - payload.update(self.extra_claims(token)) - return jwt.encode(payload, self.secret, algorithm=self.algorithm) - - def from_string(self, token_str: str) -> SessionToken: - """Verify a JWT and turn it back into a SessionToken.""" - try: - payload = jwt.decode( - token_str, - self.secret, - algorithms=[self.algorithm], - issuer=self.issuer, - audience=self.audience, - leeway=self.leeway, - options={"require": ["sid", "iat", "exp", *self.required_claims]}, - ) - except jwt.InvalidTokenError as e: - msg = "Invalid JWT token" - raise ValueError(msg) from e - - self.check_claims(payload) - issued_at = datetime.fromtimestamp(int(payload["iat"]), tz=timezone.utc) - return SessionToken( - session_id=str(payload["sid"]), signature="", issued_at=issued_at - ) - - -# --8<-- [end:serializer] - - -# --8<-- [start:multi-tenant] -class MultiTenantJWTSerializer(CustomClaimsJWTSerializer): - """Adds tenant_id and api_version, and rejects tokens for other tenants.""" - - required_claims = ("tenant_id", "api_version") - - def __init__( - self, config: SessionConfig, tenant_id: str, api_version: str = "v1" - ) -> None: - """Issue and accept tokens for one tenant and API version.""" - super().__init__(config) - self.tenant_id = tenant_id - self.api_version = api_version - - def extra_claims(self, token: SessionToken) -> dict[str, Any]: # noqa: ARG002 - """Put the tenant and API version into every new token.""" - return {"tenant_id": self.tenant_id, "api_version": self.api_version} - - def check_claims(self, payload: dict[str, Any]) -> None: - """Reject a token for another tenant or API version.""" - if payload["tenant_id"] != self.tenant_id: - msg = f"Invalid tenant_id: expected {self.tenant_id}, got {payload['tenant_id']}" - raise ValueError(msg) - if payload["api_version"] != self.api_version: - msg = f"Unsupported API version: {payload['api_version']}" - raise ValueError(msg) - - -# --8<-- [end:multi-tenant] - - -# --8<-- [start:setup] -def session_secret_key() -> str: - """Return SESSION_SECRET_KEY, or a random key for this run with a warning.""" - key = os.environ.get("SESSION_SECRET_KEY") - if key: - return key - warnings.warn( - "SESSION_SECRET_KEY is not set, so this run signs sessions with a " - "random key: they end when the process restarts and are not shared " - "between workers. Set SESSION_SECRET_KEY to a random value of at least " - "32 characters, e.g. the output of " - '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.', - UserWarning, - stacklevel=2, - ) - return secrets.token_urlsafe(48) - - -backend = MemoryBackend() -config = SessionConfig( - # HS256 wants a key of at least 32 bytes (HS384: 48, HS512: 64); see - # session_secret_key(). - secret_key=session_secret_key(), - token_format="jwt", - jwt_algorithm="HS256", - jwt_issuer="acme-corp", - jwt_audience="acme-api", - # The middleware also reads a cookie: __Host-session with Secure by default. -) - -# token_serializer replaces the serializer chosen from token_format. -serializer = MultiTenantJWTSerializer(config, tenant_id="acme-corp", api_version="v2") -session_manager = SessionManager(backend, config, token_serializer=serializer) - - -@asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - """Stop the memory backend's cleanup task on shutdown.""" - yield - await backend.aclose() - - -app = FastAPI(lifespan=lifespan) -app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=session_manager, config=config -) -# --8<-- [end:setup] - -# Demo accounts only. Store password hashes (argon2, bcrypt) in a real app. -DEMO_USERS = {"alice": "alice-demo-password"} - - -class Credentials(BaseModel): - """Login form.""" - - username: str - password: str - - -@app.post("/auth/login") -async def login(credentials: Credentials) -> dict[str, str]: - """Return a JWT that carries the tenant_id and api_version claims.""" - expected = DEMO_USERS.get(credentials.username) - if expected is None or not secrets.compare_digest(credentials.password, expected): - raise HTTPException(status_code=401, detail="Wrong username or password") - user = SessionUser(user_id=credentials.username, username=credentials.username) - _, token = await session_manager.create_session(user=user) - return {"token": token, "token_type": "bearer", "tenant_id": serializer.tenant_id} - - -@app.get("/api/profile") -async def get_profile(session: AuthenticatedSession) -> dict[str, str | None]: - """Protected endpoint; the tenant was checked while decoding the JWT. - - A token for another tenant fails to decode, so the middleware loads no - session and ``AuthenticatedSession`` answers ``401``. - """ - assert session.user is not None # guaranteed by AuthenticatedSession # noqa: S101 - return {"user_id": session.user.user_id, "username": session.user.username} diff --git a/examples/session_login.py b/examples/session_login.py deleted file mode 100644 index f4bee03..0000000 --- a/examples/session_login.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Server-side sessions with ``FastAPICacheXSessionMiddleware``. - -A visitor gets an anonymous session as soon as something is written to -``request.session`` (here, a shopping cart). Logging in with ``login()`` rotates -the session ID against session fixation and attaches the user, keeping the cart; -logging out deletes the session. The token travels in an HttpOnly cookie, as with -Starlette's ``SessionMiddleware``; header and ``Authorization: Bearer`` tokens -work too. A login here hands out only the cookie, so page scripts never see the -token; an API client gets its token from an endpoint that returns it in the -body, as ``session_jwt.py`` does. - -Run it from a checkout (see ``examples/README.md``):: - - uv run --with "fastapi-cli[standard]" fastapi dev examples/session_login.py -""" - -import os -import secrets -import warnings -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from fastapi import HTTPException -from fastapi import Request -from pydantic import BaseModel - -from fastapi_cachex import BackendProxy -from fastapi_cachex import FastAPICacheXSessionMiddleware -from fastapi_cachex import SessionConfig -from fastapi_cachex import SessionManager -from fastapi_cachex import SessionUser -from fastapi_cachex.backends import MemoryBackend -from fastapi_cachex.session import login -from fastapi_cachex.session import logout as end_session -from fastapi_cachex.session.dependencies import AuthenticatedSession - -backend = MemoryBackend() -BackendProxy.set(backend) - - -def session_secret_key() -> str: - """Return SESSION_SECRET_KEY, or a random key for this run with a warning.""" - key = os.environ.get("SESSION_SECRET_KEY") - if key: - return key - warnings.warn( - "SESSION_SECRET_KEY is not set, so this run signs sessions with a " - "random key: they end when the process restarts and are not shared " - "between workers. Set SESSION_SECRET_KEY to a random value of at least " - "32 characters, e.g. the output of " - '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.', - UserWarning, - stacklevel=2, - ) - return secrets.token_urlsafe(48) - - -config = SessionConfig( - # At least 32 characters, from the environment; see session_secret_key(). - secret_key=session_secret_key(), - session_ttl=3600, - # The default cookie (__Host-session with the Secure flag) needs HTTPS. - # This example runs over plain HTTP, so it names the cookie without the - # __Host- prefix and drops Secure; in production, leave both out. - cookie_name="session", - cookie_https_only=False, -) -session_manager = SessionManager(backend, config) - - -@asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - """Stop the memory backend's cleanup task on shutdown.""" - yield - await backend.aclose() - - -app = FastAPI(lifespan=lifespan) -app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=session_manager, config=config -) - -# Demo accounts only. Store password hashes (argon2, bcrypt) in a real app. -DEMO_USERS = {"alice": "alice-demo-password"} - - -class Credentials(BaseModel): - """Login form.""" - - username: str - password: str - - -@app.post("/cart/{item}") -async def add_to_cart(item: str, request: Request) -> dict[str, list[str]]: - """Writing to ``request.session`` starts an anonymous session if needed.""" - cart = [*request.session.get("cart", []), item] - request.session["cart"] = cart - return {"cart": cart} - - -@app.post("/login") -async def log_in(credentials: Credentials, request: Request) -> dict[str, str]: - """Check the password, then attach the user under a new session ID.""" - expected = DEMO_USERS.get(credentials.username) - if expected is None or not secrets.compare_digest(credentials.password, expected): - raise HTTPException(status_code=401, detail="Wrong username or password") - user = SessionUser(user_id=credentials.username, username=credentials.username) - # A visitor with a session (their cart) keeps it under a new ID, so a token - # planted before login is worthless; a new visitor gets a new session. The - # middleware saves it and sends the token as an HttpOnly cookie on a - # response no cache may store. - await login(request, user) - return {"user": user.user_id} - - -@app.get("/me") -async def me(session: AuthenticatedSession) -> dict[str, object]: - """Only for logged-in users; an anonymous session gets ``401`` too.""" - assert session.user is not None # guaranteed by AuthenticatedSession # noqa: S101 - return {"user": session.user.user_id, "cart": session.data.get("cart", [])} - - -@app.post("/logout") -async def logout(request: Request) -> dict[str, bool]: - """Delete the session now; the middleware expires the cookie.""" - return {"logged_out": await end_session(request)} diff --git a/examples/session_redis.py b/examples/session_redis.py deleted file mode 100644 index cb408a6..0000000 --- a/examples/session_redis.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Server-side sessions on Redis, with bindings, flash messages and logout everywhere. - -The API client gets its token from ``/api/auth/login`` and sends it back as -``Authorization: Bearer `` or in the ``X-Session-Token`` header. The -session is bound to the client's IP address, slides forward while in use, and -carries flash messages between requests. ``/api/auth/logout-all`` ends every -session of the user, on every device. - -Needs the ``redis`` extra (``uv add "fastapi-cachex[redis]"``) and a Redis -server, configured from ``REDIS_HOST``, ``REDIS_PORT``, ``REDIS_DB`` and -``REDIS_PASSWORD`` as in ``redis_backend.py``. Run it from a checkout (see -``examples/README.md``):: - - REDIS_PORT=6379 uv run --with "fastapi-cli[standard]" fastapi dev examples/session_redis.py -""" - -import os -import secrets -import warnings -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from datetime import datetime -from datetime import timezone - -from fastapi import FastAPI -from fastapi import HTTPException -from fastapi import Request -from pydantic import BaseModel - -from fastapi_cachex import SessionManagerProxy -from fastapi_cachex.backends import AsyncRedisCacheBackend -from fastapi_cachex.session import FastAPICacheXSessionMiddleware -from fastapi_cachex.session import SessionConfig -from fastapi_cachex.session import SessionManager -from fastapi_cachex.session import SessionUser -from fastapi_cachex.session.dependencies import AuthenticatedSession -from fastapi_cachex.session.dependencies import ClientIPDep -from fastapi_cachex.session.dependencies import SessionDep - -# The client connects lazily, so creating it at import needs no server yet. -backend = AsyncRedisCacheBackend( - host=os.environ.get("REDIS_HOST", "127.0.0.1"), - port=int(os.environ.get("REDIS_PORT", "6379")), - db=int(os.environ.get("REDIS_DB", "0")), - password=os.environ.get("REDIS_PASSWORD") or None, - # Namespaces this app's keys on a shared server. - key_prefix="fastapi_cachex_example:", -) - - -def session_secret_key() -> str: - """Return SESSION_SECRET_KEY, or a random key for this run with a warning.""" - key = os.environ.get("SESSION_SECRET_KEY") - if key: - return key - warnings.warn( - "SESSION_SECRET_KEY is not set, so this run signs sessions with a " - "random key: they end when the process restarts and are not shared " - "between workers. Set SESSION_SECRET_KEY to a random value of at least " - "32 characters, e.g. the output of " - '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.', - UserWarning, - stacklevel=2, - ) - return secrets.token_urlsafe(48) - - -config = SessionConfig( - # At least 32 characters, from the environment; see session_secret_key(). - secret_key=session_secret_key(), - session_ttl=3600, - sliding_expiration=True, - sliding_threshold=0.5, - ip_binding=True, # reject the token from another IP address - user_agent_binding=False, # optional: reject it from another User-Agent - # The cookie defaults to __Host-session with the Secure flag: HTTPS only. -) -session_manager = SessionManager(backend, config) -# From 0.4.0 ClientIPDep resolves the manager only through the proxy. -SessionManagerProxy.set(session_manager) - - -@asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - """Close the Redis connection pool on shutdown.""" - try: - yield - finally: - await backend.aclose() - - -app = FastAPI(lifespan=lifespan) -app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=session_manager, config=config -) - -# Demo accounts only. Store password hashes (argon2, bcrypt) in a real app, -# and read the user's ID and roles from your database. -DEMO_USERS = {"alice": "alice-demo-password", "admin": "admin-demo-password"} -DEMO_ROLES = {"alice": ["user"], "admin": ["admin", "user"]} - - -class Credentials(BaseModel): - """Login form.""" - - username: str - password: str - - -@app.post("/api/auth/login") -async def login( - credentials: Credentials, request: Request, client_ip: ClientIPDep -) -> dict[str, object]: - """Check the password and return the token of a new, bound session.""" - expected = DEMO_USERS.get(credentials.username) - if expected is None or not secrets.compare_digest(credentials.password, expected): - raise HTTPException(status_code=401, detail="Wrong username or password") - - user = SessionUser( - user_id=f"user_{credentials.username}", - username=credentials.username, - email=f"{credentials.username}@example.com", - roles=DEMO_ROLES[credentials.username], - ) - # `client_ip` is the address the middleware checks on later requests, - # including behind trusted proxies. - session, token = await session_manager.create_session( - user=user, - ip_address=client_ip, - user_agent=request.headers.get("user-agent"), - ) - - session.add_flash_message("Login successful!", "success") - # Changes to a Session object are saved only by update_session(). - await session_manager.update_session(session) - - return { - "token": token, # the client stores it and sends it on later requests - "user": {"username": user.username, "roles": user.roles}, - } - - -@app.get("/api/user/profile") -async def get_user_profile(session: AuthenticatedSession) -> dict[str, object]: - """Return the user's profile (requires a logged-in user).""" - assert session.user is not None # guaranteed by AuthenticatedSession # noqa: S101 - return { - "user_id": session.user.user_id, - "username": session.user.username, - "email": session.user.email, - "roles": session.user.roles, - "session_created": session.created_at.isoformat(), - "last_accessed": session.last_accessed.isoformat(), - } - - -@app.post("/api/user/update") -async def update_user_profile( - email: str, session: AuthenticatedSession -) -> dict[str, str]: - """Change the email stored in the session.""" - assert session.user is not None # guaranteed by AuthenticatedSession # noqa: S101 - session.user.email = email - session.data["last_updated"] = datetime.now(timezone.utc).isoformat() - await session_manager.update_session(session) - return {"message": "Profile updated"} - - -@app.get("/api/messages") -async def get_flash_messages(session: SessionDep) -> dict[str, object]: - """Return the flash messages once.""" - messages = session.get_flash_messages(clear=True) - # Clearing only changes the in-memory object; save it so the messages - # are not shown again on the next request. - await session_manager.update_session(session) - return {"messages": messages} - - -@app.post("/api/auth/logout") -async def logout(session: SessionDep) -> dict[str, str]: - """End this session; the client should discard its token.""" - await session_manager.delete_session(session.session_id) - return {"message": "Logged out"} - - -@app.post("/api/auth/logout-all") -async def logout_all_devices(session: AuthenticatedSession) -> dict[str, str]: - """End every session of this user, on every device.""" - assert session.user is not None # guaranteed by AuthenticatedSession # noqa: S101 - count = await session_manager.delete_user_sessions(session.user.user_id) - return {"message": f"Logged out from {count} devices"} diff --git a/fastapi_cachex/__init__.py b/fastapi_cachex/__init__.py index 295839e..bbd249a 100644 --- a/fastapi_cachex/__init__.py +++ b/fastapi_cachex/__init__.py @@ -1,10 +1,8 @@ """FastAPI-CacheX: A powerful and flexible caching extension for FastAPI.""" import logging -from importlib import import_module from importlib.metadata import PackageNotFoundError from importlib.metadata import version -from typing import TYPE_CHECKING from .cache import build_cache_key as build_cache_key from .cache import cache as cache @@ -29,37 +27,6 @@ from .routes import add_routes as add_routes from .types import CacheKeyBuilder as CacheKeyBuilder -if TYPE_CHECKING: - # Type checkers see the real types; at runtime `__getattr__` loads them. - from .session import ( - FastAPICacheXSessionMiddleware as FastAPICacheXSessionMiddleware, - ) - from .session import Session as Session - from .session import SessionConfig as SessionConfig - from .session import SessionManager as SessionManager - from .session import SessionManagerProxy as SessionManagerProxy - from .session import SessionUser as SessionUser - from .session import get_optional_session as get_optional_session - from .session import get_session as get_session - from .session import get_session_manager as get_session_manager - from .session import require_session as require_session - from .session import require_user_session as require_user_session - from .session.exceptions import SessionError as SessionError - from .session.exceptions import SessionExpiredError as SessionExpiredError - from .session.exceptions import SessionInvalidError as SessionInvalidError - from .session.exceptions import SessionNotFoundError as SessionNotFoundError - from .session.exceptions import SessionSecurityError as SessionSecurityError - from .session.exceptions import SessionTokenError as SessionTokenError - from .state import InvalidStateError as InvalidStateError - from .state import StateData as StateData - from .state import StateDataError as StateDataError - from .state import StateError as StateError - from .state import StateExpiredError as StateExpiredError - from .state import StateManager as StateManager - from .state import StateManagerDep as StateManagerDep - from .state import StateManagerProxy as StateManagerProxy - from .state import get_state_manager as get_state_manager - def _read_version() -> str: """Return the installed distribution's version. @@ -81,51 +48,6 @@ def _read_version() -> str: logging.NullHandler() ) # Attach a NullHandler to avoid "No handler found" warnings in user applications. -# Session and OAuth state names, deprecated in 0.4.0 and removed in 0.5.0 -# (#420). They load on first use, so `import fastapi_cachex` does not import -# either package; importing one emits its FutureWarning. They are left out of -# `__all__`, so `from fastapi_cachex import *` does not load them either. -_DEPRECATED_NAMES = { - "FastAPICacheXSessionMiddleware": "fastapi_cachex.session", - "InvalidStateError": "fastapi_cachex.state", - "Session": "fastapi_cachex.session", - "SessionConfig": "fastapi_cachex.session", - "SessionError": "fastapi_cachex.session.exceptions", - "SessionExpiredError": "fastapi_cachex.session.exceptions", - "SessionInvalidError": "fastapi_cachex.session.exceptions", - "SessionManager": "fastapi_cachex.session", - "SessionManagerProxy": "fastapi_cachex.session", - "SessionNotFoundError": "fastapi_cachex.session.exceptions", - "SessionSecurityError": "fastapi_cachex.session.exceptions", - "SessionTokenError": "fastapi_cachex.session.exceptions", - "SessionUser": "fastapi_cachex.session", - "StateData": "fastapi_cachex.state", - "StateDataError": "fastapi_cachex.state", - "StateError": "fastapi_cachex.state", - "StateExpiredError": "fastapi_cachex.state", - "StateManager": "fastapi_cachex.state", - "StateManagerDep": "fastapi_cachex.state", - "StateManagerProxy": "fastapi_cachex.state", - "get_optional_session": "fastapi_cachex.session", - "get_session": "fastapi_cachex.session", - "get_session_manager": "fastapi_cachex.session", - "get_state_manager": "fastapi_cachex.state", - "require_session": "fastapi_cachex.session", - "require_user_session": "fastapi_cachex.session", -} - - -def __getattr__(name: str) -> object: - """Resolve a deprecated session or state name on first use.""" - module_name = _DEPRECATED_NAMES.get(name) - if module_name is None: - msg = f"module {__name__!r} has no attribute {name!r}" - raise AttributeError(msg) - value = getattr(import_module(module_name), name) - globals()[name] = value - return value - - __all__ = [ "AppCache", "BackendNotFoundError", diff --git a/fastapi_cachex/_deprecation.py b/fastapi_cachex/_deprecation.py deleted file mode 100644 index 1245ead..0000000 --- a/fastapi_cachex/_deprecation.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Deprecation of the session and OAuth state subsystems (#420). - -Both packages warn once, when they are first imported, and are removed in -0.5.0 (#421). -""" - -import inspect -import warnings -from types import FrameType - -_MIGRATION_URL = ( - "https://fastapi-cachex.readthedocs.io/en/latest/" - "MIGRATING_0_4/#session-state-deprecated" -) - -SESSION_DEPRECATION = ( - "fastapi_cachex.session is deprecated and will be removed in " - "fastapi-cachex 0.5.0. Use Starlette's SessionMiddleware for signed-cookie " - f"sessions, or a dedicated session library for server-side sessions; see {_MIGRATION_URL}" -) - -STATE_DEPRECATION = ( - "fastapi_cachex.state is deprecated and will be removed in " - "fastapi-cachex 0.5.0. Use the state handling of your OAuth client library " - f"(Authlib, for example); see {_MIGRATION_URL}" -) - - -def _is_import_machinery(frame: FrameType) -> bool: - """Whether ``warnings.warn()`` skips ``frame`` when it counts stacklevel.""" - filename = frame.f_code.co_filename - return "importlib" in filename and "_bootstrap" in filename - - -def _importer_stacklevel() -> int: - """Return the ``stacklevel`` of the first frame outside this package. - - Frames of fastapi_cachex and of the import machinery are skipped, so the - warning names the application's ``import`` line, or the line that read a - deprecated name from the ``fastapi_cachex`` package. - """ - frame = inspect.currentframe() - if frame is None or frame.f_back is None: # no frame support - return 2 - # Level 1 is warn_deprecated(); start at its caller. - level, frame = 2, frame.f_back.f_back - while frame is not None: - module = frame.f_globals.get("__name__", "") - if not module.startswith(("fastapi_cachex.", "importlib.")) and module not in { - "fastapi_cachex", - "importlib", - }: - break - # warnings.warn() does not count the frozen import machinery's frames - # towards stacklevel, so neither may this. - if not _is_import_machinery(frame): - level += 1 - frame = frame.f_back - return level - - -def warn_deprecated(message: str) -> None: - """Emit the ``FutureWarning`` for a deprecated subsystem. - - ``FutureWarning`` rather than ``DeprecationWarning``: it is shown by - default, and the removal affects the application, not only its tests. - """ - warnings.warn(message, FutureWarning, stacklevel=_importer_stacklevel()) diff --git a/fastapi_cachex/_vary.py b/fastapi_cachex/_vary.py index 4e834f5..9449fe5 100644 --- a/fastapi_cachex/_vary.py +++ b/fastapi_cachex/_vary.py @@ -48,8 +48,9 @@ def _validate_vary(vary: Sequence[str] | None) -> list[str]: "authorization", "proxy-authorization", "cookie", - # The session token header (`SessionConfig.header_name`'s default). - # Inlined, so @cache does not import the deprecated session package. + # The token header of the session middleware removed in 0.5.0. Apps + # that still send it under their own session scheme keep the digest, + # so upgrading never puts their tokens into keys. "x-session-token", } ) @@ -62,10 +63,10 @@ def _vary_components(request: Request, names: Sequence[str]) -> list[str]: The name is lower-cased and the value trimmed; repeated header lines are joined with ``,`` as RFC 9110 §5.3 allows, and a missing header gives an empty value, the same as an empty one. For a credential header - (``Authorization``, ``Proxy-Authorization``, ``Cookie`` and the session - subsystem's ``X-Session-Token``) a non-empty value is replaced by - ``sha256:`` and the full hex SHA-256 of the joined value; an empty or - missing one stays ``name=``, so anonymous requests share one entry. + (``Authorization``, ``Proxy-Authorization``, ``Cookie`` and + ``X-Session-Token``) a non-empty value is replaced by ``sha256:`` and the + full hex SHA-256 of the joined value; an empty or missing one stays + ``name=``, so anonymous requests share one entry. """ components = [] for name in names: diff --git a/fastapi_cachex/backends/base.py b/fastapi_cachex/backends/base.py index 85d3762..1b23f28 100644 --- a/fastapi_cachex/backends/base.py +++ b/fastapi_cachex/backends/base.py @@ -10,7 +10,6 @@ from typing import Any from typing import overload -from fastapi_cachex._warnings import caller_stacklevel from fastapi_cachex.exceptions import CacheXError from fastapi_cachex.types import CACHE_KEY_SEPARATOR from fastapi_cachex.types import HTTP_KEY_FORMAT_TAG @@ -203,42 +202,20 @@ async def delete_many(self, keys: Iterable[str]) -> int: The base implementation deletes one key at a time and counts the deletes that found an entry. The built-in backends override it with - batched deletes. + batched deletes. Like every fallback here, it reads ``delete``'s + result with ``bool()``, so a third-party ``delete`` that still + returns ``None`` (as before 0.4.0) counts as not removed. """ count = 0 for key in keys: - if await self._delete_reporting(key): + if await self.delete(key): count += 1 return count - async def _delete_reporting(self, key: str) -> bool: - """Call ``delete`` for the fallbacks, accepting a 0.3.x ``None`` result. - - ``delete`` returned ``None`` before 0.4.0, and a third-party backend - written then may still do so. ``None`` counts as removed, as every - fallback assumed in 0.3.x, and warns: 0.5.0 will treat it as ``False``. - ``FutureWarning`` rather than ``DeprecationWarning``: the warning is - raised inside the package, where a ``DeprecationWarning`` is hidden by - default, and the change affects the application at runtime. - """ - result: object = await self.delete(key) - if result is None: - warnings.warn( - f"{type(self).__name__}.delete() returned None. Since " - "fastapi-cachex 0.4.0 it must return whether the key was " - "removed; None is counted as removed until 0.5.0, which treats " - "it as False. See https://fastapi-cachex.readthedocs.io/en/" - "stable/MIGRATING_0_4/#backend-delete", - FutureWarning, - stacklevel=caller_stacklevel(), - ) - return True - return bool(result) - async def get_and_delete(self, key: str) -> CacheEntry | None: """Atomically retrieve and remove a cached entry. - Use this for one-shot values (OAuth states, grants, invalidation) where + Use this for one-shot values (tokens, grants, invalidation) where exactly one of several concurrent callers may win: every other caller sees ``None``. @@ -250,7 +227,7 @@ async def get_and_delete(self, key: str) -> CacheEntry | None: The entry that was stored under ``key``, or ``None`` if there was none """ value = await self.get(key) - if value is None or not await self._delete_reporting(key): + if value is None or not await self.delete(key): # Absent, or another caller removed it between the get and the # delete: that caller got the entry. return None @@ -306,7 +283,7 @@ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool: """ if await self.get(key) != expected: return False - return await self._delete_reporting(key) + return bool(await self.delete(key)) async def expire_if_equals( self, key: str, expected: CacheEntry, ttl: int | timedelta @@ -346,9 +323,9 @@ async def set_if_equals( """Store ``value`` only while ``key`` still holds ``expected``. A compare-and-set: a caller that read ``expected`` earlier overwrites - it only if nothing changed, deleted or expired the key since. Sessions - save through it, so a request that loaded a session cannot bring it - back after another request deleted or invalidated it. + it only if nothing changed, deleted or expired the key since, so a + request that loaded a value cannot bring it back after another request + deleted or replaced it. The base implementation is a best-effort, NON-atomic get-compare-set fallback for third-party backends; the built-in backends override it diff --git a/fastapi_cachex/backends/memory.py b/fastapi_cachex/backends/memory.py index f235618..23dad21 100644 --- a/fastapi_cachex/backends/memory.py +++ b/fastapi_cachex/backends/memory.py @@ -208,9 +208,8 @@ async def set( """Store a response in the cache. Starting the sweeper here as well as in ``get`` matters for a - write-mostly user — `StateManager.create_state` only writes, say — who - would otherwise accumulate expired entries forever, since nothing else - ever starts it. + write-mostly user, who would otherwise accumulate expired entries + forever, since nothing else ever starts it. Args: key: Cache key diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 85f3502..8d7b21e 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -152,25 +152,15 @@ async def _read_entry( return None, False -# Where `FastAPICacheXSessionMiddleware` puts the session it loaded; -# `get_session` reads it. -_SESSION_STATE_KEY = "__fastapi_cachex_session" - - def _request_credential(request: Request) -> str | None: """What identifies the caller of ``request``, or ``None`` if nothing does. - ``Authorization``, a session the session middleware loaded (from the - token header, a bearer token or the session cookie, anonymous or not), or - a non-empty ``request.session`` from any session middleware (Starlette's - cookie sessions included). Only a token that resolved to a session - counts, so an invalid or expired one does not keep a request away from - the cache. + ``Authorization``, or a non-empty ``request.session`` from any session + middleware (Starlette's ``SessionMiddleware``, for example). An empty + session does not count, and neither does a ``Cookie`` header alone. """ if "authorization" in request.headers: return "Authorization header" - if getattr(request.state, _SESSION_STATE_KEY, None) is not None: - return "Session" if request.scope.get("session"): return "Session data" return None @@ -198,7 +188,6 @@ def _no_store_ignored_warning(ignored: list[str]) -> str: # How the one-time bypass warning names each `_request_credential` result. _CREDENTIAL_DESCRIPTIONS = { "Authorization header": "an Authorization header", - "Session": "a session token (header, bearer token or cookie)", "Session data": "non-empty session data (request.session)", } @@ -498,16 +487,14 @@ def cache( leaves the response unstored. ``False`` lets the error propagate, so the request fails. cache_authorized: Read and write the backend for requests that carry - an ``Authorization`` header or arrive with a session: one the - session middleware loaded (from its token header, a bearer token - or the session cookie, with or without a user) or a non-empty - ``request.session``. By default such a request bypasses the + an ``Authorization`` header or a non-empty ``request.session`` + (from any session middleware, such as Starlette's + ``SessionMiddleware``). By default such a request bypasses the backend as ``private=True`` does (RFC 9111 §3.5), unless ``public`` is set, and its response is sent with ``private``. With this option the response to such a request still carries ``private``: its entry is per caller only in this backend, while a shared cache downstream keys on the URL alone. - A token that does not resolve to a session does not count. A route without a positive ``ttl`` skips the backend anyway, but its response to such a request is still sent with ``private``; ``private=True`` routes send it already. @@ -536,9 +523,10 @@ def cache( appears in the key; missing or empty, they stay ``name=``. Listing ``Cookie`` emits a ``UserWarning`` when the decorator is applied, since every visitor then gets their own entry. - A request with ``Authorization`` or a session still bypasses the - backend unless ``public`` or ``cache_authorized`` is set, and a response - that sets a cookie is still not stored. + A request with ``Authorization`` or a non-empty + ``request.session`` still bypasses the backend unless ``public`` + or ``cache_authorized`` is set, and a response that sets a cookie + is still not stored. sort_query: Order the query parameters by name before building the key, so ``?a=1&b=2`` and ``?b=2&a=1`` share one entry. The sort is stable: repeated values of one name keep the order the client @@ -811,11 +799,11 @@ async def respond( # RFC 9111 §3.5: a shared cache must not reuse a response to a # request with `Authorization` unless the response allows it. The # default key carries no identity, so treat such requests as - # private unless the route is `public` or opted in. A request that - # arrived with a session is the same case, whichever transport - # carried its token (#319). Routes without a positive ttl skip the - # backend anyway, but their response still needs `private` for a - # downstream cache (#362); only `private=True` already sends it. + # private unless the route is `public` or opted in. A request with + # a non-empty `request.session` is the same case (#319). Routes + # without a positive ttl skip the backend anyway, but their + # response still needs `private` for a downstream cache (#362); + # only `private=True` already sends it. # `cache_authorized` lifts the bypass but not `private`: its entries # are per caller only in this backend, while a shared cache # downstream keys on the URL alone (#372). diff --git a/fastapi_cachex/cache_key.py b/fastapi_cachex/cache_key.py index e1499e3..fb29dda 100644 --- a/fastapi_cachex/cache_key.py +++ b/fastapi_cachex/cache_key.py @@ -194,7 +194,7 @@ def parse(cls, key: str) -> "CacheKey | None": ``key`` is the logical key, without a backend's ``key_prefix``. Only a key that starts with ``FORMAT_TAG`` and has at least a method, host, - path and query is an HTTP key. ``CacheManager`` and ``StateManager`` + path and query is an HTTP key. ``CacheManager`` and ``CacheLock`` keys, keys written by 0.3.x (``method|||host|||path|||query``) and keys from a key builder that does not use ``build_cache_key`` are not. """ diff --git a/fastapi_cachex/exceptions.py b/fastapi_cachex/exceptions.py index adbcc5d..1018b20 100644 --- a/fastapi_cachex/exceptions.py +++ b/fastapi_cachex/exceptions.py @@ -12,9 +12,9 @@ class BackendNotFoundError(CacheXError): class ProxyNotSetError(BackendNotFoundError): """Exception raised when a manager proxy has no instance set. - Raised by ``CacheManagerProxy``, ``SessionManagerProxy`` and - ``StateManagerProxy``. It subclasses ``BackendNotFoundError``, which these - proxies raised before 0.3.8, so existing handlers keep catching it. + Raised by ``CacheManagerProxy``. It subclasses ``BackendNotFoundError``, + which the manager proxies raised before 0.3.8, so existing handlers keep + catching it. """ diff --git a/fastapi_cachex/headers.py b/fastapi_cachex/headers.py index 82814c1..2b19a2c 100644 --- a/fastapi_cachex/headers.py +++ b/fastapi_cachex/headers.py @@ -1,4 +1,4 @@ -"""Response header helpers shared by ``@cache`` and the session middleware.""" +"""Response header helpers for ``@cache``.""" from starlette.datastructures import MutableHeaders diff --git a/fastapi_cachex/manager.py b/fastapi_cachex/manager.py index 1b1d84e..ec89a33 100644 --- a/fastapi_cachex/manager.py +++ b/fastapi_cachex/manager.py @@ -91,8 +91,7 @@ def _validate_get_or_set_args( class CacheManager: """Provides convenient get/set/delete access to the configured cache backend. - Unlike the ``@cache`` decorator (which caches HTTP response bodies) or - ``StateManager``/``SessionManager`` (which manage OAuth state and sessions), + Unlike the ``@cache`` decorator (which caches HTTP response bodies), ``CacheManager`` is a thin, JSON-serializing wrapper for caching arbitrary application values under a dedicated key namespace. """ diff --git a/fastapi_cachex/session/__init__.py b/fastapi_cachex/session/__init__.py deleted file mode 100644 index e7fbc5f..0000000 --- a/fastapi_cachex/session/__init__.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Session management extension for FastAPI-CacheX. - -Deprecated in 0.4.0 and removed in 0.5.0 (#420): importing this package -emits a ``FutureWarning``. -""" - -from fastapi_cachex._deprecation import SESSION_DEPRECATION as _SESSION_DEPRECATION -from fastapi_cachex._deprecation import warn_deprecated as _warn_deprecated - -from .config import SessionConfig -from .dependencies import get_optional_session -from .dependencies import get_session -from .dependencies import get_session_client_ip -from .dependencies import get_session_manager -from .dependencies import login -from .dependencies import logout -from .dependencies import require_session -from .dependencies import require_user_session -from .dependencies import rotate_session_id -from .manager import SessionManager -from .middleware import FastAPICacheXSessionMiddleware -from .middleware import get_client_ip -from .models import Session -from .models import SessionUser -from .proxy import SessionManagerProxy - -_warn_deprecated(_SESSION_DEPRECATION) - -__all__ = [ - "FastAPICacheXSessionMiddleware", - "Session", - "SessionConfig", - "SessionManager", - "SessionManagerProxy", - "SessionUser", - "get_client_ip", - "get_optional_session", - "get_session", - "get_session_client_ip", - "get_session_manager", - "login", - "logout", - "require_session", - "require_user_session", - "rotate_session_id", -] diff --git a/fastapi_cachex/session/config.py b/fastapi_cachex/session/config.py deleted file mode 100644 index a0399bb..0000000 --- a/fastapi_cachex/session/config.py +++ /dev/null @@ -1,349 +0,0 @@ -"""Session configuration settings.""" - -import ipaddress -import warnings -from functools import lru_cache -from typing import Literal - -from pydantic import BaseModel -from pydantic import ConfigDict -from pydantic import Field -from pydantic import SecretStr -from pydantic import field_validator -from pydantic import model_validator - -SameSitePolicy = Literal["lax", "strict", "none"] - -IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network -IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address - -# The default ``SessionConfig.header_name``; ``@cache(vary=[...])`` also hashes -# this header's value like ``Authorization`` and ``Cookie``. -DEFAULT_SESSION_HEADER_NAME = "X-Session-Token" - - -@lru_cache(maxsize=256) -def _parse_network(entry: str) -> IPNetwork | None: - """Parse a trusted-proxy entry as an IP network, or None if it is not one.""" - try: - return ipaddress.ip_network(entry, strict=False) - except ValueError: - return None - - -@lru_cache(maxsize=1024) -def _parse_address(value: str) -> IPAddress | None: - """Parse a peer or forwarded address as an IP address, or None if it is not one.""" - try: - address = ipaddress.ip_address(value) - except ValueError: - return None - # A dual-stack socket reports IPv4 peers as ::ffff:a.b.c.d. - if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped: - return address.ipv4_mapped - return address - - -# Signing algorithms accepted for JWT session tokens. `none` is deliberately -# absent: an unsigned token would make every session forgeable. -JWT_ALGORITHMS = frozenset( - { - "HS256", - "HS384", - "HS512", - "RS256", - "RS384", - "RS512", - "ES256", - "ES384", - "ES512", - "PS256", - "PS384", - "PS512", - "EdDSA", - } -) - -# The algorithms the built-in `JWTTokenSerializer` can use: it signs and -# verifies with the single `secret_key` string. The asymmetric ones above need -# a private key to sign and a public key to verify, so they only work with a -# custom `token_serializer`. -JWT_HMAC_ALGORITHMS = frozenset({"HS256", "HS384", "HS512"}) - - -class SessionConfig(BaseModel): - """Session configuration settings.""" - - model_config = ConfigDict(extra="forbid") - - # Session lifetime - session_ttl: int = Field( - default=3600, - description="Session time-to-live in seconds (default: 1 hour)", - ) - absolute_timeout: int | None = Field( - default=None, - description="Absolute session timeout in seconds (None = no absolute timeout)", - ) - sliding_expiration: bool = Field( - default=True, - description="Whether to refresh session expiry on each access", - ) - sliding_threshold: float = Field( - default=0.5, - ge=0.0, - le=1.0, - description="Renew once less than this fraction of session_ttl remains (0.5 = renew in the second half of the TTL)", - ) - - # Token settings - token_format: Literal["simple", "jwt"] = Field( - default="simple", - description="Token serialization format: 'simple' (default) or 'jwt'", - ) - header_name: str = Field( - default=DEFAULT_SESSION_HEADER_NAME, - description="Custom header name for session token", - ) - use_bearer_token: bool = Field( - default=True, - description="Whether to accept Authorization Bearer tokens (deprecated: " - 'leave "bearer" out of token_source_priority instead)', - ) - token_source_priority: list[Literal["header", "bearer", "cookie"]] = Field( - default=["header", "bearer"], - description="Priority order for token sources. The session cookie is " - "read after the header sources, whether or not the list names it; " - '"cookie" may only be the last entry', - ) - - # JWT settings (used when token_format == 'jwt') - jwt_algorithm: str = Field( - default="HS256", - description="JWT signing algorithm (default: HS256)", - ) - jwt_issuer: str | None = Field( - default=None, - description="Expected JWT issuer (iss). If set, will be verified.", - ) - jwt_audience: str | None = Field( - default=None, - description="Expected JWT audience (aud). If set, will be verified.", - ) - jwt_leeway: int = Field( - default=0, - ge=0, - description="Leeway in seconds for exp/iat validation (nbf is not " - "issued or verified; see docs/JWT_CLAIMS.md)", - ) - - # Security settings - secret_key: SecretStr = Field( - ..., - min_length=32, - description="Secret key for signing session tokens (min 32 characters)", - ) - ip_binding: bool = Field( - default=False, - description="Whether to bind session to client IP address", - ) - trusted_proxies: list[str] = Field( - default_factory=list, - description="Peer addresses whose X-Forwarded-For / X-Real-IP headers " - "may be believed. Empty (the default) ignores those headers and uses " - "the direct peer address, since anyone can send them. When the peer is " - "trusted, the client address is the rightmost X-Forwarded-For entry " - "that is not itself listed here: proxies append, so the leftmost entry " - "is whatever the caller chose to send. Entries may be IP addresses, " - "CIDR ranges (e.g. 10.0.0.0/8) or other strings, which match exactly.", - ) - user_agent_binding: bool = Field( - default=False, - description="Whether to bind session to User-Agent", - ) - - # Backend settings - backend_key_prefix: str = Field( - default="session:", - description="Prefix for session keys in backend storage", - ) - - # Cookie settings (FastAPICacheXSessionMiddleware only) - cookie_name: str = Field( - default="__Host-session", - description="Name of the cookie used to store the session token " - "(FastAPICacheXSessionMiddleware only)", - ) - cookie_max_age: int | None = Field( - default=14 * 24 * 60 * 60, - description="Max-Age (seconds) for the session cookie; None disables " - "Max-Age/Expires (session cookie deleted when browser closes)", - ) - cookie_path: str = Field( - default="/", - description="Path attribute for the session cookie", - ) - cookie_same_site: SameSitePolicy = Field( - default="lax", - description="SameSite attribute for the session cookie", - ) - cookie_https_only: bool = Field( - default=True, - description="Whether to set the Secure flag on the session cookie " - "(cookie only sent over HTTPS)", - ) - cookie_domain: str | None = Field( - default=None, - description="Domain attribute for the session cookie; None omits the " - "Domain attribute", - ) - - @field_validator("trusted_proxies") - @classmethod - def _check_trusted_proxies(cls, value: list[str]) -> list[str]: - """Reject entries written as a CIDR range that do not parse as one.""" - for entry in value: - if "/" in entry and _parse_network(entry) is None: - msg = f"trusted_proxies entry is not a valid CIDR range: {entry!r}" - raise ValueError(msg) - return value - - def is_trusted_proxy(self, address: str) -> bool: - """Report whether `address` matches an entry of `trusted_proxies`. - - An IP address matches an entry that is the same address or a CIDR range - containing it (IPv4-mapped IPv6 addresses count as their IPv4 form). - Anything else, such as TestClient's ``testclient`` peer, matches only - an identical entry. - - Args: - address: Peer or forwarded address to check - - Returns: - True if the address is a trusted proxy - """ - if address in self.trusted_proxies: - return True - parsed = _parse_address(address) - if parsed is None: - return False - for entry in self.trusted_proxies: - network = _parse_network(entry) - if network is not None and parsed in network: - return True - return False - - @model_validator(mode="after") - def _warn_insecure_same_site_none(self) -> "SessionConfig": - """Warn about a SameSite=None cookie without the Secure flag. - - Browsers drop such a cookie, so the session would silently never stick. - Rejecting the combination would break existing configurations. A - ``__Host-`` / ``__Secure-`` name without Secure is rejected by - ``_check_cookie_prefix`` instead, so it is not warned about twice. - """ - if ( - self.cookie_same_site == "none" - and not self.cookie_https_only - and not self.cookie_name.startswith(("__Host-", "__Secure-")) - ): - warnings.warn( - 'cookie_same_site="none" requires cookie_https_only=True: browsers ' - "reject a SameSite=None cookie without the Secure flag, so the " - "session cookie would never be stored.", - UserWarning, - stacklevel=3, - ) - return self - - @model_validator(mode="after") - def _check_cookie_prefix(self) -> "SessionConfig": - """Reject a ``__Host-`` / ``__Secure-`` cookie browsers would refuse. - - Browsers store a ``__Secure-`` cookie only with the Secure flag, and a - ``__Host-`` cookie only with Secure, ``Path=/`` and no ``Domain``, so - the session would silently never stick (#256). - """ - problems: list[str] = [] - if self.cookie_name.startswith(("__Host-", "__Secure-")): - if not self.cookie_https_only: - problems.append("cookie_https_only=True") - if self.cookie_name.startswith("__Host-"): - if self.cookie_path != "/": - problems.append('cookie_path="/"') - if self.cookie_domain is not None: - problems.append("cookie_domain=None") - if problems: - name = f"cookie_name={self.cookie_name!r}" - if "cookie_name" not in self.model_fields_set: - name += " (the default)" - hints: list[str] = [] - if "cookie_https_only=True" in problems: - hints.append( - "For plain-HTTP development, use a name without the prefix, " - "such as cookie_name='session' with cookie_https_only=False." - ) - if self.cookie_path != "/" or self.cookie_domain is not None: - hints.append( - "To set a cookie_path or cookie_domain, use " - "cookie_name='__Secure-session', which keeps the Secure flag." - ) - msg = ( - f"{name} requires {', '.join(problems)}: browsers refuse a cookie " - "with this prefix otherwise, so the session cookie would never be " - f"stored. {' '.join(hints)} See " - "https://fastapi-cachex.readthedocs.io/en/stable/SESSION/#cookie-defaults" - ) - raise ValueError(msg) - return self - - @field_validator("token_source_priority") - @classmethod - def _check_cookie_is_last( - cls, value: list[Literal["header", "bearer", "cookie"]] - ) -> list[Literal["header", "bearer", "cookie"]]: - """Only accept ``"cookie"`` where the cookie is read: last. - - ``FastAPICacheXSessionMiddleware`` reads the cookie after the header - sources whether or not the list names it, so listing it anywhere else - would promise an order the middleware does not follow. - """ - if "cookie" in value and value.index("cookie") != len(value) - 1: - msg = ( - '"cookie" must be the last entry of token_source_priority: the ' - "session cookie is read after the header sources" - ) - raise ValueError(msg) - return value - - @model_validator(mode="after") - def _warn_use_bearer_token(self) -> "SessionConfig": - """Deprecate ``use_bearer_token``, removed with the session package in 0.5.0. - - ``token_source_priority`` already decides whether bearer tokens are - read, so passing the flag at all warns, whatever its value. - """ - if "use_bearer_token" in self.model_fields_set: - warnings.warn( - "SessionConfig(use_bearer_token=...) is deprecated and will be " - "removed in version 0.5.0 with fastapi_cachex.session. " - "token_source_priority decides the " - "token sources: drop use_bearer_token=True (the default), and " - 'replace use_bearer_token=False by leaving "bearer" out of ' - 'token_source_priority, keeping "cookie" last, e.g. ' - '["header", "cookie"] ' - "(https://github.com/allen0099/FastAPI-CacheX/issues/377).", - DeprecationWarning, - stacklevel=3, - ) - return self - - @field_validator("jwt_algorithm") - @classmethod - def _check_jwt_algorithm(cls, value: str) -> str: - """Reject signing algorithms that are unsupported or unsafe.""" - if value not in JWT_ALGORITHMS: - supported = ", ".join(sorted(JWT_ALGORITHMS)) - msg = f"jwt_algorithm must be one of: {supported}" - raise ValueError(msg) - return value diff --git a/fastapi_cachex/session/dependencies.py b/fastapi_cachex/session/dependencies.py deleted file mode 100644 index 9722e01..0000000 --- a/fastapi_cachex/session/dependencies.py +++ /dev/null @@ -1,435 +0,0 @@ -"""FastAPI dependency injection utilities for session management.""" - -from collections.abc import Iterable -from typing import TYPE_CHECKING -from typing import Annotated - -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Request -from fastapi import status -from fastapi.security import HTTPAuthorizationCredentials -from fastapi.security import HTTPBearer - -from .exceptions import SessionError -from .middleware import _SESSION_READ_KEY -from .middleware import _log_in -from .middleware import _RequestSession -from .middleware import get_client_ip -from .models import Session - -if TYPE_CHECKING: - from .manager import SessionManager - from .models import SessionUser - - -# HTTPBearer security scheme for OpenAPI UI -_http_bearer = HTTPBearer( - scheme_name="SessionBearer", - description="Session authentication using Bearer token", - auto_error=False, -) - - -def get_optional_session( - request: Request, - credentials: HTTPAuthorizationCredentials | None = Depends(_http_bearer), # noqa: ARG001 -) -> Session | None: - """Get session from request state (optional). - - This dependency automatically displays the authorization input box in OpenAPI/Swagger UI. - The actual authentication is handled by the session middleware - (``FastAPICacheXSessionMiddleware``); the credentials parameter - is only used to generate the OpenAPI security scheme. - - Args: - request: FastAPI request object - credentials: HTTPBearer credentials (for OpenAPI UI display only) - - Returns: - Session object or None if not authenticated - """ - return _read_session(request) - - -def _read_session(request: Request) -> Session | None: - """The session the middleware loaded for ``request``, or None. - - Records the read, so the session middleware adds ``Vary`` for the headers - the token may come from: the response depends on them even when the - handler never touches ``request.session`` (#372). - """ - setattr(request.state, _SESSION_READ_KEY, True) - return getattr(request.state, "__fastapi_cachex_session", None) - - -def get_session( - request: Request, - credentials: HTTPAuthorizationCredentials | None = Depends(_http_bearer), # noqa: ARG001 -) -> Session: - """Get session from request state (required). - - This dependency automatically displays the authorization input box in OpenAPI/Swagger UI. - The actual authentication is handled by the session middleware - (``FastAPICacheXSessionMiddleware``); the credentials parameter - is only used to generate the OpenAPI security scheme. - - Args: - request: FastAPI request object - credentials: HTTPBearer credentials (for OpenAPI UI display only) - - Returns: - Session object - - Raises: - HTTPException: 401 if session not found - """ - session = _read_session(request) - if session is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required", - headers={"WWW-Authenticate": "Bearer"}, - ) - return session - - -def require_user_session(session: Session = Depends(get_session)) -> Session: - """Get the request's session, requiring a logged-in user. - - ``get_session`` only checks that a session exists. Under - ``FastAPICacheXSessionMiddleware`` any visitor who reaches a route that - writes to ``request.session`` (a cart, a CSRF value) gets an anonymous - session with ``user=None``, which ``get_session`` accepts. Use this - dependency to guard routes that need an authenticated user. - - Args: - session: The request's session, from ``get_session`` - - Returns: - Session object whose ``user`` is set - - Raises: - HTTPException: 401 if there is no session, or the session has no user - """ - if session.user is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required", - headers={"WWW-Authenticate": "Bearer"}, - ) - return session - - -def get_session_manager(request: Request) -> "SessionManager": - """Get SessionManager instance from app state. - - This dependency allows you to access the SessionManager instance - that the session middleware (``FastAPICacheXSessionMiddleware``) - registered on ``app.state`` when it handled its first request. Use this when you need - to perform session operations like create, delete, or regenerate. - - Example: - ```python - from fastapi import Depends - from fastapi_cachex.session import get_session_manager, SessionManager - - - @app.post("/login") - async def login( - username: str, manager: SessionManager = Depends(get_session_manager) - ): - session, token = await manager.create_session(...) - return {"token": token} - ``` - - Args: - request: FastAPI request object - - Returns: - SessionManager instance - - Raises: - HTTPException: 500 if no session middleware has registered a - SessionManager yet - """ - state = request.app.state - manager: SessionManager | None = getattr( - state, "__fastapi_cachex_session_manager", None - ) - if manager is None: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=( - "SessionManager not initialized. Ensure " - "FastAPICacheXSessionMiddleware is added to the app." - ), - ) - return manager - - -async def rotate_session_id(request: Request) -> bool: - """Give the request's session a new ID, as a defence against session fixation. - - To log a user in under ``FastAPICacheXSessionMiddleware``, call - :func:`login` instead: it rotates the ID the same way, attaches the - ``SessionUser`` that ``require_user_session`` / ``AuthenticatedSession`` - check, and makes the middleware save the session and send its token, - also for a visitor who had no session yet. Use this function on its own - when the ID should change without a login (a privilege change, say). - - A session token the client arrived with may have been planted by someone - else; after rotation the old token no longer resolves, and the middleware - sends the client a token for the new ID through the transport the request - used. The session keeps its data, user and expiry. - - With no session loaded there is nothing to rotate: the first write to - ``request.session`` (or ``create_session()``) starts a session under a - fresh ID anyway. - - ``request.session["user_id"] = ...`` is application data, not a login: - ``require_user_session`` / ``AuthenticatedSession`` still answer ``401`` - for such a session. - - Example: - ```python - from fastapi_cachex.session import rotate_session_id - - - @app.post("/sudo") - async def sudo(request: Request, session: AuthenticatedSession): - ... # re-check the password - await rotate_session_id(request) - request.session["elevated"] = True - return {"ok": True} - ``` - - Args: - request: FastAPI request object - - Returns: - True if a loaded session was given a new ID, False if none was loaded - - Raises: - HTTPException: 401 if another request logged the session out (deleted, - invalidated or rotated it) since this one loaded it; 500 if no - session middleware has registered a SessionManager yet - """ - manager = get_session_manager(request) - session: Session | None = getattr(request.state, "__fastapi_cachex_session", None) - if session is None: - return False - try: - await manager.regenerate_session_id(session) - except SessionError as e: - # Another request logged the session out while this one ran (#128). - request_session = request.scope.get("session") - if isinstance(request_session, _RequestSession): - request_session.ended = True - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required", - headers={"WWW-Authenticate": "Bearer"}, - ) from e - return True - - -async def login( - request: Request, user: "SessionUser", *, keep: Iterable[str] | None = None -) -> Session: - """Log ``user`` in on the request's session, under a new session ID. - - This is the way to log in under ``FastAPICacheXSessionMiddleware`` when - routes are guarded by ``require_user_session`` / ``AuthenticatedSession``: - a later request with the token the response carries passes them. - - - An anonymous loaded session (a visitor's cart, say) keeps its data and - gets the user and a new ID, as with :func:`rotate_session_id`, so a - token planted before the login is worthless: the old token no longer - resolves. ``keep=`` narrows the data carried over. - - A loaded session of the same ``user_id`` (a re-login) is handled the - same way: its data is kept, the ID rotated, and ``user`` replaces the - stored ``SessionUser``, so changed roles or metadata take effect. - - A loaded session of a different user is deleted, and so is anything - written to ``request.session`` earlier in this request: none of the - previous user's data reaches the new one. A new session is created as - below, and its old token no longer resolves. - - With no session loaded (a new visitor, or a token that did not - resolve) a new session is created with the user, bound to the client - IP and User-Agent as configured. - - A loaded session that another request deleted, invalidated or rotated - while this one ran is not rotated back to life (#128). A new session is - created as above, and nothing written to ``request.session`` before - the call is carried over. - - The middleware then saves the session, including anything written to - ``request.session`` after the call (and, unless the loaded session was a - different user's, before it), and sends its token through - the transport the request used: the response header (``header_name``) - when the request carried a header or ``Authorization: Bearer`` token, - otherwise an HttpOnly ``Set-Cookie``. So a request that carried no token - at all gets only the cookie; an API client that needs the token in the - body can return ``SessionManager.issue_token()`` for the returned session. - The response is marked ``Cache-Control: private, no-store``, as for every - response that carries a session token. - - Within the same request, ``request.session.clear()`` after ``login()`` is - a logout: the logged-in session is deleted and no token is sent (a cookie - client gets its cookie expired). ``clear()`` before ``login()`` logs the - loaded session out, and ``login()`` then starts a new session instead of - rotating it. Calling ``login()`` twice rotates again and keeps the last - user. :func:`logout` ends the session. - - This is the only way to attach a user under the middleware: - ``Session.user`` is read-only. - - Example: - ```python - from fastapi_cachex.session import SessionUser, login - - - @app.post("/login") - async def log_in(credentials: Credentials, request: Request): - ... # verify the credentials - await login(request, SessionUser(user_id=credentials.username)) - return {"ok": True} - ``` - - Args: - request: FastAPI request object - user: The user to attach - keep: The ``request.session`` keys to carry into the logged-in session - (``keep=["cart"]``), or ``[]`` for none. By default (None) all of - them are carried. Anything else, including what was written to - ``request.session`` earlier in this request, is dropped. It never - carries a different user's data, which is always dropped. - - Returns: - The logged-in session, also what ``get_session`` returns for the rest - of the request - - Raises: - RuntimeError: If the request did not pass through - ``FastAPICacheXSessionMiddleware``. Without it, create the - session with ``SessionManager.create_session(user=...)`` and - return its token. - TypeError: If ``keep`` is a string rather than a collection of keys - """ - if isinstance(keep, str): - msg = f"keep must be a collection of keys, not a string: use keep=[{keep!r}]" - raise TypeError(msg) - request_session = _middleware_session(request, "login()") - kept = None if keep is None else frozenset(keep) - return await _log_in(request, request_session, user, kept) - - -async def logout(request: Request) -> bool: - """Log the request's session out: delete it and forget its token. - - The session record is deleted at once, so its token stops resolving for - every request, including ones already in flight. The middleware then - expires a cookie client's cookie; a header client simply drops its token, - as no new one is sent. For the rest of the request, ``get_session`` - finds no session, and ``request.session`` is empty: anything written to - it afterwards starts a new anonymous session. - - ``request.session.clear()`` also logs out, but the record is deleted only - when the response starts, and ``get_session`` keeps returning the session - for the rest of the request. Calling :func:`login` after either in the - same request starts a new session. - - Example: - ```python - from fastapi_cachex.session import logout - - - @app.post("/logout") - async def log_out(request: Request): - await logout(request) - return {"ok": True} - ``` - - Args: - request: FastAPI request object - - Returns: - True if a session was deleted, False if none was loaded or started - in this request (a token that did not resolve included) - - Raises: - RuntimeError: If the request did not pass through - ``FastAPICacheXSessionMiddleware``. Without it, call - ``SessionManager.delete_session()`` with the session's ID. - """ - request_session = _middleware_session(request, "logout()") - session = request_session.backend - # clear() makes the middleware expire the cookie (and delete the record - # again, which is harmless) when the response starts. - request_session.clear() - request.scope.setdefault("state", {})["__fastapi_cachex_session"] = None - if session is None: - return False - middleware = request_session.middleware - assert middleware is not None # noqa: S101 - checked by _middleware_session() - await middleware.session_manager.delete_session(session.session_id) - return True - - -def _middleware_session(request: Request, caller: str) -> _RequestSession: - """Return the middleware's ``request.session``, or raise for ``caller``.""" - request_session = request.scope.get("session") - if ( - not isinstance(request_session, _RequestSession) - or request_session.middleware is None - ): - msg = ( - f"{caller} needs FastAPICacheXSessionMiddleware: add it to the app " - "so it can save the session and send its token" - ) - raise RuntimeError(msg) - return request_session - - -def get_session_client_ip( - request: Request, - manager: "SessionManager" = Depends(get_session_manager), -) -> str | None: - """Get the client IP address the session middleware binds sessions to. - - Resolves the address with the registered SessionManager's configuration, - honouring `trusted_proxies` exactly like the middleware does. Pass it to - `create_session()` so `ip_binding` checks later requests against the same - address. - - Example: - ```python - from fastapi_cachex.session.dependencies import ClientIPDep, SessionManagerDep - - - @app.post("/login") - async def login(manager: SessionManagerDep, client_ip: ClientIPDep): - session, token = await manager.create_session(user, ip_address=client_ip) - return {"token": token} - ``` - - Args: - request: FastAPI request object - manager: SessionManager registered by the session middleware - - Returns: - Client IP address or None - """ - return get_client_ip(request, manager.config) - - -require_session = get_session # Alias for required session dependency - -# Type annotations for dependency injection -OptionalSession = Annotated[Session | None, Depends(get_optional_session)] -RequiredSession = Annotated[Session, Depends(get_session)] -SessionDep = Annotated[Session, Depends(get_session)] -AuthenticatedSession = Annotated[Session, Depends(require_user_session)] -# Same as AuthenticatedSession; it admitted anonymous sessions before 0.4.0. -UserSessionDep = Annotated[Session, Depends(require_user_session)] -SessionManagerDep = Annotated["SessionManager", Depends(get_session_manager)] -ClientIPDep = Annotated[str | None, Depends(get_session_client_ip)] diff --git a/fastapi_cachex/session/exceptions.py b/fastapi_cachex/session/exceptions.py deleted file mode 100644 index 4c71b4b..0000000 --- a/fastapi_cachex/session/exceptions.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Session-related exceptions.""" - -from fastapi_cachex.exceptions import CacheXError - - -class SessionError(CacheXError): - """Base exception for session errors. - - Derives from ``CacheXError`` since 0.3.8, like ``StateError``, so - ``except CacheXError`` also catches session errors. - """ - - -class SessionNotFoundError(SessionError): - """Raised when a session is not found.""" - - -class SessionExpiredError(SessionError): - """Raised when a session has expired.""" - - -class SessionInvalidError(SessionError): - """Raised when a session is invalid.""" - - -class SessionSecurityError(SessionError): - """Raised when a session fails security checks.""" - - -class SessionTokenError(SessionError): - """Raised when there's an issue with session token.""" diff --git a/fastapi_cachex/session/manager.py b/fastapi_cachex/session/manager.py deleted file mode 100644 index 5483e1e..0000000 --- a/fastapi_cachex/session/manager.py +++ /dev/null @@ -1,667 +0,0 @@ -"""Session manager for CRUD operations.""" - -import logging -from collections.abc import AsyncIterator -from datetime import datetime -from datetime import timedelta -from datetime import timezone - -from fastapi_cachex.backends.base import BaseCacheBackend -from fastapi_cachex.types import CacheEntry - -from .config import SessionConfig -from .exceptions import SessionExpiredError -from .exceptions import SessionInvalidError -from .exceptions import SessionNotFoundError -from .exceptions import SessionSecurityError -from .exceptions import SessionTokenError -from .models import Session -from .models import SessionStatus -from .models import SessionToken -from .models import SessionUser -from .security import SecurityManager -from .token_serializers import JWTTokenSerializer -from .token_serializers import SimpleTokenSerializer -from .token_serializers import TokenSerializer - -logger = logging.getLogger(__name__) - -# Session entries are never compared by fingerprint, so a constant saves -# hashing the payload on every write. -_SESSION_FINGERPRINT = "session" - - -def _now() -> datetime: - return datetime.now(timezone.utc) - - -class SessionManager: - """Manages session lifecycle and storage.""" - - def __init__( - self, - backend: BaseCacheBackend, - config: SessionConfig, - token_serializer: TokenSerializer | None = None, - ) -> None: - """Initialize session manager. - - Args: - backend: Cache backend for session storage - config: Session configuration - token_serializer: Optional custom token serializer. If provided, - overrides the built-in selection (simple/jwt). - - Raises: - ValueError: If ``config.token_format`` is ``"jwt"`` and no - ``token_serializer`` is given, with an asymmetric - ``jwt_algorithm``, or with a ``secret_key`` shorter in UTF-8 - bytes than the HMAC hash output (48 for HS384, 64 for HS512). - ImportError: If ``config.token_format`` is ``"jwt"``, no - ``token_serializer`` is given and PyJWT is not installed. - """ - self.backend = backend - self.config = config - secret_value = config.secret_key.get_secret_value() - self.security = SecurityManager(secret_value) - # Select token serializer strategy (allow DI override) - if token_serializer is not None: - self._serializer = token_serializer - elif config.token_format == "jwt": - self._serializer = JWTTokenSerializer(config) - else: - self._serializer = SimpleTokenSerializer() - logger.debug("Token serializer: %s", type(self._serializer).__name__) - logger.debug( - "SessionManager initialized with backend prefix=%s", - config.backend_key_prefix, - ) - - def _expiry_for(self, session: Session) -> datetime: - """Return now + session_ttl, capped at the session's absolute_timeout. - - Without the cap, the backend TTL and a JWT's exp would outlive the - session, which get_session rejects once absolute_timeout passes. - """ - expires_at = _now() + timedelta(seconds=self.config.session_ttl) - if self.config.absolute_timeout is not None: - cap = session.created_at + timedelta(seconds=self.config.absolute_timeout) - expires_at = min(expires_at, cap) - return expires_at - - def _get_backend_key(self, session_id: str) -> str: - """Get backend storage key for a session. - - Args: - session_id: The session ID - - Returns: - Backend storage key - """ - return f"{self.config.backend_key_prefix}{session_id}" - - async def create_session( - self, - user: SessionUser, - ip_address: str | None = None, - user_agent: str | None = None, - **extra_data: object, - ) -> tuple[Session, str]: - """Create a new session for an authenticated user. - - Args: - user: Authenticated user data - ip_address: Client IP address (if IP binding enabled) - user_agent: Client User-Agent (if UA binding enabled) - **extra_data: Additional session data - - Returns: - Tuple of (Session, token_string) - """ - return await self._create_session( - user=user, - ip_address=ip_address, - user_agent=user_agent, - **extra_data, - ) - - async def create_anonymous_session( - self, - ip_address: str | None = None, - user_agent: str | None = None, - **extra_data: object, - ) -> tuple[Session, str]: - """Create a new session without user information. - - Args: - ip_address: Client IP address (if IP binding enabled) - user_agent: Client User-Agent (if UA binding enabled) - **extra_data: Additional session data - - Returns: - Tuple of (Session, token_string) - """ - return await self._create_session( - user=None, - ip_address=ip_address, - user_agent=user_agent, - **extra_data, - ) - - async def _create_session( - self, - user: SessionUser | None, - ip_address: str | None = None, - user_agent: str | None = None, - **extra_data: object, - ) -> tuple[Session, str]: - """Internal helper to create and persist a session.""" - session = Session( - user=user, - data=extra_data, - ) - - # Set expiry - if self.config.session_ttl: - session.expires_at = self._expiry_for(session) - - # Bind IP and User-Agent if configured - if self.config.ip_binding: - if ip_address: - session.ip_address = ip_address - else: - logger.warning( - "ip_binding is enabled but no IP address available; " - "session created without IP binding" - ) - if self.config.user_agent_binding: - if user_agent: - session.user_agent = user_agent - else: - logger.warning( - "user_agent_binding is enabled but no User-Agent available; " - "session created without UA binding" - ) - - # Store in backend - await self._save_session(session, conditional=False) - - token = self.issue_token(session) - logger.debug( - "Session created; id=%s ttl=%s ip=%s ua=%s", - session.session_id, - self.config.session_ttl, - session.ip_address, - session.user_agent, - ) - - return session, token - - async def get_session( - self, - token_string: str, - ip_address: str | None = None, - user_agent: str | None = None, - *, - touch: bool = False, - ) -> tuple[Session, str | None]: - """Retrieve and validate a session. - - A lookup writes to the backend only when sliding expiration renewed - the session, since that is the only change to its expiry or TTL. The - returned session's ``last_accessed`` is always the current time, but - it is stored only when the session is next written, unless ``touch`` - is set. - - That write is conditional, like ``update_session()``. If another - request changed, deleted or invalidated the session since it was read, - the session is read and checked again, so a deleted or invalidated - session is refused and one another request already renewed is - returned as it is. - - Args: - token_string: Session token string - ip_address: Current request IP address - user_agent: Current request User-Agent - touch: Save the session even when it was not renewed, so the - stored ``last_accessed`` is exact - - Returns: - Tuple of (session, new_token_string). new_token_string is non-None - when sliding expiration triggered a renewal; the caller should - propagate it to the client (e.g. via a response header). - - Raises: - SessionTokenError: If the token cannot be parsed (for a JWT, also - a bad signature, an expired ``exp`` or a wrong ``iss``/``aud``) - SessionNotFoundError: If session not found - SessionExpiredError: If the session is past its ``expires_at`` or - ``absolute_timeout``; it is saved as ``EXPIRED`` first - SessionInvalidError: If session is not active - SessionSecurityError: If a ``simple`` token's signature is wrong, - or an IP / User-Agent binding does not match - """ - return await self._get_session( - token_string, ip_address, user_agent, touch=touch, retry=True - ) - - async def _get_session( - self, - token_string: str, - ip_address: str | None, - user_agent: str | None, - *, - touch: bool, - retry: bool, - ) -> tuple[Session, str | None]: - """``get_session()``, reading the session again once if its save loses a race.""" - # Parse and verify token - try: - token = self._serializer.from_string(token_string) - except ValueError as e: - logger.debug("Session token parse error: %s", e) - raise SessionTokenError(str(e)) from e - # For simple format, verify signature explicitly - if self.config.token_format == "simple" and not self.security.verify_signature( - token.session_id, - token.signature, - ): - msg = "Invalid session signature" - logger.debug( - "Session signature verification failed; id=%s", token.session_id - ) - raise SessionSecurityError(msg) - - # Load session from backend - session = await self._load_session(token.session_id) - if not session: - msg = f"Session {token.session_id} not found" - logger.debug("Session not found; id=%s", token.session_id) - raise SessionNotFoundError(msg) - - # Validate session - if session.status != SessionStatus.ACTIVE: - msg = f"Session is {session.status}" - logger.debug( - "Session not active; id=%s status=%s", - session.session_id, - session.status, - ) - raise SessionInvalidError(msg) - - if session.is_expired(): - logger.debug("Session expired; id=%s", session.session_id) - await self._expire(session, "Session has expired") - - # Check absolute timeout (hard cap regardless of sliding expiration) - if self.config.absolute_timeout is not None and _now() >= ( - session.created_at + timedelta(seconds=self.config.absolute_timeout) - ): - logger.debug( - "Session absolute timeout exceeded; id=%s created_at=%s", - session.session_id, - session.created_at, - ) - await self._expire(session, "Session has exceeded absolute timeout") - - # Security checks - if self.config.ip_binding and not self.security.check_ip_match( - session, - ip_address, - ): - msg = "IP address mismatch" - logger.debug( - "IP mismatch; id=%s expected=%s got=%s", - session.session_id, - session.ip_address, - ip_address, - ) - raise SessionSecurityError(msg) - - if self.config.user_agent_binding and not self.security.check_user_agent_match( - session, - user_agent, - ): - msg = "User-Agent mismatch" - logger.debug( - "UA mismatch; id=%s expected=%s got=%s", - session.session_id, - session.user_agent, - user_agent, - ) - raise SessionSecurityError(msg) - - # Update last accessed and handle sliding expiration - session.update_last_accessed() - - renewed_token: str | None = None - if self.config.sliding_expiration and session.expires_at: - time_remaining = (session.expires_at - _now()).total_seconds() - threshold = self.config.session_ttl * self.config.sliding_threshold - - expires_at = self._expiry_for(session) - # Once expires_at sits at the absolute_timeout cap, renewing would - # only re-issue a token with the same expiry on every request. - if time_remaining < threshold and expires_at > session.expires_at: - session.expires_at = expires_at - renewed_token = self.issue_token(session) - logger.debug( - "Session renewed (sliding expiration); id=%s ttl=%s", - session.session_id, - self.config.session_ttl, - ) - - if (renewed_token is not None or touch) and not await self._save_session( - session, conditional=True - ): - if retry: - return await self._get_session( - token_string, ip_address, user_agent, touch=touch, retry=False - ) - # Lost the race twice: the session was valid when read again, so - # return it without the renewal rather than refuse the request. - logger.debug( - "Session renewal dropped after a second concurrent write; id=%s", - session.session_id, - ) - renewed_token = None - - return session, renewed_token - - async def update_session(self, session: Session) -> bool: - """Save changes to an existing session. - - The save is conditional (#128): it succeeds only while the backend - still holds what ``session`` was last read from or written as. If - another request changed, deleted or invalidated the session in the - meantime, nothing is written and ``False`` is returned, so a request - that loaded the session earlier cannot bring back a logged-out or - rotated session. Two concurrent changes to one session therefore keep - the first save, not the last. - - Deleting and invalidating are unconditional. Regenerating the ID - requires only that the session is still valid, not unchanged. - - Args: - session: Session to update, as returned by ``get_session()`` or - ``create_session()`` - - Returns: - Whether the session was saved - """ - session.update_last_accessed() - if not await self._save_session(session, conditional=True): - logger.info( - "Session save dropped: the session changed, was deleted or was " - "invalidated since it was read; id=%s", - session.session_id, - ) - return False - logger.debug("Session updated; id=%s", session.session_id) - return True - - async def delete_session(self, session_id: str) -> None: - """Delete a session. - - Args: - session_id: Session ID to delete - """ - key = self._get_backend_key(session_id) - await self.backend.delete(key) - logger.debug("Session deleted; id=%s", session_id) - - async def invalidate_session(self, session: Session) -> None: - """Invalidate a session. - - Args: - session: Session to invalidate - """ - session.invalidate() - await self._save_session(session, conditional=False) - logger.debug("Session invalidated; id=%s", session.session_id) - - async def regenerate_session_id( - self, - session: Session, - ) -> tuple[Session, str]: - """Regenerate session ID (after login for security). - - ``session`` is changed in place. When it is the request's session under - either middleware, the middleware notices the new ID and sends the new - token through the request's transport, so a handler that only needs - the cookie or header updated can ignore the returned token. - - A session read from the backend is rotated only while its record is - still there and valid (#128): the old record is removed atomically, so - a copy read before another request deleted or invalidated the session - cannot come back under a new ID, and of two concurrent rotations only - one succeeds. The old record is gone either way, so the old token no - longer resolves, even when the rotation is refused. A session that was - never read from or written to the backend (built by hand) is rotated - without the check. - - Args: - session: Session to regenerate - - Returns: - Tuple of (updated session, new token string) - - Raises: - SessionNotFoundError: The record was deleted since ``session`` was - read, or another request rotated it first - SessionInvalidError: The record was invalidated or has expired - since ``session`` was read - """ - old_id = session.session_id - if session._stored is None: # noqa: SLF001 - await self.delete_session(old_id) - else: - await self._claim(old_id) - - session.regenerate_id() - - # Save with new ID - await self._save_session(session, conditional=False) - - logger.debug( - "Session ID regenerated; old_id=%s new_id=%s", old_id, session.session_id - ) - - return session, self.issue_token(session) - - def issue_token(self, session: Session) -> str: - """Sign a token string for ``session``'s current ID and expiry. - - This is the token ``create_session``, sliding renewal and - ``regenerate_session_id`` hand out; the middleware also uses it to - send a fresh token once a handler has regenerated the session ID. - - Args: - session: Session to issue a token for - - Returns: - Serialized session token - """ - # expires_at is passed so a JWT's exp matches the session's expiry. - token = self._create_token(session.session_id, expires_at=session.expires_at) - return self._serializer.to_string(token) - - async def delete_user_sessions(self, user_id: str) -> int: - """Delete all sessions for a user. - - Args: - user_id: User ID - - Returns: - Number of sessions deleted - """ - keys = [ - key - async for key, session in self._iter_sessions() - if session.user and session.user.user_id == user_id - ] - count = await self.backend.delete_many(keys) - - logger.debug("User sessions deleted; user_id=%s count=%s", user_id, count) - return count - - async def clear_expired_sessions(self) -> int: - """Clear every session that can no longer be used. - - That is any session past its ``expires_at`` and any session no longer - ``ACTIVE``: one that ``invalidate_session()`` or an expired read already - marked, which would otherwise stay in the backend until its TTL. - - Returns: - Number of sessions cleared - """ - keys = [ - key - async for key, session in self._iter_sessions() - if session.status != SessionStatus.ACTIVE or session.is_expired() - ] - count = await self.backend.delete_many(keys) - - logger.debug("Expired sessions cleared; count=%s", count) - return count - - async def _iter_sessions(self) -> AsyncIterator[tuple[str, Session]]: - """Yield every readable session under this manager's prefix with its key. - - Backends that cannot enumerate keys yield nothing: the built-in - Memcached backend returns ``[]`` from ``get_all_keys()`` with a - ``RuntimeWarning``, and a custom backend may raise - ``NotImplementedError`` instead. - """ - try: - all_keys = await self.backend.get_all_keys() - except NotImplementedError: - return - for key in all_keys: - if not key.startswith(self.config.backend_key_prefix): - continue - session = await self._load_session_by_key(key) - if session is not None: - yield key, session - - async def _expire(self, session: Session, reason: str) -> None: - """Persist ``session`` as expired and raise SessionExpiredError.""" - session.status = SessionStatus.EXPIRED - await self._save_session(session, conditional=False) - raise SessionExpiredError(reason) - - def _create_token( - self, - session_id: str, - expires_at: datetime | None = None, - ) -> SessionToken: - """Create a signed session token. - - Args: - session_id: Session ID to sign - expires_at: Session expiry time, forwarded to JWT serializers - - Returns: - SessionToken object - """ - # For 'simple' format, include HMAC signature in the token model. - # For 'jwt' format, signature will be embedded in the JWT string; we can - # leave the signature field empty as it won't be used downstream. - signature = ( - self.security.sign_session_id(session_id) - if self.config.token_format == "simple" - else "" - ) - return SessionToken( - session_id=session_id, signature=signature, expires_at=expires_at - ) - - async def _save_session(self, session: Session, *, conditional: bool) -> bool: - """Save session to backend. - - Args: - session: Session to save - conditional: Store only while the backend holds what ``session`` - was last read from or written as (only if the record is absent, - when it was never read or written); otherwise overwrite - - Returns: - Whether the session was saved (always True when not conditional) - """ - payload = session.model_dump_json() - - # Calculate TTL - ttl = None - if session.expires_at: - ttl = int((session.expires_at - _now()).total_seconds()) - ttl = max(ttl, 1) # Ensure at least 1 second - - entry = CacheEntry( - fingerprint=_SESSION_FINGERPRINT, - content=payload.encode("utf-8"), - ) - key = self._get_backend_key(session.session_id) - expected = session._stored # noqa: SLF001 - if not conditional: - await self.backend.set(key, entry, ttl=ttl) - elif expected is None: - if not await self.backend.set_if_absent(key, entry, ttl=ttl): - return False - elif not await self.backend.set_if_equals(key, expected, entry, ttl=ttl): - return False - session._stored = entry # noqa: SLF001 - logger.debug("Session saved; id=%s ttl=%s", session.session_id, ttl) - return True - - async def _load_session(self, session_id: str) -> Session | None: - """Load session from backend. - - Args: - session_id: Session ID to load - - Returns: - Session object or None if not found - """ - return await self._load_session_by_key(self._get_backend_key(session_id)) - - async def _claim(self, session_id: str) -> None: - """Remove the record under ``session_id``, raising unless it was valid.""" - claimed = await self.backend.get_and_delete(self._get_backend_key(session_id)) - current = self._decode(claimed) if claimed else None - if current is None: - msg = f"Session {session_id} was deleted before its ID could be regenerated" - logger.debug("Session rotation refused, not found; id=%s", session_id) - raise SessionNotFoundError(msg) - if not current.is_valid(): - msg = f"Session {session_id} ended before its ID could be regenerated" - logger.debug("Session rotation refused, not valid; id=%s", session_id) - raise SessionInvalidError(msg) - - @staticmethod - def _decode(entry: CacheEntry) -> Session | None: - """The session stored as ``entry``, or None if it does not parse.""" - try: - session = Session.model_validate_json(entry.content) - except (ValueError, TypeError): - return None - session._stored = entry # noqa: SLF001 - return session - - async def _load_session_by_key(self, key: str) -> Session | None: - """Load session from backend by key. - - Args: - key: Backend key - - Returns: - Session object or None if not found - """ - cached = await self.backend.get(key) - if not cached: - logger.debug("Session load MISS; key=%s", key) - return None - - session = self._decode(cached) - if session is None: - logger.debug("Session load DESERIALIZE ERROR; key=%s", key) - return session diff --git a/fastapi_cachex/session/middleware.py b/fastapi_cachex/session/middleware.py deleted file mode 100644 index 664e203..0000000 --- a/fastapi_cachex/session/middleware.py +++ /dev/null @@ -1,618 +0,0 @@ -"""Session middleware for FastAPI.""" - -import logging -from typing import TYPE_CHECKING -from typing import Any - -from starlette.datastructures import MutableHeaders -from starlette.middleware.sessions import Session as StarletteSession -from starlette.requests import HTTPConnection -from starlette.types import ASGIApp -from starlette.types import Message -from starlette.types import Receive -from starlette.types import Scope -from starlette.types import Send - -from fastapi_cachex.headers import add_vary - -from .config import SessionConfig -from .exceptions import SessionError -from .manager import SessionManager -from .proxy import SessionManagerProxy - -if TYPE_CHECKING: - from collections.abc import Collection - - from .models import Session - from .models import SessionUser - -logger = logging.getLogger(__name__) - - -def get_client_ip(connection: HTTPConnection, config: SessionConfig) -> str | None: - """Get the client IP address from an HTTP connection. - - This is the address the session middleware checks `ip_binding` against. - Pass the same value to `SessionManager.create_session()` so the address - stored at login matches the one checked on later requests, which - `request.client.host` does not when the app runs behind a trusted proxy. - - `X-Forwarded-For` and `X-Real-IP` are believed only when the request - actually arrived from one of `config.trusted_proxies`; with the default - empty list they are ignored entirely, because anyone can send them. - - Even behind a trusted proxy the leftmost `X-Forwarded-For` entry is not the - client: proxies append, so a caller who sends the header themselves has - their value sitting in front of the address the proxy added. This walks the - chain from the right and takes the first address that is not a trusted - proxy — the closest hop nobody in the chain could have forged. If every - entry is a trusted proxy there is no client address to recover and the - direct peer is used. - - Args: - connection: Incoming HTTP connection (or a `Request`, which IS-A - `HTTPConnection`) - config: Session configuration carrying the trusted proxy list - - Returns: - Client IP address or None - """ - peer = connection.client.host if connection.client else None - - if peer is not None and config.is_trusted_proxy(peer): - # A proxy may add its own header line instead of appending to the - # caller's, so the chain is every line joined, not just the first one. - forwarded_for = ",".join(connection.headers.getlist("x-forwarded-for")) - if forwarded_for: - for entry in reversed(forwarded_for.split(",")): - candidate = entry.strip() - if candidate and not config.is_trusted_proxy(candidate): - logger.debug("Client IP from X-Forwarded-For: %s", candidate) - return candidate - - # X-Real-IP is written by the proxy itself, so it has no chain to walk. - real_ip = connection.headers.get("x-real-ip") - if real_ip: - logger.debug("Client IP from X-Real-IP: %s", real_ip) - return real_ip - - if peer is not None: - logger.debug("Client IP from connection: %s", peer) - - return peer - - -def _read_header_token( - connection: HTTPConnection, config: SessionConfig -) -> tuple[str | None, list[str]]: - """Extract a session token and name the request headers that were read. - - The response depends on every header read before the token was found, so - those are the names it must ``Vary`` on. - - Args: - connection: Incoming HTTP connection - config: Session configuration - - Returns: - ``(token, consulted)``: the token or None, and the header names read, - in the order they were checked - """ - consulted: list[str] = [] - # `token_source_priority` is a list of Literals, so pydantic has already - # rejected anything else; the chain stays an `elif` so "cookie" (read by - # FastAPICacheXSessionMiddleware after these) and any source added later - # fall through instead of being read as a bearer token. - for source in config.token_source_priority: - if source == "header": - consulted.append(config.header_name) - token = connection.headers.get(config.header_name) - if token: - logger.debug("Token extracted from header") - return token, consulted - - elif source == "bearer": - if config.use_bearer_token: - consulted.append("Authorization") - # The scheme name is case-insensitive (RFC 9110 §11.1) and is - # followed by one or more spaces (RFC 6750 §2.1). - scheme, _, token_value = connection.headers.get( - "authorization", "" - ).partition(" ") - token_value = token_value.lstrip(" ") - if scheme.lower() == "bearer" and token_value: - logger.debug("Token extracted from bearer auth") - return token_value, consulted - - return None, consulted - - -# Set on the request state when a session dependency reads the loaded session, -# so the middleware knows the response depends on the token sources (#372). -_SESSION_READ_KEY = "__fastapi_cachex_session_read" - - -def _session_was_read(connection: HTTPConnection) -> bool: - """Whether a session dependency read the session for this request.""" - return bool(connection.scope.get("state", {}).get(_SESSION_READ_KEY)) - - -def _forbid_storing(headers: MutableHeaders) -> None: - """Keep a response that carries a session token out of every cache. - - The token is a credential: a shared cache that stored the response would - hand it to the next visitor. This replaces any ``Cache-Control`` the route - set, ``public`` and ``max-age`` included. - - Args: - headers: Mutable response headers to write to - """ - headers["Cache-Control"] = "private, no-store" - - -def _stash_session_manager(app: Any, manager: SessionManager) -> None: - """Register the session manager on ``app.state`` for dependency injection. - - Stored under a private key on the first request only; ``get_session_manager`` - reads it back. - - Args: - app: The Starlette application (``scope["app"]`` / ``request.app``) - manager: Session manager to register - """ - if not hasattr(app.state, "__fastapi_cachex_session_manager"): - setattr(app.state, "__fastapi_cachex_session_manager", manager) - - -class _RequestSession(StarletteSession): - """``request.session`` that remembers an explicit ``clear()``. - - ``clear()`` is the logout idiom, so it has to end the loaded session even - when its data was already empty, while removing the last key with - ``del``/``pop`` must not log a user out. - """ - - cleared: bool = False - # rotate_session_id() found that another request ended the loaded session - # (#128). Nothing is saved or sent: the winner of a concurrent rotation may - # already have given the client its new token. - ended: bool = False - # The backend session this dict belongs to: the one the middleware loaded, - # or the one ``login()`` started or rotated. Read when the response starts. - backend: "Session | None" = None - # The middleware that created this dict; ``login()`` goes through it. - middleware: "FastAPICacheXSessionMiddleware | None" = None - - def clear(self) -> None: - self.cleared = True - super().clear() - - -class FastAPICacheXSessionMiddleware: - """Drop-in-compatible replacement for Starlette's ``SessionMiddleware``. - - Provides the same ``request.session`` / ``scope["session"]`` dict-like - interface as ``starlette.middleware.sessions.SessionMiddleware``, but the - session payload is persisted via the configured ``SessionManager``/cache - backend instead of being encoded into the cookie itself. Only a signed - session token is stored client-side, in the cookie named by - ``SessionConfig.cookie_name``. A token in the custom header or an - ``Authorization: Bearer`` header is read before the cookie, and a request - that sent one (even one that no longer resolves) gets its token back in - the ``header_name`` response header instead of ``Set-Cookie``. - """ - - def __init__( - self, - app: ASGIApp, - session_manager: SessionManager | None = None, - config: SessionConfig | None = None, - ) -> None: - """Initialize the Starlette-aligned session middleware. - - Args: - app: ASGI application - session_manager: Session manager instance; defaults to the one - set in ``SessionManagerProxy`` - config: Session configuration; defaults to - ``session_manager.config`` - - Raises: - ProxyNotSetError: If ``session_manager`` is omitted and - ``SessionManagerProxy`` holds none. - """ - self.app = app - self.session_manager = session_manager or SessionManagerProxy.get() - self.config = config or self.session_manager.config - - security_flags = f"httponly; samesite={self.config.cookie_same_site}" - if self.config.cookie_https_only: - security_flags += "; secure" - if self.config.cookie_domain is not None: - security_flags += f"; domain={self.config.cookie_domain}" - self._security_flags = security_flags - - logger.debug( - "FastAPICacheXSessionMiddleware initialized; cookie=%s path=%s", - self.config.cookie_name, - self.config.cookie_path, - ) - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - """Load the session for the connection and persist it on response. - - Args: - scope: ASGI connection scope - receive: ASGI receive callable - send: ASGI send callable - """ - if scope["type"] not in ("http", "websocket"): - await self.app(scope, receive, send) - return - - # Store session manager in app state for dependency injection (first request only) - _stash_session_manager(scope["app"], self.session_manager) - - connection = HTTPConnection(scope) - loaded_token: str | None = None - renewed_token: str | None = None - backend_session: Session | None = None - loaded_session_id: str | None = None - - # Resolve the incoming session token: prefer the header/bearer transport - # (e.g. X-Session-Token) and fall back to the session cookie, so - # header-based clients authenticate here too. - # `header_token` is captured so the response is routed by transport: a - # header-sourced token is echoed back via the response header, otherwise - # via Set-Cookie (see send_wrapper). - header_token, vary_on = self._token_sources(connection) - token_value = header_token or connection.cookies.get(self.config.cookie_name) - if token_value: - try: - ip_address = get_client_ip(connection, self.config) - user_agent = connection.headers.get("user-agent") - backend_session, renewed_token = await self.session_manager.get_session( - token_value, - ip_address=ip_address, - user_agent=user_agent, - ) - loaded_token = renewed_token or token_value - loaded_session_id = backend_session.session_id - except SessionError: - logger.debug( - "FastAPICacheXSessionMiddleware: token invalid/expired; " - "starting empty session", - ) - - request_session = _RequestSession( - backend_session.data if backend_session is not None else {} - ) - request_session.backend = backend_session - request_session.middleware = self - scope["session"] = request_session - - scope.setdefault("state", {})["__fastapi_cachex_session"] = backend_session - - # Route the response by how the token arrived: header-sourced tokens are - # echoed back via the response header (no cookies); cookie-sourced (or - # brand-new) sessions use Set-Cookie. - from_header = header_token is not None - - async def send_wrapper(message: Message) -> None: - if message["type"] == "http.response.start": - session: _RequestSession = scope["session"] - headers = MutableHeaders(scope=message) - - # login() may have started a session, or replaced the loaded one. - target = request_session.backend - current_token, fresh_token = self._response_tokens( - target, loaded_session_id, loaded_token, renewed_token - ) - - sent_token = await self._persist( - session, - headers, - connection, - target, - current_token, - fresh_token, - from_header=from_header, - ) - - if session.accessed or sent_token or _session_was_read(connection): - add_vary(headers, vary_on) - if sent_token: - _forbid_storing(headers) - - await send(message) - - await self.app(scope, receive, send_wrapper) - - async def _persist( # noqa: PLR0913, PLR0917 - self, - session: _RequestSession, - headers: MutableHeaders, - connection: HTTPConnection, - backend_session: "Session | None", - current_token: str | None, - fresh_token: str | None, - *, - from_header: bool, - ) -> bool: - """Save, delete or renew the session according to what the request did to it. - - Returns: - True if a token or a clearing cookie was written to the response - """ - if session.ended: - return False - sent_token = False - target = backend_session - if session.cleared and target is not None: - # clear() logs out, whatever the data held. Anything - # written after it goes into a new anonymous session. - await self.session_manager.delete_session(target.session_id) - target = current_token = fresh_token = None - if not session and not from_header: - # Cookie transport: expire the cookie. A header-based - # client simply drops its now-dangling token. - headers.append("Set-Cookie", self._build_clear_cookie_header()) - sent_token = True - - if session.modified and ( - session or (target is not None and target.user is not None) - ): - # A logged-in session emptied with del/pop keeps its user - # and is saved with empty data. - cookie_token, new_token = await self._write_session( - session, - connection, - target, - current_token, - fresh_token, - ) - # Header clients only need a genuinely new/renewed token (an - # unchanged one is already held); cookie clients always get a - # refreshed cookie. - token_to_emit = new_token if from_header else cookie_token - if token_to_emit is not None: - self._emit_token(headers, token_to_emit, from_header=from_header) - sent_token = True - elif session.modified and target is not None: - # An anonymous session left empty holds nothing to keep. - await self.session_manager.delete_session(target.session_id) - if not from_header: - headers.append("Set-Cookie", self._build_clear_cookie_header()) - sent_token = True - elif fresh_token is not None: - # Sliding expiration renewed the token, or the ID was - # regenerated, even though the dict itself was untouched; - # propagate it via the same transport. - self._emit_token(headers, fresh_token, from_header=from_header) - sent_token = True - return sent_token - - def _token_sources( - self, connection: HTTPConnection - ) -> tuple[str | None, list[str]]: - """The header-carried token, if any, and the request headers to Vary on. - - The response depends on every header read to find the token. The - cookie is read only when no header carried one. - """ - header_token, vary_on = _read_header_token(connection, self.config) - if header_token is None: - vary_on.append("Cookie") - return header_token, vary_on - - def _response_tokens( - self, - backend_session: "Session | None", - loaded_session_id: str | None, - loaded_token: str | None, - renewed_token: str | None, - ) -> tuple[str | None, str | None]: - """The ``(current, fresh)`` tokens to answer with. - - Normally the loaded token and the sliding-renewed one (if any). If the - handler regenerated the session ID (at login, say), the loaded token - names a deleted record, so both become a token for the new ID and the - client gets it on either transport. - """ - if backend_session is None or backend_session.session_id == loaded_session_id: - return loaded_token, renewed_token - token = self.session_manager.issue_token(backend_session) - return token, token - - def _emit_token( - self, headers: MutableHeaders, token: str, *, from_header: bool - ) -> None: - """Send a session token to the client via its transport. - - Args: - headers: Mutable response headers to write to - token: Session token string to deliver - from_header: If True, echo via the configured response header; - otherwise (re)set it as a Set-Cookie header. - """ - if from_header: - headers.append(self.config.header_name, token) - else: - headers.append("Set-Cookie", self._build_set_cookie_header(token)) - - async def _write_session( - self, - session: StarletteSession, - connection: HTTPConnection, - backend_session: "Session | None", - loaded_token: str | None, - renewed_token: str | None, - ) -> tuple[str | None, str | None]: - """Create-or-update the backend session for a modified dict. - - The dict is non-empty, or was emptied on a session that has a user. - - Args: - session: The Starlette session dict for this request - connection: Incoming HTTP connection (for IP / User-Agent binding) - backend_session: The session the dict belongs to (loaded, or - started by ``login()``), or None to create an anonymous one - loaded_token: Token for the loaded session (None when creating anew) - renewed_token: Sliding-expiration renewed token, if any - - Returns: - ``(cookie_token, new_token)`` where ``cookie_token`` is the token to - (re)set as a cookie, and ``new_token`` is a genuinely new/renewed token - to hand a header-based client (``None`` when the token is unchanged). - Both are ``None`` when the save was dropped. - """ - if backend_session is None: - ( - backend_session, - loaded_token, - ) = await self.session_manager.create_anonymous_session( - ip_address=get_client_ip(connection, self.config), - user_agent=connection.headers.get("user-agent"), - ) - new_token: str | None = loaded_token - else: - # backend_session and loaded_token are always set together (after a - # successful get_session() call). - assert loaded_token is not None # noqa: S101 - new_token = renewed_token - backend_session.data = dict(session) - if not await self.session_manager.update_session(backend_session): - # Another request changed, deleted or invalidated the session since - # this one read it (#128). The save is dropped. A renewal that - # get_session() already stored is still sent while the session is - # valid: a JWT client would otherwise keep a token that expires - # before the record does. Otherwise no token is sent. - if new_token is not None and await self._still_valid(backend_session): - return new_token, new_token - return None, None - return loaded_token, new_token - - async def _still_valid(self, session: "Session") -> bool: - """Whether the backend still holds a valid session under this ID.""" - current = await self.session_manager._load_session(session.session_id) # noqa: SLF001 - return current is not None and current.is_valid() - - def _build_set_cookie_header(self, token: str) -> str: - """Build a `Set-Cookie` header value carrying the session token. - - Args: - token: Signed session token string - - Returns: - Set-Cookie header value - """ - max_age = ( - f"Max-Age={self.config.cookie_max_age}; " - if self.config.cookie_max_age - else "" - ) - return ( - f"{self.config.cookie_name}={token}; " - f"path={self.config.cookie_path}; " - f"{max_age}" - f"{self._security_flags}" - ) - - def _build_clear_cookie_header(self) -> str: - """Build a `Set-Cookie` header value that expires the session cookie. - - Returns: - Set-Cookie header value - """ - return ( - f"{self.config.cookie_name}=; " - f"path={self.config.cookie_path}; " - f"expires=Thu, 01 Jan 1970 00:00:00 GMT; " - f"{self._security_flags}" - ) - - -async def _log_in( - connection: HTTPConnection, - request_session: _RequestSession, - user: "SessionUser", - keep: "Collection[str] | None" = None, -) -> "Session": - """Attach ``user`` to the request's session under a new session ID. - - The public entry point is ``fastapi_cachex.session.login()``, which - documents the behaviour and checks that ``request_session`` belongs to - ``FastAPICacheXSessionMiddleware``. - - Args: - connection: The request being handled - request_session: The middleware's ``request.session`` for it - user: The user to attach - keep: The ``request.session`` keys to carry into the logged-in - session, or None for all of them - - Returns: - The logged-in session - """ - middleware = request_session.middleware - assert middleware is not None # noqa: S101 - checked by login() - manager = middleware.session_manager - current = request_session.backend - if request_session.cleared: - # clear() already logged the loaded session out. Delete it now, since - # the flag that would have deleted it is reset below, and log in on a - # new session. - if current is not None: - await manager.delete_session(current.session_id) - current = None - request_session.cleared = False - elif ( - current is not None - and current.user is not None - and current.user.user_id != user.user_id - ): - # Another user's session: nothing in it belongs to the new user. Delete - # it and empty the request dict (without marking a logout), so the new - # session starts with only what the handler writes after login(). - await manager.delete_session(current.session_id) - current = None - dict.clear(request_session) - - if keep is not None: - # pop() marks the dict modified, so the middleware saves what is left. - for key in [key for key in request_session if key not in keep]: - request_session.pop(key) - if current is not None: - # Rotation stores ``current`` under the new ID: keep the dropped - # data out of that record too. - current.data = {k: v for k, v in current.data.items() if k in keep} - - if current is None: - current, _ = await manager.create_session( - user, - ip_address=get_client_ip(connection, middleware.config), - user_agent=connection.headers.get("user-agent"), - ) - else: - # Attach the user before the rotation, so the record under the old ID - # never holds it. - current._attach_user(user) # noqa: SLF001 - try: - await manager.regenerate_session_id(current) - except SessionError: - # Another request ended the loaded session while this one ran - # (#128). Its data went with it: log in on a new session that - # starts with only what the handler writes after login(). - dict.clear(request_session) - current, _ = await manager.create_session( - user, - ip_address=get_client_ip(connection, middleware.config), - user_agent=connection.headers.get("user-agent"), - ) - - # The session is already stored with the user. Its ID differs from the one - # the middleware loaded (if any), so the middleware sends a token for it - # and saves ``request.session`` into it if the handler writes there. - request_session.backend = current - connection.scope.setdefault("state", {})["__fastapi_cachex_session"] = current - return current diff --git a/fastapi_cachex/session/models.py b/fastapi_cachex/session/models.py deleted file mode 100644 index d56e437..0000000 --- a/fastapi_cachex/session/models.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Session data models and user structures.""" - -import logging -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from enum import Enum -from typing import TYPE_CHECKING -from typing import Any -from uuid import uuid4 - -from pydantic import BaseModel -from pydantic import Field -from pydantic import PrivateAttr - -from fastapi_cachex.types import CacheEntry - -logger = logging.getLogger(__name__) - - -class SessionStatus(str, Enum): - """Session status enumeration.""" - - ACTIVE = "active" - EXPIRED = "expired" - INVALIDATED = "invalidated" - - -class SessionUser(BaseModel): - """Base session user model. - - This can be extended by application-specific user models. - """ - - user_id: str - username: str | None = None - email: str | None = None - roles: list[str] = Field(default_factory=list) - permissions: list[str] = Field(default_factory=list) - metadata: dict[str, Any] = Field(default_factory=dict) - - model_config = {"extra": "allow"} - - -class Session(BaseModel): - """Core session model containing all session data. - - ``user`` is read-only (#256): a user is attached only by - ``login(request, user)`` under the session middleware, which always issues - a new session ID, or by ``SessionManager.create_session(user=...)``, which - starts a new session. Assigning it raises ``AttributeError``, so a session - whose ID an attacker may know cannot be promoted to a logged-in one in - place. - """ - - session_id: str = Field(default_factory=lambda: str(uuid4())) - user: SessionUser | None = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - last_accessed: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - expires_at: datetime | None = None - status: SessionStatus = SessionStatus.ACTIVE - ip_address: str | None = None - user_agent: str | None = None - data: dict[str, Any] = Field(default_factory=dict) - flash_messages: list[dict[str, Any]] = Field(default_factory=list) - - model_config = {"use_enum_values": True} - - # The backend entry this object was last read from or written as. An - # ordinary save stores only while the backend still holds it (#128); None - # means the object was never read or written, so the record must not exist. - _stored: CacheEntry | None = PrivateAttr(default=None) - - # Hidden from type checkers: a visible __setattr__ would make them accept - # assignment to any attribute name, typos included. - if not TYPE_CHECKING: # pragma: no branch - - def __setattr__(self, name: str, value: Any) -> None: - """Refuse to assign ``user``; every other field is assigned as usual.""" - if name == "user": - msg = ( - "Session.user is read-only: log a user in with " - "login(request, user) under FastAPICacheXSessionMiddleware, or " - "start a session with SessionManager.create_session(user=...) " - "(https://github.com/allen0099/FastAPI-CacheX/issues/256)" - ) - raise AttributeError(msg) - super().__setattr__(name, value) - - def __eq__(self, other: object) -> bool: - """Compare the fields only, not which backend entry each copy last saw.""" - if not isinstance(other, Session): - return NotImplemented - return ( - type(self) is type(other) - and self.__dict__ == other.__dict__ - and self.__pydantic_extra__ == other.__pydantic_extra__ - ) - - # Mutable, like every pydantic model that is not frozen. - __hash__ = None # type: ignore[assignment] - - def _attach_user(self, user: SessionUser) -> None: - """Set ``user``, for ``login()`` only, which rotates the ID right after.""" - super().__setattr__("user", user) - - def is_valid(self) -> bool: - """Check if session is valid (active and not expired).""" - if self.status != SessionStatus.ACTIVE: - return False - - return not (self.expires_at and datetime.now(timezone.utc) > self.expires_at) - - def is_expired(self) -> bool: - """Check if session has expired.""" - if self.expires_at is None: - return False - return datetime.now(timezone.utc) > self.expires_at - - def update_last_accessed(self) -> None: - """Update the last accessed timestamp.""" - self.last_accessed = datetime.now(timezone.utc) - logger.debug("Session last_accessed updated; id=%s", self.session_id) - - def renew(self, ttl: int) -> None: - """Renew session expiry time. - - Args: - ttl: Time-to-live in seconds - """ - self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl) - self.update_last_accessed() - logger.debug("Session renewed; id=%s ttl=%s", self.session_id, ttl) - - def invalidate(self) -> None: - """Mark session as invalidated.""" - self.status = SessionStatus.INVALIDATED - logger.debug("Session invalidated; id=%s", self.session_id) - - def regenerate_id(self) -> str: - """Regenerate session ID (for security after login). - - Returns: - The new session ID - """ - old_id = self.session_id - self.session_id = str(uuid4()) - logger.debug( - "Session ID regenerated; old_id=%s new_id=%s", old_id, self.session_id - ) - return self.session_id - - def add_flash_message(self, message: str, category: str = "info") -> None: - """Add a flash message. - - Args: - message: The message content - category: Message category (info, success, warning, error) - """ - self.flash_messages.append( - { - "message": message, - "category": category, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - ) - logger.debug( - "Flash message added; id=%s category=%s", self.session_id, category - ) - - def get_flash_messages(self, clear: bool = True) -> list[dict[str, Any]]: - """Get and optionally clear flash messages. - - Args: - clear: Whether to clear messages after retrieving - - Returns: - List of flash messages - """ - messages = self.flash_messages.copy() - if clear: - self.flash_messages.clear() - logger.debug( - "Flash messages cleared; id=%s count=%s", self.session_id, len(messages) - ) - return messages - - -class SessionToken(BaseModel): - """Session token containing signed data.""" - - session_id: str - signature: str - issued_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - expires_at: datetime | None = None diff --git a/fastapi_cachex/session/proxy.py b/fastapi_cachex/session/proxy.py deleted file mode 100644 index 7c1a020..0000000 --- a/fastapi_cachex/session/proxy.py +++ /dev/null @@ -1,9 +0,0 @@ -"""FastAPI CacheX Proxy for session manager management.""" - -from fastapi_cachex.proxy import ProxyBase - -from .manager import SessionManager - - -class SessionManagerProxy(ProxyBase[SessionManager]): - """FastAPI CacheX Proxy for session manager management.""" diff --git a/fastapi_cachex/session/security.py b/fastapi_cachex/session/security.py deleted file mode 100644 index 8149483..0000000 --- a/fastapi_cachex/session/security.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Security utilities for session management.""" - -import hashlib -import hmac -import logging - -from .models import Session - -logger = logging.getLogger(__name__) - - -class SecurityManager: - """Handles session security operations.""" - - def __init__(self, secret_key: str) -> None: - """Initialize security manager. - - Args: - secret_key: Secret key for signing tokens - """ - if len(secret_key) < 32: # noqa: PLR2004 - msg = "Secret key must be at least 32 characters" - raise ValueError(msg) - self._secret_key_bytes = secret_key.encode("utf-8") - - logger.debug( - "SecurityManager initialized with secret length=%s", len(secret_key) - ) - - def sign_session_id(self, session_id: str) -> str: - """Sign a session ID using HMAC-SHA256. - - Args: - session_id: The session ID to sign - - Returns: - The signature as a hex string - """ - return hmac.new( - self._secret_key_bytes, - session_id.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - - def verify_signature(self, session_id: str, signature: str) -> bool: - """Verify a session signature. - - Args: - session_id: The session ID - signature: The signature to verify - - Returns: - True if signature is valid, False otherwise - """ - expected_signature = self.sign_session_id(session_id) - # Compare as bytes, in constant time. `compare_digest` refuses str - # arguments that are not ASCII-only, and the signature here comes - # straight off the wire — Starlette decodes header bytes as latin-1, so - # a token carrying a non-ASCII signature used to raise TypeError and - # escape the middleware's SessionError handler as a 500. - valid = hmac.compare_digest( - expected_signature.encode("utf-8"), - signature.encode("utf-8"), - ) - - if not valid: - logger.debug("Signature verification failed; id=%s", session_id) - return valid - - def check_ip_match(self, session: Session, current_ip: str | None) -> bool: - """Check if session IP matches current request IP. - - Args: - session: The session to check - current_ip: Current request IP address - - Returns: - True if IPs match or session has no IP binding - """ - if session.ip_address is None: - return True - if current_ip is None: - return False - return session.ip_address == current_ip - - def check_user_agent_match( - self, - session: Session, - current_user_agent: str | None, - ) -> bool: - """Check if session User-Agent matches current request. - - Args: - session: The session to check - current_user_agent: Current request User-Agent - - Returns: - True if User-Agents match or session has no UA binding - """ - if session.user_agent is None: - return True - if current_user_agent is None: - return False - return session.user_agent == current_user_agent - - def hash_data(self, data: str) -> str: - """Hash data using SHA-256. - - Args: - data: Data to hash - - Returns: - Hex digest of the hash - """ - digest = hashlib.sha256(data.encode("utf-8")).hexdigest() - - logger.debug("Data hashed for session operations") - return digest diff --git a/fastapi_cachex/session/token_serializers.py b/fastapi_cachex/session/token_serializers.py deleted file mode 100644 index 54499a1..0000000 --- a/fastapi_cachex/session/token_serializers.py +++ /dev/null @@ -1,268 +0,0 @@ -"""Token serializer strategies for session tokens. - -Provides a simple serializer compatible with the existing -"session_id.signature.timestamp" format, and an optional JWT serializer. - -The JWT serializer supports dependency injection to allow swapping in -compatible JWT backends (e.g., PyJWT or another library exposing -``encode``/``decode`` with similar signatures). -""" - -from __future__ import annotations - -import importlib -import logging -from datetime import datetime -from datetime import timezone -from typing import TYPE_CHECKING -from typing import Any -from typing import Protocol - -from .config import JWT_HMAC_ALGORITHMS -from .models import SessionToken - -if TYPE_CHECKING: # Import for typing only to avoid circular import concerns - from .config import SessionConfig - -logger = logging.getLogger(__name__) - -# Token format constant - 3 parts: session_id, signature, timestamp -TOKEN_PARTS_COUNT = 3 - -# RFC 7518 section 3.2: an HMAC key must be at least as long as the hash output. -_HMAC_MIN_KEY_BYTES = {"HS256": 32, "HS384": 48, "HS512": 64} - - -class TokenSerializer(Protocol): - """Protocol for token serialization strategies.""" - - def to_string(self, token: SessionToken) -> str: # pragma: no cover - Protocol body - """Serialize a `SessionToken` to a string.""" - - def from_string( - self, token_str: str - ) -> SessionToken: # pragma: no cover - Protocol body - """Parse a string into a `SessionToken` (with necessary verification). - - Raise ``ValueError`` for an invalid token; ``SessionManager`` turns it - into ``SessionTokenError``. - """ - - -class SimpleTokenSerializer: - """Serializer for the default simple token format.""" - - def to_string(self, token: SessionToken) -> str: - """Convert token to string format. - - Format: {session_id}.{signature}.{timestamp} - - Args: - token: SessionToken instance to serialize - - Returns: - Token string in format {session_id}.{signature}.{timestamp} - """ - timestamp = int(token.issued_at.timestamp()) - - logger.debug("SimpleTokenSerializer to_string called; id=%s", token.session_id) - return f"{token.session_id}.{token.signature}.{timestamp}" - - def from_string(self, token_str: str) -> SessionToken: - """Parse token from string format. - - Args: - token_str: Token string in format {session_id}.{signature}.{timestamp} - - Returns: - SessionToken instance - - Raises: - ValueError: If token format is invalid - """ - parts = token_str.split(".") - if len(parts) != TOKEN_PARTS_COUNT: - msg = "Invalid token format" - raise ValueError(msg) - - session_id, signature, timestamp = parts - try: - issued_at = datetime.fromtimestamp(int(timestamp), tz=timezone.utc) - except (ValueError, OSError, OverflowError) as e: - msg = f"Invalid timestamp in token: {e}" - raise ValueError(msg) from e - - logger.debug("SimpleTokenSerializer parsed from string; id=%s", session_id) - return SessionToken( - session_id=session_id, signature=signature, issued_at=issued_at - ) - - -def _check_key_length(secret: str, algorithm: str) -> None: - """Reject ``secret`` if it is shorter than ``algorithm``'s hash output. - - ``SessionConfig`` only requires 32 characters, enough for HS256 but not - for HS384 (48 bytes) or HS512 (64 bytes). 0.3.x warned; 0.4.0 refuses - such a key when the serializer is built (#129). - - Raises: - ValueError: If ``secret`` is shorter in UTF-8 bytes than the hash output - """ - min_bytes = _HMAC_MIN_KEY_BYTES[algorithm] - key_bytes = len(secret.encode("utf-8")) - if key_bytes < min_bytes: - msg = ( - f"secret_key is {key_bytes} bytes, shorter than the {min_bytes} " - f"bytes RFC 7518 section 3.2 requires for {algorithm}. Use a longer " - f"secret_key (e.g. secrets.token_urlsafe({min_bytes})) or " - f'jwt_algorithm="HS256" ' - f"(https://github.com/allen0099/FastAPI-CacheX/issues/129)." - ) - raise ValueError(msg) - - -class JWTTokenSerializer: - """JWT-based token serializer. - - Encodes the session reference into a signed JWT with claims: - - sid: session id (custom claim) - - iat: issued at (epoch seconds) - - exp: expiry (epoch seconds), the session's ``expires_at`` (so it follows - sliding renewal and the ``absolute_timeout`` cap), or iat + - config.session_ttl when the session has none - Optionally: - - iss: issuer (if configured) - - aud: audience (if configured) - """ - - def __init__(self, config: SessionConfig, jwt_module: Any | None = None) -> None: - """Initialize a JWT-based serializer with optional backend injection. - - Args: - config: Session configuration instance. - jwt_module: Optional JWT-compatible module providing ``encode`` and - ``decode``; defaults to importing ``jwt`` (PyJWT). - - Raises: - ValueError: If ``config.jwt_algorithm`` is asymmetric. This - serializer signs and verifies with the ``secret_key`` string, - which only the HMAC algorithms can use; an asymmetric - algorithm needs a custom ``token_serializer`` that holds the - key pair. Also if ``secret_key`` is shorter in UTF-8 bytes - than the HMAC hash output (48 for HS384, 64 for HS512). - ImportError: If no ``jwt_module`` is given and PyJWT (the ``jwt`` - extra) is not installed. - """ - if config.jwt_algorithm not in JWT_HMAC_ALGORITHMS: - supported = ", ".join(sorted(JWT_HMAC_ALGORITHMS)) - msg = ( - f"jwt_algorithm {config.jwt_algorithm!r} needs a private/public " - f"key pair, but the built-in JWT serializer signs with secret_key; " - f"use one of {supported}, or pass a custom token_serializer to " - f"SessionManager" - ) - raise ValueError(msg) - _check_key_length(config.secret_key.get_secret_value(), config.jwt_algorithm) - - if jwt_module is not None: - self.jwt_encoder = jwt_module - else: - try: - self.jwt_encoder = importlib.import_module("jwt") - except ImportError as e: - msg = "JWT backend not available; install fastapi-cachex[jwt] or inject jwt_module" - raise ImportError(msg) from e - - # Copy required parameters - self._secret = config.secret_key.get_secret_value() - self._algorithm = config.jwt_algorithm - self._issuer = config.jwt_issuer - self._audience = config.jwt_audience - self._leeway = config.jwt_leeway - self._session_ttl = config.session_ttl - - def to_string(self, token: SessionToken) -> str: - """Encode a `SessionToken` as a signed JWT string. - - Uses claims `sid`, `iat`, `exp`, and optional `iss`/`aud`. - """ - iat = int(token.issued_at.timestamp()) - # Use session's expires_at when available (supports sliding expiration) - if token.expires_at is not None: - exp = int(token.expires_at.timestamp()) - else: - exp = iat + int(self._session_ttl) - - payload: dict[str, object] = { - "sid": token.session_id, - "iat": iat, - "exp": exp, - } - if self._issuer: - payload["iss"] = self._issuer - if self._audience: - payload["aud"] = self._audience - - encoded = self.jwt_encoder.encode( - payload, self._secret, algorithm=self._algorithm - ) - logger.debug("JWT token encoded; sid=%s", token.session_id) - # PyJWT may return str in PyJWT>=2, ensure str - return str(encoded) - - def from_string(self, token_str: str) -> SessionToken: - """Decode and verify a JWT string into a `SessionToken`. - - Verifies signature, `exp`, and `iat`, and optional `iss`/`aud`. - - Raises: - ValueError: If the token fails decoding or verification, or its - payload is malformed. - """ - options = { - "require": ["sid", "iat", "exp"], - "verify_signature": True, - "verify_exp": True, - "verify_iat": True, - } - - kwargs: dict[str, object] = { - "algorithms": [self._algorithm], - "options": options, - "leeway": self._leeway, - "key": self._secret, - } - - if self._issuer: - kwargs["issuer"] = self._issuer - if self._audience: - kwargs["audience"] = self._audience - - try: - payload = self.jwt_encoder.decode(token_str, **kwargs) - except Exception as e: # Broad catch to normalize to ValueError - logger.debug("JWT decode failed: %s", e) - msg = "Invalid JWT token" - raise ValueError(msg) from e - - try: - raw_sid = payload["sid"] - raw_iat = payload["iat"] - except Exception as e: - msg = "Invalid JWT payload" - raise ValueError(msg) from e - - # Normalize types for mypy and runtime safety - sid = raw_sid if isinstance(raw_sid, str) else str(raw_sid) - if isinstance(raw_iat, int): - iat = raw_iat - else: - try: - iat = int(raw_iat) - except Exception as e: - msg = "Invalid JWT payload" - raise ValueError(msg) from e - - issued_at = datetime.fromtimestamp(iat, tz=timezone.utc) - # For JWT path, signature is not used in downstream verification - return SessionToken(session_id=sid, signature="", issued_at=issued_at) diff --git a/fastapi_cachex/state/__init__.py b/fastapi_cachex/state/__init__.py deleted file mode 100644 index 44cc25c..0000000 --- a/fastapi_cachex/state/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""State management extension for FastAPI-CacheX. - -Deprecated in 0.4.0 and removed in 0.5.0 (#420): importing this package -emits a ``FutureWarning``. -""" - -from fastapi_cachex._deprecation import STATE_DEPRECATION as _STATE_DEPRECATION -from fastapi_cachex._deprecation import warn_deprecated as _warn_deprecated - -from .dependencies import StateManagerDep as StateManagerDep -from .dependencies import get_state_manager as get_state_manager -from .exceptions import InvalidStateError as InvalidStateError -from .exceptions import StateDataError as StateDataError -from .exceptions import StateError as StateError -from .exceptions import StateExpiredError as StateExpiredError -from .manager import StateManager as StateManager -from .models import StateData as StateData -from .proxy import StateManagerProxy as StateManagerProxy - -_warn_deprecated(_STATE_DEPRECATION) diff --git a/fastapi_cachex/state/dependencies.py b/fastapi_cachex/state/dependencies.py deleted file mode 100644 index 3301b7f..0000000 --- a/fastapi_cachex/state/dependencies.py +++ /dev/null @@ -1,27 +0,0 @@ -"""FastAPI dependency injection utilities for state management.""" - -from typing import Annotated - -from fastapi import Depends - -from .manager import StateManager -from .proxy import StateManagerProxy - - -def get_state_manager() -> StateManager: - """Dependency to get the application StateManager instance. - - Lazily creates and registers a default StateManager (backed by - BackendProxy) the first time it's requested, unless one was already - set via StateManagerProxy.set(...). Concurrent first calls share one - instance. - - Unlike `AppCache`, it does not fall back to a `MemoryBackend`: OAuth - states must be readable by whichever worker handles the callback, so with - no backend configured it raises `BackendNotFoundError` and registers - nothing. - """ - return StateManagerProxy.get_or_create(StateManager) - - -StateManagerDep = Annotated[StateManager, Depends(get_state_manager)] diff --git a/fastapi_cachex/state/exceptions.py b/fastapi_cachex/state/exceptions.py deleted file mode 100644 index 6a73fb4..0000000 --- a/fastapi_cachex/state/exceptions.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Custom exception classes for state management.""" - -from fastapi_cachex.exceptions import CacheXError - - -class StateError(CacheXError): - """Base exception for state-related errors.""" - - -class InvalidStateError(StateError): - """Raised when a state is invalid or not found, or its binding does not match.""" - - -class StateExpiredError(StateError): - """Raised when a state has expired.""" - - -class StateDataError(StateError): - """Raised when state data parsing or format is invalid.""" diff --git a/fastapi_cachex/state/manager.py b/fastapi_cachex/state/manager.py deleted file mode 100644 index 17679b4..0000000 --- a/fastapi_cachex/state/manager.py +++ /dev/null @@ -1,313 +0,0 @@ -"""State manager for one-time OAuth state tokens.""" - -import hashlib -import hmac -import json -import logging -import secrets -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from typing import Any - -from fastapi_cachex.backends.base import BaseCacheBackend -from fastapi_cachex.backends.base import validate_ttl -from fastapi_cachex.proxy import BackendProxy -from fastapi_cachex.types import CacheEntry -from fastapi_cachex.types import log_ref - -from .exceptions import InvalidStateError -from .exceptions import StateDataError -from .exceptions import StateExpiredError -from .models import StateData - -logger = logging.getLogger(__name__) - -# Default TTL for OAuth state (10 minutes) -DEFAULT_STATE_TTL = 600 - - -def _is_past(moment: datetime) -> bool: - return datetime.now(timezone.utc) > moment - - -def _state_ref(state: str) -> str: - """Return a short digest that identifies a state in logs without revealing it. - - The state comes straight from the callback query string, so logging it raw - would leak live tokens and let a caller forge log lines with CR/LF. - """ - return log_ref(state) - - -def _binding_hash(binding: str) -> str: - return hashlib.sha256(binding.encode("utf-8", "surrogatepass")).hexdigest() - - -def _binding_matches(stored: str | None, binding: str | None) -> bool: - """Whether ``binding`` is the one a state was created with. - - An unbound state matches only an absent binding and a bound one only its own, - so neither side can drop the check by leaving its binding out. - """ - if stored is None or binding is None: - return stored is None and binding is None - return hmac.compare_digest(stored, _binding_hash(binding)) - - -def _log_decode_failure(state: str) -> None: - # No traceback: pydantic validation errors echo the stored input, which - # includes the state itself. - logger.warning( - "Stored OAuth state data is malformed; state_ref=%s", _state_ref(state) - ) - - -class StateManager: - """Manages the lifecycle and storage of one-time OAuth state tokens.""" - - def __init__( - self, - backend: BaseCacheBackend | None = None, - key_prefix: str = "oauth_state:", - default_ttl: int = DEFAULT_STATE_TTL, - ) -> None: - """Initialize StateManager. - - Args: - backend: Cache backend instance. If None, uses BackendProxy.get(). - key_prefix: Prefix for state keys in cache backend - default_ttl: Default time-to-live in seconds for state - - Raises: - BackendNotFoundError: If ``backend`` is None and no backend has - been set with ``BackendProxy.set()``. - TypeError: If ``default_ttl`` is not an ``int`` (a float or bool - is rejected). - ValueError: If ``default_ttl`` is zero, negative or above - ``MAX_TTL``. - """ - self.backend = backend if backend is not None else BackendProxy.get() - self.key_prefix = key_prefix - self.default_ttl = validate_ttl(default_ttl) - - def _cache_key(self, state: str) -> str: - return f"{self.key_prefix}{state}" - - def _decode_state(self, cached: CacheEntry) -> StateData: - """Turn a backend entry into a StateData model. - - Nothing is logged here; the caller logs the failure once. - - Args: - cached: The CacheEntry retrieved from backend - - Returns: - StateData instance - - Raises: - StateDataError: If the content is not UTF-8 text, not a JSON object, - or does not fit the StateData model - """ - try: - json_content = cached.content.decode("utf-8") - except (AttributeError, UnicodeDecodeError) as e: - msg = "Unexpected state data format" - raise StateDataError(msg) from e - - # ValueError covers JSONDecodeError and an integer longer than - # sys.int_info.default_max_str_digits. - try: - state_dict: object = json.loads(json_content) - except ValueError as e: - msg = f"Failed to parse state data: {e}" - raise StateDataError(msg) from e - - if not isinstance(state_dict, dict): - msg = f"Invalid state data structure: expected a JSON object, got {type(state_dict).__name__}" - raise StateDataError(msg) - - try: - return StateData(**state_dict) - except ValueError as e: - msg = f"Invalid state data structure: {e}" - raise StateDataError(msg) from e - - async def _peek_state(self, state: str) -> StateData | None: - """Load a state without consuming it; None when missing, malformed or expired.""" - cached = await self.backend.get(self._cache_key(state)) - if cached is None: - logger.debug("State not found; state_ref=%s", _state_ref(state)) - return None - - try: - state_data = self._decode_state(cached) - except StateDataError: - _log_decode_failure(state) - return None - - if _is_past(state_data.expires_at): - logger.debug("State expired; state_ref=%s", _state_ref(state)) - return None - - return state_data - - async def create_state( - self, - ttl: int | None = None, - metadata: dict[str, Any] | None = None, - *, - binding: str | None = None, - ) -> str: - """Create a new random OAuth state and store it with metadata. - - Args: - ttl: Time-to-live in seconds (uses default_ttl if not provided) - metadata: Additional metadata to store with the state (e.g., callback_url, user_info) - binding: A secret tied to the client that starts the flow, such as a - random nonce also set as a cookie. ``consume_state()`` then - accepts the state only with the same binding, so a state issued - to one browser cannot complete the flow in another (login - CSRF). Only its SHA-256 is stored. - - Returns: - The generated state string - - Raises: - TypeError: If ``ttl`` is not an ``int`` (a float or bool is - rejected). - ValueError: If ``ttl`` is zero, negative or above ``MAX_TTL``, or - ``binding`` is empty. - - Backend errors (for example a Redis connection error) propagate - unchanged; they are not wrapped in ``StateDataError``. - """ - # Generate a random state string (32 bytes = 256 bits of entropy) - state = secrets.token_urlsafe(32) - - # Use provided TTL or default - effective_ttl = validate_ttl(ttl if ttl is not None else self.default_ttl) - if binding == "": - # A missing cookie read as "" would otherwise bind every such - # client to the same value. - msg = "binding must not be empty" - raise ValueError(msg) - - # Create state data model - state_data = StateData( - state=state, - expires_at=datetime.now(timezone.utc) + timedelta(seconds=effective_ttl), - metadata=metadata or {}, - binding_hash=None if binding is None else _binding_hash(binding), - ) - - content = state_data.model_dump_json().encode("utf-8") - entry = CacheEntry( - fingerprint=hashlib.sha256(content).hexdigest(), content=content - ) - await self.backend.set(self._cache_key(state), entry, ttl=effective_ttl) - - logger.debug( - "OAuth state created; state_ref=%s ttl=%s", _state_ref(state), effective_ttl - ) - return state - - async def consume_state( - self, state: str, *, binding: str | None = None - ) -> StateData: - """Consume and validate an OAuth state, removing it from storage. - - Args: - state: The state string to validate and consume - binding: The binding the state was created with, if any. A state - created with a binding is accepted only with that binding, and - one created without is rejected when a binding is given. The - state is consumed either way. - - Returns: - StateData object containing state data and metadata - - Raises: - InvalidStateError: If state is invalid or not found, or the binding - does not match - StateExpiredError: If state has expired - StateDataError: If state data format is invalid - - Backend errors propagate unchanged; on Memcached, a value replaced by - other writers 16 times in a row raises ``CacheXError``. - """ - # Take the state out of the backend atomically: of several concurrent - # callers presenting the same state exactly one gets the entry, so a - # replayed callback can never be accepted twice. An entry that turns - # out to be malformed or past its wall-clock expiry is gone as well. - cached = await self.backend.get_and_delete(self._cache_key(state)) - if cached is None: - logger.info( - "OAuth state not found or expired; state_ref=%s", _state_ref(state) - ) - msg = "Invalid or expired state" - raise InvalidStateError(msg) - - try: - state_data = self._decode_state(cached) - except StateDataError: - _log_decode_failure(state) - raise - - if _is_past(state_data.expires_at): - logger.info("OAuth state expired; state_ref=%s", _state_ref(state)) - msg = "State has expired" - raise StateExpiredError(msg) - - if not _binding_matches(state_data.binding_hash, binding): - logger.info( - "OAuth state presented with a different binding; state_ref=%s", - _state_ref(state), - ) - msg = "State was issued to a different client" - raise InvalidStateError(msg) - - logger.debug( - "OAuth state consumed and deleted; state_ref=%s", _state_ref(state) - ) - return state_data - - async def validate_state(self, state: str) -> bool: - """Validate if a state exists and is not expired (without consuming it). - - Args: - state: The state string to validate - - Returns: - True if state is valid and not expired, False otherwise - """ - return await self._peek_state(state) is not None - - async def get_state_metadata(self, state: str) -> dict[str, Any] | None: - """Retrieve metadata for a state without consuming it. - - Args: - state: The state string - - Returns: - Metadata dictionary if state exists and is valid, None otherwise - """ - state_data = await self._peek_state(state) - return None if state_data is None else state_data.metadata - - async def delete_state(self, state: str) -> bool: - """Manually delete a state from storage. - - Args: - state: The state string to delete - - Returns: - True if state was deleted, False if it didn't exist - """ - if await self.backend.get_and_delete(self._cache_key(state)) is None: - logger.debug( - "OAuth state not found for deletion; state_ref=%s", _state_ref(state) - ) - return False - logger.debug("OAuth state deleted; state_ref=%s", _state_ref(state)) - return True diff --git a/fastapi_cachex/state/models.py b/fastapi_cachex/state/models.py deleted file mode 100644 index 0d5e5ec..0000000 --- a/fastapi_cachex/state/models.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Data models for state management.""" - -from datetime import datetime -from datetime import timezone -from typing import Any - -from pydantic import BaseModel -from pydantic import ConfigDict -from pydantic import Field -from pydantic import field_serializer - - -class StateData(BaseModel): - """OAuth state data model.""" - - model_config = ConfigDict() - - state: str = Field(..., description="The unique state identifier") - created_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), - description="When the state was created", - ) - expires_at: datetime = Field(..., description="When the state expires") - metadata: dict[str, Any] = Field( - default_factory=dict, description="Additional metadata associated with state" - ) - binding_hash: str | None = Field( - default=None, - description=( - "SHA-256 of the binding the state was created with, or None for an " - "unbound state" - ), - ) - - @field_serializer("created_at", "expires_at", when_used="json") - def serialize_datetime(self, value: datetime) -> str: - """Serialize datetime to ISO format string for JSON.""" - return value.isoformat() diff --git a/fastapi_cachex/state/proxy.py b/fastapi_cachex/state/proxy.py deleted file mode 100644 index 58411df..0000000 --- a/fastapi_cachex/state/proxy.py +++ /dev/null @@ -1,9 +0,0 @@ -"""FastAPI CacheX Proxy for state manager management.""" - -from fastapi_cachex.proxy import ProxyBase - -from .manager import StateManager - - -class StateManagerProxy(ProxyBase[StateManager]): - """FastAPI CacheX Proxy for StateManager instance management.""" diff --git a/fastapi_cachex/types.py b/fastapi_cachex/types.py index a20d0ed..03848f9 100644 --- a/fastapi_cachex/types.py +++ b/fastapi_cachex/types.py @@ -48,9 +48,8 @@ def log_ref(value: str) -> str: """Return a short digest that identifies ``value`` in logs without revealing it. Cache keys carry the raw query string, ``vary`` header values and custom - key components, and OAuth states come from the callback query string; - logging them at ``WARNING`` would leak tokens, e-mail addresses or user - IDs into application logs. The same value always gives the same digest, + key components; logging them at ``WARNING`` would leak tokens, e-mail + addresses or user IDs into application logs. The same value always gives the same digest, so a warning can still be matched to the full value logged at ``DEBUG``. """ return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()[:12] diff --git a/i18n/zh-TW/GLOSSARY.md b/i18n/zh-TW/GLOSSARY.md index 5a6ac73..7afad12 100644 --- a/i18n/zh-TW/GLOSSARY.md +++ b/i18n/zh-TW/GLOSSARY.md @@ -72,5 +72,6 @@ | production | 正式環境 | | | callback(OAuth) | 回呼(callback) | | | extra | 保留 | 套件的選用依賴,例:`redis` extra | +| (dependency) floor / lower bound | 最低版本 | 例:`fastapi` 的最低版本 | | deprecated | 已棄用 | | | breaking change | 破壞性變更 | | diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md index cceacbf..b7ddff3 100644 --- a/i18n/zh-TW/docs/APP_CACHE.md +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -71,13 +71,13 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*" - `get_or_set()` 預設使用 cache stampede 保護(`lock=True`,可針對單次呼叫或以 `CacheManager(lock=...)` 全域設定),避免多次並行未命中時同時執行 `factory`,若等待超時則具備直接計算的優雅降級回退。分散式鎖的鍵名格式為 `lock:`(預設為 `lock:cache:user:42`)。 - `get_or_set()` 在未命中與命中時都回傳經 JSON 解碼後的值(見 [JSON 往返](#json-round-trip)),因此兩條路徑的結果相同。 - `add()` 只在鍵尚未被占用時寫入值,並回傳是否有寫入。檢查與寫入是同一個後端原子操作(`set_if_absent`),因此適合「每個鍵只做一次」的工作,例如 webhook 或電子郵件的去重。已過期的鍵視為未被占用;存放無法解碼之值的鍵則不算,即使 `get()` 會把它當成未命中。 -- 鍵預設位於獨立、以 `cache:` 為前綴的命名空間,與 HTTP 路由快取及 OAuth state 分開,因此 `clear()`/`clear_prefix()` 絕不會動到無關的快取項目。 -- 前綴是以單純的字串前綴比對。因此 `key_prefix="cache:"` 的 manager 也會清除 `key_prefix="cache:users:"` 的 manager 的項目;而空的 `key_prefix` 會讓 `clear()` 移除後端中的所有內容,包括 HTTP 回應、鎖、OAuth state 與 Session。請讓每個 manager 的前綴都不以另一個 manager 的前綴開頭。 +- 鍵預設位於獨立、以 `cache:` 為前綴的命名空間,與 HTTP 路由快取及鎖分開,因此 `clear()`/`clear_prefix()` 絕不會動到無關的快取項目。 +- 前綴是以單純的字串前綴比對。因此 `key_prefix="cache:"` 的 manager 也會清除 `key_prefix="cache:users:"` 的 manager 的項目;而空的 `key_prefix` 會讓 `clear()` 移除後端中的所有內容,包括 HTTP 回應與鎖。請讓每個 manager 的前綴都不以另一個 manager 的前綴開頭。 - `clear_pattern(pattern)` 只把 `pattern` 當成 glob;`key_prefix` 一律照字面比對。前綴不含 glob 特殊字元(`*`、`?`、`[`、`]`、`\`)時,會把 `key_prefix + pattern` 交給後端的 `clear_pattern()`(Redis `SCAN MATCH`),`pattern` 採用後端的 glob 語法。前綴含有這些字元時(例如 `cache[1]:`),無法把它當成 glob 傳給後端,因此 `clear_pattern()` 會以 `get_all_keys()` 列出所有鍵,保留以該前綴開頭、且其餘部分以 `fnmatch.fnmatchcase` 符合 `pattern` 的鍵,再以 `delete_many()` 刪除。這在 Redis 上較慢,而且此時 `pattern` 採用 fnmatch 語法而非 Redis glob:區分大小寫、不支援反斜線跳脫,否定用 `[!a]` 而非 `[^a]`。以這種前綴建立 `CacheManager` 時會發出 `UserWarning`;請改用不含 `*?[]\` 的前綴以維持快速路徑。 - `AppCache` 依賴項在第一次使用時會建立並註冊一個預設的 `CacheManager`;`CacheManagerProxy.set()` 則可改為註冊你自己的實例。 > [!NOTE] -> `CacheManager.clear()`/`clear_prefix()` 是以後端的 `get_all_keys()` 與 `delete_many()` 實作(在 Redis 上是每批 100 個鍵的 `DEL`)。由於 Memcached 不支援列舉鍵(見[後端](BACKENDS.md#memcached)),這些方法以及 `CacheManager.clear_pattern()` 在 Memcached 後端上不會有任何作用,只會回傳 0 並發出 `RuntimeWarning`;`get()`/`set()`/`add()`/`delete()`/`has()` 則照常運作。若需要大量清除,請使用 Redis 或記憶體後端。不要在 Memcached 上改用後端本身的 `clear()`:`MemcachedBackend.clear()` 會發出 `flush_all`,清空整台伺服器,包括 HTTP 回應、Session、鎖以及其他應用程式的鍵。 +> `CacheManager.clear()`/`clear_prefix()` 是以後端的 `get_all_keys()` 與 `delete_many()` 實作(在 Redis 上是每批 100 個鍵的 `DEL`)。由於 Memcached 不支援列舉鍵(見[後端](BACKENDS.md#memcached)),這些方法以及 `CacheManager.clear_pattern()` 在 Memcached 後端上不會有任何作用,只會回傳 0 並發出 `RuntimeWarning`;`get()`/`set()`/`add()`/`delete()`/`has()` 則照常運作。若需要大量清除,請使用 Redis 或記憶體後端。不要在 Memcached 上改用後端本身的 `clear()`:`MemcachedBackend.clear()` 會發出 `flush_all`,清空整台伺服器,包括 HTTP 回應、鎖以及其他應用程式的鍵。 ### 在 Memcached 上讓一組鍵失效 {#group-invalidation-on-memcached} diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 9dddd85..272e75c 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -1,6 +1,6 @@ # 後端 {#backends} -所有快取,包括 HTTP 回應、`CacheManager` 的值、Session 與 OAuth state,都存放在同一個後端,並在啟動時以 `BackendProxy.set()` 註冊一次。 +所有快取,包括 HTTP 回應、`CacheManager` 的值與 `CacheLock` 的鎖,都存放在同一個後端,並在啟動時以 `BackendProxy.set()` 註冊一次。 ## 選擇後端 {#choosing-a-backend} @@ -113,7 +113,7 @@ BackendProxy.set(backend) - 不重試時,單一暫時性錯誤(例如指令執行到一半時連線被重設)會讓該指令失敗。`Retry(NoBackoff(), 1)` 會立即重試一次。 - `socket_timeout` 也會限制較慢的回覆。兩個逾時都應設定得比你平常的 Redis 延遲高,包括快取最大項目時的延遲。 -- 這些設定適用於後端送出的所有指令,而不只是 `@cache`。`CacheManager`、`StateManager`、`CacheLock` 與 Session 不會 fail open,而是拋出後端錯誤,它們也會更早收到這些錯誤。 +- 這些設定適用於後端送出的所有指令,而不只是 `@cache`。`CacheManager` 與 `CacheLock` 不會 fail open,而是拋出後端錯誤,它們也會更早收到這些錯誤。 `RedisConfig` 沒有 `retry` 欄位,因此請將它傳給建構函式。 @@ -199,11 +199,11 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): ``` - `increment(key, delta=1, ttl=None) -> int`:記憶體後端在鎖內執行讀取—修改—寫入,Redis 執行 Lua 腳本(`EXISTS` + `INCRBY` + `EXPIRE`),Memcached 則使用 `ADD` + `INCR`/`DECR`(Memcached 的計數器最低停在 0)。Memcached 以整秒計時,因此 `ttl` 很短的新計數器可能在 `ADD` 與 `INCR` 之間就過期;此時 Memcached 會重試 `ADD` + `INCR`,從 `delta` 開始新的時間窗,只有連續 16 次嘗試計數器都消失時才拋出 `CacheXError`。計數器可透過 `get()` 讀到,形式為 fingerprint 為 `COUNTER_FINGERPRINT`、內容為十進位數值的 `CacheEntry`,因此 `delete`/`clear*` 與監控路由都會把它當成一般項目處理。對存放其他內容的鍵執行 increment,在每個後端上都會拋出 `CacheXError`,即使是本文剛好是數字的快取回應也一樣。以 `set(key, counter_entry(n))` 寫入的計數器在每個後端上都可以 increment,前提是 `n` 在伺服器計數器的範圍內:Redis 的計數器是 signed 64 位元,因此在它上面 `n` 必須介於 -2**63 到 2**63 - 1 之間;Memcached 的計數器沒有正負號,因此在它上面 `n` 必須介於 0 到 2**64 - 1 之間。對超出該範圍的計數器執行 increment 會拋出 `CacheXError`。記憶體後端與基底類別的後備實作同樣使用 signed 64 位元的範圍。在每個後端上,結果會超出範圍的 increment 都會拋出 `CacheXError`,並保持計數器不變(0.4.1 之前,Redis 會拋出它自己的 `ResponseError`,Memcached 會繞回成很小的數字,記憶體後端則會無限制地增長)。Memcached 是在 `INCR` 之後才偵測到繞回,再以第二個 `INCR` 復原,因此在兩者之間對同一個鍵執行的 increment 或 decrement 可能會看到繞回後的值。`delta` 必須是 signed 64 位元範圍內的 `int`,否則會在存取後端之前拋出 `TypeError` 或 `ValueError`。 -- `get_and_delete(key) -> CacheEntry | None`:記憶體後端在鎖內 pop,Redis 使用 `GETDEL`(伺服器 6.2 以上),Memcached 使用 `GETS` + `exptime=-1` 的 `CAS` 寫入(若中間有其他寫入者替換了值則會重試;連續 16 次都被替換時會拋出 `CacheXError`,而不是當成鍵不存在)。`StateManager.consume_state`、`StateManager.delete_state`、`CacheManager.delete` 與 `invalidate()` 都建立在它之上。 +- `get_and_delete(key) -> CacheEntry | None`:記憶體後端在鎖內 pop,Redis 使用 `GETDEL`(伺服器 6.2 以上),Memcached 使用 `GETS` + `exptime=-1` 的 `CAS` 寫入(若中間有其他寫入者替換了值則會重試;連續 16 次都被替換時會拋出 `CacheXError`,而不是當成鍵不存在)。`CacheManager.delete` 與 `invalidate()` 都建立在它之上。 - `set_if_absent(key, value, ttl=None) -> bool`:只在 `key` 不存在時儲存 `value`(已過期的鍵視為不存在),並回報是否有寫入。記憶體後端在鎖內檢查,Redis 使用 `SET NX EX`,Memcached 使用 `ADD`。 - `delete_if_equals(key, expected) -> bool`:只在 `key` 仍存放 `expected` 時才移除它,因此項目已過期的持有者無法釋放已被他人取得的鎖。請在你儲存的項目中放入唯一的權杖,並以同一個項目釋放。記憶體後端在鎖內比較,Redis 透過 Lua 腳本刪除,並在腳本中重新檢查先前比較過的值,Memcached 則使用 `GETS` + 一個讓項目立即過期的 `CAS` 寫入(傳統協定的 `DELETE` 不接受 CAS 權杖)。 - `expire_if_equals(key, expected, ttl) -> bool`:只在 `key` 仍存放 `expected` 時,才把它的 TTL 更新為 `ttl` 秒,因此長時間執行的鎖持有者可以續約租期,而不會在鎖已過期時動到別人的鎖。記憶體後端在鎖內更新,Redis 先在 Python 中比較,再執行 Lua 腳本(`GET` 比較 + `EXPIRE`),Memcached 則使用 `GETS` + 以新 exptime 寫回相同位元組的 `CAS`(`TOUCH` 不接受 CAS 權杖)。 -- `set_if_equals(key, expected, value, ttl=None) -> bool`:只在 `key` 仍存放 `expected` 時才儲存 `value`。這是 compare-and-set:若呼叫端讀取之後有任何操作變更、刪除了該鍵,或它已過期,寫入就會失敗。Session 透過它儲存(見 [Session 寫入](MIGRATING_0_4.md#session-writes))。記憶體後端在鎖內比較,Redis 先在 Python 中比較,再執行 Lua 腳本(`GET` 比較 + `SET`,設定了 `ttl` 時加上 `EX`),Memcached 則使用 `GETS` + 寫入新值的 `CAS`。 +- `set_if_equals(key, expected, value, ttl=None) -> bool`:只在 `key` 仍存放 `expected` 時才儲存 `value`。這是 compare-and-set:若呼叫端讀取之後有任何操作變更、刪除了該鍵,或它已過期,寫入就會失敗。記憶體後端在鎖內比較,Redis 先在 Python 中比較,再執行 Lua 腳本(`GET` 比較 + `SET`,設定了 `ttl` 時加上 `EX`),Memcached 則使用 `GETS` + 寫入新值的 `CAS`。 這六個方法在 `BaseCacheBackend` 上都有非原子性的後備實作,因此只實作抽象方法的第三方後端仍可正常運作;覆寫它們才能得到真正的原子性。這些後備實作依賴 `delete()` 回傳鍵是否存有項目;仍像 0.3.x 一樣回傳 `None` 的 `delete()` 會發出警告,並在 0.5.0 之前視為已移除(見 [delete() 的回傳值](MIGRATING_0_4.md#backend-delete))。 @@ -211,7 +211,7 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): ## TTL 值 {#ttl-values} -每個 `ttl` 參數(`set`、`set_if_absent`、`set_if_equals`、`increment`,以及建立在它們之上的 `CacheManager`、`CacheLock` 與 `StateManager` 方法和預設值)只能是 `None`(表示項目永不過期),或介於 1 到 `MAX_TTL`(2**31 - 1,約 68 年)之間的秒數,型別可以是 `int` 或整數秒的 `datetime.timedelta`(`timedelta(minutes=5)` 會存成 `300`)。這些檢查都在存取後端之前進行: +每個 `ttl` 參數(`set`、`set_if_absent`、`set_if_equals`、`increment`,以及建立在它們之上的 `CacheManager` 與 `CacheLock` 方法和預設值)只能是 `None`(表示項目永不過期),或介於 1 到 `MAX_TTL`(2**31 - 1,約 68 年)之間的秒數,型別可以是 `int` 或整數秒的 `datetime.timedelta`(`timedelta(minutes=5)` 會存成 `300`)。這些檢查都在存取後端之前進行: - 零、負值與更大的值會拋出 `ValueError`。底層儲存對 `0` 的解讀各不相同:Memcached 把 exptime `0` 視為「永不過期」,Redis 拒絕 `EX 0`,而行程內的 dict 則會立即讓項目過期。 - `float`、`bool` 或其他型別會拋出 `TypeError`。float 過去只在記憶體後端上有效,而 `True` 會被當成一秒。帶有小數秒的 `timedelta`(`timedelta(seconds=1.5)`)會拋出 `ValueError`,因為每個後端都以整秒計時。 diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 860ffd9..2550f06 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -14,12 +14,12 @@ Cache-Control 標頭) no-store? ── 是 → 執行 handler,既不讀取也不寫入快取, │ 回應帶上 Cache-Control: no-store ↓ 否 -private、沒有正數的 ttl,或帶有 Authorization/Session 且未設定 public/cache_authorized? +private、沒有正數的 ttl,或帶有 Authorization/Session 資料且未設定 public/cache_authorized? ── 是 → 執行 handler;比對 If-None-Match 決定回傳 304 或 200 │ (共用後端既不讀取也不寫入,key builder 也不會執行; - │ Authorization 或 Session 的情況下 Cache-Control 以 private 取代 public) + │ Authorization 或 Session 資料的情況下 Cache-Control 以 private 取代 public) ↓ 否 -(設定 cache_authorized 且帶有 Authorization 或 Session:下方照常使用後端, +(設定 cache_authorized 且帶有 Authorization 或 Session 資料:下方照常使用後端, 但每個回應仍以 private 取代 public) ↓ 建立快取鍵:key_builder(預設為 http:v2|method|host|path|query_params), @@ -89,7 +89,7 @@ cache_key = "|".join( 方法、host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25`(`fastapi_cachex/types.py` 中的 `escape_key_component`)。host 與路徑來自用戶端,其中若出現未編碼的 `|`,各段就會錯位,使某個請求的快取鍵可能與另一個請求相同。查詢字串本來就經過 URL 編碼,因此不會含有 `|`。監控路由顯示時會再解碼各段。 -自訂的 `key_builder` 可以用 `build_cache_key(request, *components)` 在查詢字串之後加入其他段;這些段以同樣方式編碼,`clear_path()` 也仍會比對路徑(見 [HTTP 快取](HTTP_CACHING.md#adding-components-to-the-key)中的「在鍵中加入其他段」)。`@cache(vary=[...])` 會在 key builder 回傳的鍵之後,為每個列出的請求標頭附加一個 `name=value` 段,並把這些名稱加入回應的 `Vary` 標頭(見 [HTTP 快取](HTTP_CACHING.md#varying-on-request-headers)中的「依請求標頭區分」)。對於憑證標頭 `Authorization`、`Proxy-Authorization`、`Cookie` 與 `X-Session-Token`,非空的值會寫成 `sha256:<十六進位摘要>`,因此鍵中不會出現任何權杖。 +自訂的 `key_builder` 可以用 `build_cache_key(request, *components)` 在查詢字串之後加入其他段;這些段以同樣方式編碼,`clear_path()` 也仍會比對路徑(見 [HTTP 快取](HTTP_CACHING.md#adding-components-to-the-key)中的「在鍵中加入其他段」)。`@cache(vary=[...])` 會在 key builder 回傳的鍵之後,為每個列出的請求標頭附加一個 `name=value` 段,並把這些名稱加入回應的 `Vary` 標頭(見 [HTTP 快取](HTTP_CACHING.md#varying-on-request-headers)中的「依請求標頭區分」)。對於憑證標頭 `Authorization`、`Proxy-Authorization` 與 `Cookie`,非空的值會寫成 `sha256:<十六進位摘要>`,因此鍵中不會出現任何權杖。 查詢參數會依名稱**排序**(穩定排序:同名參數的多個值保留送出的順序),因此 `?page=1&limit=10` 與 `?limit=10&page=1` 共用同一個快取項目。`@cache(sort_query=False)` 則保留請求送出的順序(見 [HTTP 快取](HTTP_CACHING.md#cache-keys)中的「快取鍵」)。超過 200 位元組的查詢接著會改寫為 `sha256:<十六進位摘要>`,讓鍵中查詢的部分維持有限長度。 @@ -111,9 +111,9 @@ cache_key = "|".join( # 一般快取行為 @cache(ttl=3600) # 快取 1 小時(也作為 max-age 的值) -@cache(ttl=3600, public=True) # 允許共用快取,帶有 Authorization/Session 的請求也一樣 +@cache(ttl=3600, public=True) # 允許共用快取,帶有 Authorization/Session 資料的請求也一樣 @cache(private=True) # 僅限私有;永遠不接觸共用後端 -@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # 帶有 Authorization/Session 的請求也使用後端,以 private 回應 +@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # 帶有 Authorization/Session 資料的請求也使用後端,以 private 回應 @cache(ttl=3600, immutable=True) # 內容永不改變 # 只影響標頭的指令(不會改變伺服器端行為) @@ -142,7 +142,7 @@ cache_key = "|".join( > 對於回應內容取決於呼叫者的端點,請擇一處理: > > 1. `private=True`:永遠不讀取或寫入共用後端。它仍會送出 `Cache-Control: private`,讓使用者自己的瀏覽器可以快取回應,而且 `If-None-Match` 仍會與新產生的內容比對。 -> 2. 包含身分的自訂 `key_builder`,並搭配 `cache_authorized=True`:當你確實需要以使用者為單位的伺服器端快取時使用。未設定 `cache_authorized` 時,帶有 `Authorization` 標頭或 Session 的請求會繞過後端(見下方說明)。設定之後,回應仍以 `private` 送出,因為下游的共用快取看不到鍵中的身分。 +> 2. 包含身分的自訂 `key_builder`,並搭配 `cache_authorized=True`:當你確實需要以使用者為單位的伺服器端快取時使用。未設定 `cache_authorized` 時,帶有 `Authorization` 標頭或不是空的 `request.session` 的請求會繞過後端(見下方說明)。設定之後,回應仍以 `private` 送出,因為下游的共用快取看不到鍵中的身分。 > > 身分請取自可信任的來源(已驗證的權杖 claim、透過依賴注入取得的使用者物件);不要信任未經檢查的用戶端標頭。 @@ -180,7 +180,7 @@ if no_store: return await render() # 不讀取,不寫入 bypass = private or not ttl -# Authorization 標頭、中介軟體載入的 Session,或不是空的 request.session +# Authorization 標頭,或不是空的 request.session credential = None if private or public else request_credential(request) header = private_header if credential else decorator_header # 用於下方每個回應 if bypass or (credential and not cache_authorized): @@ -238,7 +238,7 @@ return response > 「非 2xx 不寫入」是刻意的設計:暫時性的錯誤不應抹除最後一次正常的快取回應,也不應在之後被當成 200 重播。`206 Partial Content` 同樣不會快取,因為它的內容只對產生它的那個 `Range` 請求有意義。非 2xx 回應也永遠不會以 `304` 回應,且回傳時不帶裝飾器的 `Cache-Control` 標頭(只有 `no_store=True` 會在每個回應加上 `no-store`)。 > [!NOTE] -> **屬於單一呼叫者的回應永遠不會被儲存。** 依照 RFC 9111 §3.5,帶有 `Authorization` 標頭的請求會繞過後端(不讀取也不寫入),帶有 Session 的請求(Session 中介軟體從任何權杖來源載入了 Session,或 `request.session` 不是空的)也一樣,除非路由設定了 `public=True`,或以 `cache_authorized=True` 明確選擇啟用(用於包含已驗證身分的 `key_builder`)。產生回應時,若回應自己的 `Cache-Control` 含有 `private` 或 `no-store`(完整指令,不分大小寫),或回應設定了 cookie,則照常回傳但不寫入。handler 送出的 `private`/`no-store` 標頭會原樣送出,不會被裝飾器的標頭取代。設定 cookie 的回應,以及任何 `Authorization` 或 Session 請求的回應(無論繞過後端,或設定 `cache_authorized` 而由後端回應;200 或 304),會以 `private` 取代 `public` 送出並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓下游的共用快取也不會儲存它們。`must_revalidate=True` 不會解除 `Authorization` 的繞過;雖然 RFC 9111 允許在 `must-revalidate` 下重複使用,本函式庫仍要求明確選擇啟用。該鍵下已儲存的項目保持不變,而在 handler 執行前就命中該項目的請求仍照常由它回應。每次略過都會以 `DEBUG` 等級記錄。0.3.9 之前這類回應會被儲存並重播給每位呼叫者(#296);帶有 Session 的請求在 0.3.9 之前也不會繞過(#319)。 +> **屬於單一呼叫者的回應永遠不會被儲存。** 依照 RFC 9111 §3.5,帶有 `Authorization` 標頭的請求會繞過後端(不讀取也不寫入),帶有不是空的 `request.session`(來自任何 Session 中介軟體)的請求也一樣,除非路由設定了 `public=True`,或以 `cache_authorized=True` 明確選擇啟用(用於包含已驗證身分的 `key_builder`)。產生回應時,若回應自己的 `Cache-Control` 含有 `private` 或 `no-store`(完整指令,不分大小寫),或回應設定了 cookie,則照常回傳但不寫入。handler 送出的 `private`/`no-store` 標頭會原樣送出,不會被裝飾器的標頭取代。設定 cookie 的回應,以及任何帶有 `Authorization` 或 Session 資料的請求的回應(無論繞過後端,或設定 `cache_authorized` 而由後端回應;200 或 304),會以 `private` 取代 `public` 送出並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓下游的共用快取也不會儲存它們。`must_revalidate=True` 不會解除 `Authorization` 的繞過;雖然 RFC 9111 允許在 `must-revalidate` 下重複使用,本函式庫仍要求明確選擇啟用。該鍵下已儲存的項目保持不變,而在 handler 執行前就命中該項目的請求仍照常由它回應。每次略過都會以 `DEBUG` 等級記錄。0.3.9 之前這類回應會被儲存並重播給每位呼叫者(#296);帶有 Session 的請求在 0.3.9 之前也不會繞過(#319)。 ### 4. ETag 產生與驗證 {#4-etag-generation-and-validation} @@ -393,7 +393,7 @@ async def cleanup_task(): | `no_store=True` | 既不讀取也不寫入快取;端點每次都會執行 | | `no_cache=True` | 端點每次都會執行以重新計算 ETag;與用戶端的 `If-None-Match` 相符時仍回傳 304,ETag 改變時會更新快取 | | `private=True` | **共用後端**既不讀取也不寫入;仍會送出 `Cache-Control: private`,並以新產生的內容比對 ETag | -| 帶有 `Authorization` 或 Session 的請求 | 與 `private=True` 一樣,既不讀取也不寫入後端,`Cache-Control` 以 `private` 取代 `public`,除非路由設定了 `public=True` 或 `cache_authorized=True`(`must_revalidate=True` 不算);設定 `cache_authorized=True` 時會使用後端,但 `Cache-Control` 仍帶有 `private` | +| 帶有 `Authorization` 或不是空的 `request.session` 的請求 | 與 `private=True` 一樣,既不讀取也不寫入後端,`Cache-Control` 以 `private` 取代 `public`,除非路由設定了 `public=True` 或 `cache_authorized=True`(`must_revalidate=True` 不算);設定 `cache_authorized=True` 時會使用後端,但 `Cache-Control` 仍帶有 `private` | | handler 送出 `Cache-Control: private`/`no-store` | 回傳時保留 handler 的標頭,不寫入,既有項目也保持不變 | | 回應設定了 cookie | 回傳時 `Cache-Control` 以 `private` 取代 `public`,不寫入,既有項目也保持不變 | | 沒有 `ttl`(或 `ttl=0`) | 與 `private=True` 一樣,既不讀取也不寫入後端;端點每次都會執行,並以新產生的內容比對 ETag | diff --git a/i18n/zh-TW/docs/COMPARISON.md b/i18n/zh-TW/docs/COMPARISON.md index e316a31..fc5ddbe 100644 --- a/i18n/zh-TW/docs/COMPARISON.md +++ b/i18n/zh-TW/docs/COMPARISON.md @@ -56,6 +56,8 @@ FastAPI-CacheX 在 FastAPI 應用程式內快取 HTTP 回應,並正確處理 - 帶 `Authorization` 或 cookie 的請求和其他請求一樣被快取。每個使用者各自不同的端點必須自行把使用者放進快取鍵。 - 它的最新版本發行於 2024 年 7 月。 +要轉換過來,請見[從 fastapi-cache2 遷移](MIGRATING_FROM_FASTAPI_CACHE2.md)。 + ## cashews {#cashews} [cashews](https://github.com/Krukov/cashews) 是非同步 Python 的通用快取工具組,是本頁幾個函式庫中功能最多的。 diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index ddc5a5c..e6e6908 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -47,7 +47,7 @@ async def items(): ... | `no-cache` | `no_cache=True` | :white_check_mark: | 每個請求都執行 handler;回應仍會儲存,`If-None-Match` 相符時回 304。 | | `no-store` | `no_store=True` | :white_check_mark: | 不讀取也不儲存,也不設定 ETag。 | | `private` | `private=True` | :white_check_mark: | 完全不經過後端;每個請求都執行 handler,ETag 重新驗證仍有效。 | -| `public` | `public=True` | :white_check_mark: | 帶有 `Authorization` 或 Session 的請求仍會使用後端(否則會繞過後端)。 | +| `public` | `public=True` | :white_check_mark: | 帶有 `Authorization` 或 Session 資料的請求仍會使用後端(否則會繞過後端)。 | | `immutable` | `immutable=True` | :white_check_mark: | 無(僅寫入標頭)。 | | `must-revalidate` | `must_revalidate=True` | :white_check_mark: | 無(僅寫入標頭)。 | | `stale-while-revalidate` | `stale="revalidate", stale_ttl=N` | :white_check_mark: | 無(僅寫入標頭):伺服器端快取不會回傳過期內容。 | @@ -61,7 +61,7 @@ async def items(): ... `no_store=True` 搭配其他任何快取參數(`ttl`、`stale`、`no_cache`、`public`、`private`、`immutable`、`must_revalidate`)時,套用裝飾器時 `@cache` 會發出指向你 `@cache(...)` 那一行的 `UserWarning`:`no_store` 會覆蓋這些參數,警告會列出它們。 -不帶參數的 `@cache()`,既沒有 `ttl` 也沒有任何指令,不會儲存任何內容,也沒有自己的 `Cache-Control`。它會加上 ETag,以 `304` 回應相符的 `If-None-Match`,並保留 handler 自己的 `Cache-Control`(沒有就不送)。與其他路由一樣,設定 Cookie 的回應,或回應帶有 `Authorization` 或 Session 的請求時,仍會送出 `private`。 +不帶參數的 `@cache()`,既沒有 `ttl` 也沒有任何指令,不會儲存任何內容,也沒有自己的 `Cache-Control`。它會加上 ETag,以 `304` 回應相符的 `If-None-Match`,並保留 handler 自己的 `Cache-Control`(沒有就不送)。與其他路由一樣,設定 Cookie 的回應,或回應帶有 `Authorization` 或 Session 資料的請求時,仍會送出 `private`。 ### 請求的 `Cache-Control` 會被忽略 {#the-requests-cache-control-is-ignored} @@ -104,7 +104,7 @@ GET /items → 200, Cache-Control: max-age=60, Age: 42(儲存後 42 秒送出 屬於單一呼叫者的回應同樣不會被儲存(#296): -- **請求帶有 `Authorization` 或 Session。** 依照 RFC 9111 §3.5 對共用快取的要求,這類請求會像 `private=True` 一樣繞過後端:不讀取也不寫入,handler 照常執行,`If-None-Match` 與新產生的回應比對。回應(以及 304)會以 `private` 取代 `public` 送出,並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓 CDN 或代理也不會儲存它。`public=True` 的路由不受此限。設定 `cache_authorized=True`(給包含呼叫者身分的 key builder 使用的明確選項,見[需驗證身分的端點](#authenticated-endpoints))的路由會為這類請求讀寫後端,但回應仍帶有 `private`:項目只在後端依呼叫者區分,CDN 則只以 URL 為鍵(0.3.9 之前會原樣送出裝飾器的標頭,#372)。`must_revalidate=True` 不會解除繞過:RFC 9111 允許共用快取在 `must-revalidate` 下重複使用這類回應,但本函式庫要求明確選擇啟用。沒有正數 `ttl` 的路由本來就不經過後端,但它對這類請求的回應仍會加上 `private`(0.3.9 之前不會加,#362);`private=True` 的路由本來就會送出 `private`。請求「帶有 Session」是指 `FastAPICacheXSessionMiddleware`(已棄用,0.5.0 移除)為它載入了 Session(權杖來自標頭、Bearer 權杖或 Session Cookie 皆可,有沒有使用者都算),或在任何 Session 中介軟體(包括 Starlette 的)下 `request.session` 不是空的。解析不出 Session 的權杖(偽造、過期)不算,因此無法用來略過快取。0.3.9 之前只有 `Authorization` 會觸發繞過,讀取 Session 的路由只加上 `@cache` 時,會把一位訪客的回應提供給下一位(#319)。會讀取後端的路由第一次繞過時,會以 `WARNING` 等級記錄(見[帶有憑證的請求](#requests-with-credentials))。 +- **請求帶有 `Authorization` 或 Session 資料。** 依照 RFC 9111 §3.5 對共用快取的要求,這類請求會像 `private=True` 一樣繞過後端:不讀取也不寫入,handler 照常執行,`If-None-Match` 與新產生的回應比對。回應(以及 304)會以 `private` 取代 `public` 送出,並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓 CDN 或代理也不會儲存它。`public=True` 的路由不受此限。設定 `cache_authorized=True`(給包含呼叫者身分的 key builder 使用的明確選項,見[需驗證身分的端點](#authenticated-endpoints))的路由會為這類請求讀寫後端,但回應仍帶有 `private`:項目只在後端依呼叫者區分,CDN 則只以 URL 為鍵(0.3.9 之前會原樣送出裝飾器的標頭,#372)。`must_revalidate=True` 不會解除繞過:RFC 9111 允許共用快取在 `must-revalidate` 下重複使用這類回應,但本函式庫要求明確選擇啟用。沒有正數 `ttl` 的路由本來就不經過後端,但它對這類請求的回應仍會加上 `private`(0.3.9 之前不會加,#362);`private=True` 的路由本來就會送出 `private`。請求「帶有 Session 資料」是指在任何 Session 中介軟體(例如 Starlette 的 `SessionMiddleware`)下 `request.session` 不是空的。空的 Session 不算,單獨的 `Cookie` 標頭也不算。0.3.9 之前只有 `Authorization` 會觸發繞過,讀取 Session 的路由只加上 `@cache` 時,會把一位訪客的回應提供給下一位(#319)。0.5.0 之前,已移除的 `FastAPICacheXSessionMiddleware` 載入的 Session(來自 `X-Session-Token`、Bearer 權杖或它的 Cookie)也算,請參閱[遷移至 0.5.0](MIGRATING_0_5.md#cache-session-token)。會讀取後端的路由第一次繞過時,會以 `WARNING` 等級記錄(見[帶有憑證的請求](#requests-with-credentials))。 - **handler 自己的 `Cache-Control` 含有 `private` 或 `no-store`**(完整指令,不分大小寫)。回應照常送出但不儲存,而且 handler 的標頭會原樣送出,不會被裝飾器的標頭取代。 - **回應設定了 cookie。** 回應照常送出(包含 `Set-Cookie`),但不儲存;它(以及 304)會以 `private` 取代 `public` 送出並保留其他指令,讓下游的共用快取也不會儲存它。針對新產生回應的 304 同樣帶有 `Set-Cookie`,handler 的背景任務也照常執行(0.4.1 之前,繞過後端的請求與 `no_cache` 路由的 304 會遺失兩者,#233)。 @@ -170,12 +170,12 @@ async def items() -> list[str]: ... ### 帶有憑證的請求 {#requests-with-credentials} -每個請求都送出 `Authorization` 的單頁應用程式,或每位訪客都有 Session 的網站,在只加上 `@cache` 的路由上完全不會命中快取:每個請求都會繞過後端(見上文)。請依 handler 回傳的內容選擇: +每個請求都送出 `Authorization` 的單頁應用程式,或每位訪客都有 Session 資料的網站,在只加上 `@cache` 的路由上完全不會命中快取:每個請求都會繞過後端(見上文)。請依 handler 回傳的內容選擇: -- **每位使用者得到相同的回應**(商品列表、公開文章):設定 `public=True`。帶有 `Authorization` 或 Session 的請求就會像其他請求一樣讀寫後端。注意 `public=True` 也會把送往下游的標頭改為 `Cache-Control: public, ...`,告訴 CDN 或反向 proxy 即使請求帶有憑證也可以儲存這個回應。只有在這確實成立時才使用它。 +- **每位使用者得到相同的回應**(商品列表、公開文章):設定 `public=True`。帶有 `Authorization` 或 Session 資料的請求就會像其他請求一樣讀寫後端。注意 `public=True` 也會把送往下游的標頭改為 `Cache-Control: public, ...`,告訴 CDN 或反向 proxy 即使請求帶有憑證也可以儲存這個回應。只有在這確實成立時才使用它。 - **回應依使用者而不同**(個人資料、購物車、儀表板):設定 `cache_authorized=True`,並搭配把已驗證的呼叫者身分放進鍵的 `key_builder`,讓每位使用者擁有自己的項目(見[需驗證身分的端點](#authenticated-endpoints))。鍵中沒有身分時,一位使用者的回應會提供給下一位。回應仍以 `private` 送出,因此下游只有使用者自己的瀏覽器會保留一份。若不需要伺服器端快取,兩個選項都不要設定(或使用 `private=True`),只讓瀏覽器快取它。 -為了不讓 0% 的命中率無人察覺,路由第一次因憑證而繞過後端時,會在 `fastapi_cachex.cache` logger 上以 `WARNING` 等級記錄一次,內容包含路由樣板(例如 `'/items/{item_id}'`)、造成繞過的憑證(`Authorization` 標頭、Session 權杖,或不是空的 `request.session` 資料),以及上述兩個選項: +為了不讓 0% 的命中率無人察覺,路由第一次因憑證而繞過後端時,會在 `fastapi_cachex.cache` logger 上以 `WARNING` 等級記錄一次,內容包含路由樣板(例如 `'/items/{item_id}'`)、造成繞過的憑證(`Authorization` 標頭,或不是空的 `request.session` 資料),以及上述兩個選項: ```text @cache bypassed the shared backend for route '/products': the request carried an Authorization header, so the response is not cached and is sent with Cache-Control: private. If the response is the same for every user, set @cache(public=True) (this also sends Cache-Control: public, so shared caches downstream may store it). If it is per user, set cache_authorized=True with a key_builder that puts the verified caller's identity into the key. Logged once per route and credential; each bypass is logged at DEBUG. @@ -227,7 +227,7 @@ async def report(): return await build_report() ``` -這只適用於 `@cache`。`invalidate()`、`CacheManager`、`CacheLock`,以及已棄用的 `StateManager` 與 Session 仍會把後端錯誤拋給呼叫端。 +這只適用於 `@cache`。`invalidate()`、`CacheManager` 與 `CacheLock` 仍會把後端錯誤拋給呼叫端。 ## 快取鍵 {#cache-keys} @@ -333,15 +333,15 @@ async def greeting(request: Request): #### 憑證標頭會雜湊 {#credential-headers-are-hashed} -快取鍵並非機密:`get_all_keys()` 會列出它、`/cached-records` 與 `/cached-hits` 監控路由會顯示它,Redis 或 Memcached 的鍵空間也會原樣儲存它。因此對於攜帶憑證的標頭,也就是 `Authorization`、`Proxy-Authorization`、`Cookie` 與 `X-Session-Token`(已棄用的 Session 子系統預設的 `header_name`),不分大小寫,該段存放的是值(依上述方式去除空白並串接)的完整十六進位 SHA-256,而不是值本身: +快取鍵並非機密:`get_all_keys()` 會列出它、`/cached-records` 與 `/cached-hits` 監控路由會顯示它,Redis 或 Memcached 的鍵空間也會原樣儲存它。因此對於攜帶憑證的標頭,也就是 `Authorization`、`Proxy-Authorization`、`Cookie` 與 `X-Session-Token`(0.5.0 移除的 Session 中介軟體所用的權杖標頭),不分大小寫,該段存放的是值(依上述方式去除空白並串接)的完整十六進位 SHA-256,而不是值本身: ``` http:v2|GET|example.com|/me||authorization=sha256:3f0a…(64 個十六進位字元) ``` -同一個權杖永遠得到同一個摘要,因此會命中自己的項目;兩個不同的權杖則得到兩筆項目。缺少或空白的憑證標頭不會雜湊,而是與其他空標頭一樣維持 `authorization=`,讓所有匿名呼叫者共用一筆項目,鍵也仍看得出這是匿名的那一筆。其他標頭(包括以其他名稱設定的 Session 標頭)都維持可讀;若你的標頭帶有機密,請透過 `key_builder`(自行雜湊)而不是 `vary` 以它作為鍵。 +同一個權杖永遠得到同一個摘要,因此會命中自己的項目;兩個不同的權杖則得到兩筆項目。缺少或空白的憑證標頭不會雜湊,而是與其他空標頭一樣維持 `authorization=`,讓所有匿名呼叫者共用一筆項目,鍵也仍看得出這是匿名的那一筆。其他標頭(包括 `X-API-Key` 這類自訂的權杖標頭)都維持可讀;若你的標頭帶有機密,請透過 `key_builder`(自行雜湊)而不是 `vary` 以它作為鍵。 -`vary=["Authorization"]` 不會解除針對已授權請求的規則(見[需驗證身分的端點](#authenticated-endpoints)):除非路由設定 `public=True` 或傳入 `cache_authorized=True`,帶有 `Authorization` 標頭(或 Session)的請求仍會繞過後端。兩者都沒有設定時,只會儲存匿名的 `authorization=` 那一筆。 +`vary=["Authorization"]` 不會解除針對已授權請求的規則(見[需驗證身分的端點](#authenticated-endpoints)):除非路由設定 `public=True` 或傳入 `cache_authorized=True`,帶有 `Authorization` 標頭(或 Session 資料)的請求仍會繞過後端。兩者都沒有設定時,只會儲存匿名的 `authorization=` 那一筆。 #### `vary=["Cookie"]` 會發出警告 {#varycookie-warns} @@ -350,7 +350,7 @@ http:v2|GET|example.com|/me||authorization=sha256:3f0a…(64 個十六進位 - 讓 `key_builder` 回傳 `build_cache_key(request, <真正重要的那個 Cookie 或使用者 ID>)`,並自行在回應設定 `Vary: Cookie`; - `private=True`,把每位訪客各自的回應交給瀏覽器快取。 -針對單一呼叫者的規則仍然適用:帶有 Cookie 的請求會被快取(只有 `Authorization` 或 Session 會觸發繞過),但設定 Cookie 的回應一律不會儲存,並以 `private` 送出,因此每個請求都會更新 Session Cookie 的路由什麼也不會存。若你確實需要 `vary=["Cookie"]`,請在匯入定義該路由的模組之前,用標準的過濾器關閉這個警告: +針對單一呼叫者的規則仍然適用:帶有 Cookie 的請求會被快取(只有 `Authorization` 或 Session 資料會觸發繞過),但設定 Cookie 的回應一律不會儲存,並以 `private` 送出,因此每個請求都會更新 Session Cookie 的路由什麼也不會存。若你確實需要 `vary=["Cookie"]`,請在匯入定義該路由的模組之前,用標準的過濾器關閉這個警告: ```python import warnings @@ -392,9 +392,9 @@ warnings.filterwarnings("ignore", message="cache vary on Cookie") > 回應內容取決於請求者身分的端點,請擇一處理: > > 1. **`private=True`**:回應永遠不會從共用後端讀取,也不會寫入。`Cache-Control: private` 仍允許使用者自己的瀏覽器快取它,而 `If-None-Match` 重新驗證仍會對新產生的內容運作。 -> 2. **包含呼叫者身分的 key builder**:確實需要依使用者區分的伺服器端快取時使用。不要設定 `private`:`private=True` 會繞過後端,key builder 就永遠不會被使用。呼叫者以 `Authorization` 標頭或 Session 驗證身分時,請傳入 `cache_authorized=True`:沒有它,這類請求同樣會繞過後端。 +> 2. **包含呼叫者身分的 key builder**:確實需要依使用者區分的伺服器端快取時使用。不要設定 `private`:`private=True` 會繞過後端,key builder 就永遠不會被使用。呼叫者以 `Authorization` 標頭或 Session 資料驗證身分時,請傳入 `cache_authorized=True`:沒有它,這類請求同樣會繞過後端。 > -> 若呼叫者以沒有任何 Session 中介軟體載入的 Cookie 驗證身分(例如由你自己的依賴項讀取的權杖 Cookie),沒有任何條件會觸發繞過:只加上 `@cache` 會把第一位呼叫者的回應提供給所有人,請改用上面兩種做法之一。 +> 若呼叫者以沒有任何 Session 中介軟體載入 `request.session` 的 Cookie 驗證身分(例如由你自己的依賴項讀取的權杖 Cookie),沒有任何條件會觸發繞過:只加上 `@cache` 會把第一位呼叫者的回應提供給所有人,請改用上面兩種做法之一。 ```python from fastapi import Request @@ -422,13 +422,13 @@ def per_user_key(request: Request) -> str: @app.get("/me/dashboard") @cache(ttl=60, key_builder=per_user_key, cache_authorized=True) async def my_dashboard(user: CurrentUser): - # 對帶有 `Authorization` 或 Session 的請求,以 + # 對帶有 `Authorization` 或 Session 資料的請求,以 # `Cache-Control: private, max-age=60` 送出:只有這個後端與 # 使用者的瀏覽器會保留一份。 return build_dashboard(user) ``` -依使用者區分的項目只存在於你的後端。應用程式前方的共用快取(CDN、反向代理)只看得到 URL,因此即使設定了 `cache_authorized`,對帶有 `Authorization` 或 Session 的請求的每個回應仍帶有 `private`。若身分改由你自己的 Cookie 提供,沒有任何條件會把請求標記為帶有憑證,裝飾器的標頭會原樣送出:請設定 `private=True`(做法 1),或在共用快取可能儲存它時自行送出 `Vary: Cookie`。 +依使用者區分的項目只存在於你的後端。應用程式前方的共用快取(CDN、反向代理)只看得到 URL,因此即使設定了 `cache_authorized`,對帶有 `Authorization` 或 Session 資料的請求的每個回應仍帶有 `private`。若身分改由你自己的 Cookie 提供,沒有任何條件會把請求標記為帶有憑證,裝飾器的標頭會原樣送出:請設定 `private=True`(做法 1),或在共用快取可能儲存它時自行送出 `Vary: Cookie`。 > [!CAUTION] > key builder 決定了誰能看到誰的資料,因此它讀取的身分必須來自已經驗證過的來源:已檢查權杖中的 claim、你的依賴項解析出的使用者,或驗證中介軟體寫入 `request.state` 的值。 @@ -440,7 +440,7 @@ async def my_dashboard(user: CurrentUser): > > 以原始請求標頭組成的鍵等同於水平權限提升:送出 `X-User-Id: ` 就會拿到該使用者的快取回應。 -key builder 只在 `@cache` 讀取或寫入後端時執行,因此 `no_store=True`、`private=True`、沒有 `ttl` 的路由,以及路由未設定 `public=True` 或 `cache_authorized=True` 時帶有 `Authorization` 或 Session 的請求,都不會呼叫它。0.3.8 之前它仍會被呼叫,但只用於除錯日誌。請讓它不帶副作用。 +key builder 只在 `@cache` 讀取或寫入後端時執行,因此 `no_store=True`、`private=True`、沒有 `ttl` 的路由,以及路由未設定 `public=True` 或 `cache_authorized=True` 時帶有 `Authorization` 或 Session 資料的請求,都不會呼叫它。0.3.8 之前它仍會被呼叫,但只用於除錯日誌。請讓它不帶副作用。 key builder 必須是回傳 `str` 的同步函式,呼叫時不會被 await。`async def` 函式、具有 `async def __call__` 的物件,或包裝上述兩者的 `functools.partial`,都會在套用 `@cache` 時以 `CacheXError` 拒絕;`invalidate()` 也會在存取後端之前拒絕它們。仍然回傳非 `str` 的 builder(例如回傳協程的同步包裝函式)會在請求時拋出 `CacheXError`。`fail_open` 不涵蓋這種情況:這是路由的錯誤,不是後端故障。需要非同步讀取的資料(例如從資料庫取得使用者),請在依賴項或中介軟體中讀取,放到 `request.state` 供 builder 使用。 @@ -547,7 +547,7 @@ add_routes( - `GET {prefix}/cached-hits`:列出每筆快取項目,拆分為方法、主機、路徑與查詢(超過 200 位元組的查詢為 `sha256:<十六進位>`),附上 ETag 與到期時間,另外統計有效與已過期的項目數,以及不重複的快取路徑。它不會計算命中次數。 - `GET {prefix}/cached-records`:列出每筆快取紀錄的大小、到期時間、`media_type`(儲存的回應的媒體類型,沒有時為 `null`),以及在 `include_content_preview=True` 時快取內容前 100 個位元組的預覽。預設 `content_preview` 為 `null`,不會有任何回應本文離開伺服器;鍵、大小與到期時間仍會回報。`content_type` 一律是 `"bytes"`,只為相容而保留;請改讀 `media_type`。 -兩個路由都只列出路由項目(格式為 `http:v2|method|host|path|query` 的鍵);`CacheManager`、Session、state 與鎖的鍵都會略過,未使用 `build_cache_key()` 的 `key_builder` 產生的鍵也一樣。 +兩個路由都只列出路由項目(格式為 `http:v2|method|host|path|query` 的鍵);`CacheManager` 與鎖的鍵都會略過,未使用 `build_cache_key()` 的 `key_builder` 產生的鍵也一樣。 > [!WARNING] > **這些路由本身沒有任何身分驗證。** `include_in_schema=False` 只是讓它們不出現在 OpenAPI 文件中;任何猜到路徑的人都能讀取。它們會暴露整個路由結構(包含查詢字串),設定 `include_content_preview=True` 時還會暴露每個快取回應的開頭。因此 `dependencies` 為必填:請傳入 `dependencies=[Depends(your_auth)]`,或將路由掛載在僅供內部使用的應用程式上。若本機或測試用的應用程式確實要保持開放,請傳入 `dependencies=[]` 明確選擇不設防護。 diff --git a/i18n/zh-TW/docs/JWT_CLAIMS.md b/i18n/zh-TW/docs/JWT_CLAIMS.md deleted file mode 100644 index 39e6f77..0000000 --- a/i18n/zh-TW/docs/JWT_CLAIMS.md +++ /dev/null @@ -1,479 +0,0 @@ -# JWT claims:實作說明與擴充指南 {#jwt-claims-implementation-notes-and-extension-guide} - -> [!WARNING] -> **已棄用。** `fastapi_cachex.session` 在 0.4.0 已棄用,並將在 0.5.0 移除([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420))。匯入時會發出 `FutureWarning`。遷移方向請見[遷移至 0.4.0](MIGRATING_0_4.md#session-state-deprecated)。 - -## 概觀 {#overview} - -FastAPI-CacheX 的 JWT 權杖序列化器只實作了最小的一組 JWT claim,用來安全地承載 Session 權杖。本文件說明: - -1. 為什麼我們不實作完整的 JWT claim(例如 `jti` 與 `nbf`) -2. 目前實作背後的設計考量 -3. 如何以自訂 claim 擴充序列化器 - -實作位於 [`fastapi_cachex/session/token_serializers.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/session/token_serializers.py)。JWT 支援需要安裝選用的 extra:`pip install "fastapi-cachex[jwt]"`。 - -## 目前實作中的 JWT claim {#jwt-claims-in-the-current-implementation} - -### 已實作的標準 claim {#implemented-standard-claims} - -`JWTTokenSerializer` 實作了下列 JWT claim: - -| Claim | 名稱 | 必要 | 驗證 | 說明 | -|-------|------|------|------|------| -| `sid` | Session ID | ✅ | ✅ | 自訂 claim,對應到伺服器端的 Session | -| `iat` | Issued At | ✅ | ✅ | 權杖的發行時間(RFC 7519);若位於未來(超出 `jwt_leeway`)則拒絕 | -| `exp` | Expiration | ✅ | ✅ | 權杖的過期時間:Session 的 `expires_at`(因此會跟著滑動過期,且不會超過 `absolute_timeout`),沒有時退回 `iat + session_ttl` | -| `iss` | Issuer | ⚠️ | ✅ | 權杖發行者(選用;只有設定 `jwt_issuer` 時才會發行並驗證) | -| `aud` | Audience | ⚠️ | ✅ | 預期的受眾(選用;只有設定 `jwt_audience` 時才會發行並驗證) | - -### 相關的 `SessionConfig` 欄位 {#related-sessionconfig-fields} - -| 欄位 | 預設值 | 說明 | -|------|--------|------| -| `token_format` | `"simple"` | 設為 `"jwt"` 以使用 `JWTTokenSerializer` | -| `secret_key` | (必填) | 簽署金鑰,至少 32 個字元;同時用於簽署與驗證 JWT | -| `jwt_algorithm` | `"HS256"` | 簽章演算法;必須是支援的值之一(會拒絕 `none`) | -| `jwt_issuer` | `None` | 預期的 `iss`;設定時會發行並驗證 | -| `jwt_audience` | `None` | 預期的 `aud`;設定時會發行並驗證 | -| `jwt_leeway` | `0` | 驗證 `exp`/`iat` 時容許的誤差秒數 | -| `session_ttl` | `3600` | Session 存活時間(秒);Session 沒有 `expires_at` 時用於計算 `exp` | - -> [!NOTE] -> **非對稱演算法:** `jwt_algorithm` 接受 `HS*`、`RS*`、`ES*`、`PS*` 與 `EdDSA`,但內建的序列化器以單一的 `secret_key` 字串簽署與驗證,因此只支援 HMAC 演算法(`HS256`、`HS384`、`HS512`)。以非對稱演算法建立 `SessionManager` 卻沒有提供自訂序列化器時,會拋出 `ValueError`。若要使用非對稱演算法,請傳入自訂的 `token_serializer`,以私鑰編碼、以對應的公鑰解碼(見[擴充指南](#extension-guide-adding-custom-claims))。 - -### 未實作的標準 claim {#standard-claims-that-are-not-implemented} - -下列 RFC 7519 定義的選用 claim **並未實作**: - -| Claim | 名稱 | 用途 | 未實作的原因 | -|-------|------|------|--------------| -| `jti` | JWT ID | 權杖的唯一識別碼,防止重送攻擊 | 有狀態的 Session 模型已透過伺服器端狀態處理這件事 | -| `nbf` | Not Before | 權杖開始生效的時間 | Session 通常立即生效,不需要延後啟用 | -| `sub` | Subject | 主體識別碼(通常是使用者 ID) | 以自訂的 `sid` claim 表示 Session ID 更清楚 | - -## 設計理由 {#design-rationale} - -### 有狀態 Session 與無狀態 JWT {#stateful-session-vs-stateless-jwt} - -FastAPI-CacheX 採用**有狀態 Session** 模型,與純粹無狀態的 JWT 有根本上的不同: - -``` -┌─────────────────────────────────────────────────────────┐ -│ FastAPI-CacheX Session Model (Stateful) │ -├─────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Client │ JWT │ Server │ │ Redis/ │ │ -│ │ │ ──────> │ │ ────> │ Cache │ │ -│ │ │ (sid) │ │ lookup │ │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -│ │ -│ The JWT carries only the session ID (sid) │ -│ The actual session data is stored server-side │ -│ Revocable instantly (delete the session from cache) │ -└─────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────┐ -│ Traditional Stateless JWT (NOT used by CacheX) │ -├─────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────┐ │ -│ │ Client │ JWT │ Server │ │ -│ │ │ ──────> │ │ │ -│ │ │ (all) │ │ │ -│ └──────────┘ └──────────┘ │ -│ │ -│ The JWT contains all user info and permissions │ -│ The server is stateless and cannot revoke tokens │ -│ Revocation requires jti + a blacklist │ -└─────────────────────────────────────────────────────────┘ -``` - -有效的 JWT 簽章是必要條件,但並不充分:解碼之後,`SessionManager.get_session()` 仍會從後端載入 Session,並在 Session 不存在、不在啟用狀態、已過期、超過 `absolute_timeout`,或未通過 IP/User-Agent 綁定檢查時拒絕它。任何解碼失敗都會以 `SessionTokenError` 拋出,Session 中介軟體(`FastAPICacheXSessionMiddleware`)會將其視為「沒有 Session」。 - -### 為什麼採用有狀態 Session {#why-a-stateful-session} - -#### ✅ 優點 {#advantages} - -1. **立即撤銷** - - `SessionManager.delete_session()`(或 `invalidate_session()`)會立即生效 - - 不需要維護權杖黑名單 - - 不需要 `jti` claim 與黑名單系統 - -2. **保護敏感資料** - - Session 資料(包括使用者資訊)存放在伺服器端 - - JWT 只包含最少的資訊(Session ID) - - 降低 JWT 外洩的影響 - -3. **彈性的 Session 管理** - - 支援滑動過期:Session 續期時,中介軟體會透過請求使用的傳輸方式送出帶有更新後 `exp` 的新權杖:由 `header_name` 指定的回應標頭(預設為 `X-Session-Token`),或對 Cookie 使用 `Set-Cookie` - - 支援即時更新 Session 資料 - - 支援 flash 訊息等功能 - -4. **權杖體積小** - - JWT 只需要承載 `sid` 與時間戳記 - - 網路負擔較小 - - 很適合 API 優先架構中頻繁的請求 - -#### ⚠️ 取捨 {#trade-offs} - -1. **需要後端儲存** - - 需要 Redis、Memcached 或記憶體後端 - - 水平擴展需要共用的快取(例如 Redis 叢集) - -2. **每個請求都需要查詢快取** - - 每個請求多一次快取查詢 - - 但現代的快取系統(Redis)非常快(低於一毫秒) - -### 為什麼不需要某些 claim {#why-some-claims-are-not-needed} - -#### `jti`(JWT ID) {#jti-jwt-id} - -**用途**:為每個 JWT 產生唯一的 ID,用於: - -- 權杖黑名單 -- 防止權杖重送攻擊 -- 追蹤個別權杖 - -**為什麼不需要**: - -```python -# 無狀態 JWT 需要 jti + 黑名單 -jwt_payload = {"jti": "uuid-1234", "user_id": "123", ...} -# 撤銷方式:將 jti 加入黑名單,並在每次驗證時檢查 - -# FastAPI-CacheX 的有狀態 Session -jwt_payload = {"sid": "session-abc123"} -# 撤銷方式:直接從快取中刪除 Session -await session_manager.delete_session("session-abc123") -# 下一個請求查詢快取時找不到 Session,請求會自動被拒絕 -``` - -#### `nbf`(Not Before) {#nbf-not-before} - -**用途**:指定權杖開始生效的時間,用於: - -- 預先發行權杖供日後使用 -- 容忍時鐘偏差 - -**為什麼不需要**: - -- Session 通常在建立後立即生效 -- 若需要延後啟用,應該放在應用程式邏輯中處理 -- `jwt_leeway` 設定已經處理了 `exp` 與 `iat` 的時鐘偏差 - -#### `sub`(Subject) {#sub-subject} - -**用途**:識別權杖的主體(通常是使用者 ID) - -**為什麼改用 `sid`**: - -- `sub` 通常代表**不可變**的使用者識別碼 -- `sid` 代表**可變**的 Session 識別碼 -- `SessionManager.regenerate_session_id()` 會改變 `sid`,而 `user_id` 保持不變 -- `sid` 讓語意更清楚 - -## 擴充指南:加入自訂 claim {#extension-guide-adding-custom-claims} - -如果你的應用程式需要額外的 JWT claim,請撰寫自己的序列化器,並透過 `SessionManager` 的 `token_serializer` 參數傳入實例。任何具有 `to_string(token) -> str` 與 `from_string(token_str) -> SessionToken` 方法的物件(即 `TokenSerializer` 協定)都可以;`from_string()` 遇到無效權杖時應拋出 `ValueError`,`SessionManager` 會將它轉換為 `SessionTokenError`。 - -下面的基底類別做的事與內建的 `JWTTokenSerializer` 相同,並為額外的 claim 留下兩個掛鉤。它從 `SessionConfig` 的公開欄位讀取設定並自行保存,而不是存取 `JWTTokenSerializer` 的私有屬性,因為那些屬性在任何版本都可能改變。它與內建序列化器一樣,在 `to_string()` 中採用 `token.expires_at`,讓 `exp` 持續跟著滑動過期。它是 [`examples/session_jwt_claims.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt_claims.py) 的 `serializer` 部分(程式碼註解為英文)。 - - -```python ---8<-- "examples/session_jwt_claims.py:serializer" -``` - - -PyJWT 預設會驗證簽章、`exp`、`iat` 與(存在時的)`nbf`,並在傳入 `issuer`/`audience` 時驗證 `iss`/`aud`。內建序列化器的兩項檢查在這裡沒有重複:它會拒絕非對稱的 `jwt_algorithm`,並在 `secret_key` 短於 HMAC 輸出長度時拋出 `ValueError`。這個類別同樣以 `secret_key` 簽署,因此請使用 `HS*` 演算法;若要使用非對稱演算法,請在類別中保存私鑰與公鑰,並在 `jwt.encode()` 與 `jwt.decode()` 中使用它們。 - -### 範例 1:加入 `jti` 與 `nbf` {#example-1-adding-jti-and-nbf} - -```python -import uuid -from typing import Any - -from fastapi_cachex.session.models import SessionToken - - -class ExtendedJWTSerializer(CustomClaimsJWTSerializer): - """Adds the jti and nbf claims.""" - - required_claims = ("jti", "nbf") - - def extra_claims(self, token: SessionToken) -> dict[str, Any]: - return { - "jti": str(uuid.uuid4()), # 唯一的權杖 ID - "nbf": int(token.issued_at.timestamp()), # 生效時間 = 發行時間 - } -``` - -### 範例 2:加入多租戶的自訂 claim {#example-2-adding-multi-tenant-custom-claims} - -以下取自 [`examples/session_jwt_claims.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt_claims.py)(程式碼註解為英文): - - -```python ---8<-- "examples/session_jwt_claims.py:multi-tenant" -``` - - -### 使用自訂序列化器 {#using-a-custom-serializer} - -#### 做法 1:傳給 `SessionManager`(建議) {#option-1-pass-it-to-sessionmanager-recommended} - -以下取自 [`examples/session_jwt_claims.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt_claims.py)(程式碼註解為英文): - - -```python ---8<-- "examples/session_jwt_claims.py:setup" -``` - - -提供 `token_serializer` 時,它會取代依 `token_format` 選擇的內建序列化器。但仍請保留 `token_format="jwt"`:若設為 `"simple"`,`SessionManager` 會對解析出的權杖額外執行自己的 HMAC 簽章檢查,而以 JWT 為基礎的序列化器產生的權杖通不過這項檢查。 - -範例使用 `MemoryBackend`,因此不需要伺服器即可執行;任何後端都可以,例如[後端](BACKENDS.md#closing-a-backend)中的 Redis 設定。 - -#### 做法 2:繼承 `SessionManager`(進階) {#option-2-subclass-sessionmanager-advanced} - -```python -from fastapi_cachex.backends.base import BaseCacheBackend -from fastapi_cachex.session import SessionConfig, SessionManager - - -class MultiTenantSessionManager(SessionManager): - """SessionManager with multi-tenant support.""" - - def __init__( - self, backend: BaseCacheBackend, config: SessionConfig, tenant_id: str - ) -> None: - super().__init__( - backend, - config, - token_serializer=MultiTenantJWTSerializer( - config=config, tenant_id=tenant_id - ), - ) - - -# 使用方式 -manager = MultiTenantSessionManager(backend, config, tenant_id="acme-corp") -``` - -不要在建構之後以指派私有屬性的方式替換序列化器;請透過 `token_serializer` 參數傳入,讓管理器在發行與解析權杖時都使用它。 - -## 完整應用程式範例 {#complete-application-example} - -[`examples/session_jwt_claims.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt_claims.py) 把上面的各個部分組合成可執行的應用程式(程式碼註解為英文): - - -```python ---8<-- "examples/session_jwt_claims.py" -``` - - -## 安全性考量 {#security-considerations} - -### 1. 權杖大小 {#1-token-size} - -加入更多 claim 會增加 JWT 的大小,進而影響: - -- 網路負擔 -- Cookie 大小限制(若權杖存放在 Cookie 中,例如使用 `FastAPICacheXSessionMiddleware` 時) -- 效能 - -**建議**:只加入需要的 claim,避免在 JWT 中放入大量資料。 - -### 2. 敏感資料 {#2-sensitive-data} - -不要在 JWT 中存放敏感資料(例如密碼或信用卡號碼): - -- JWT 可以被解碼(它是 base64url 編碼) -- 即使有簽章,內容仍然可以讀取 -- 請改將敏感資料存放在伺服器端的 Session 中 - -### 3. Claim 驗證 {#3-claim-validation} - -一律在 `from_string()` 中驗證自訂 claim: - -```python -# ❌ 錯誤:沒有驗證 -payload = jwt.decode(token_str, self.secret, algorithms=[self.algorithm]) -tenant_id = payload.get("tenant_id") # 可能不存在或無效 - -# ✅ 正確:嚴格驗證 -payload = jwt.decode( - token_str, - self.secret, - algorithms=[self.algorithm], - options={"require": ["sid", "iat", "exp", "tenant_id"]}, -) -if payload["tenant_id"] != self.tenant_id: - raise ValueError("Invalid tenant_id") -``` - -### 4. 金鑰輪替 {#4-key-rotation} - -若要支援金鑰輪替,可以使用 `kid`(Key ID)標頭參數。以下是以 `CustomClaimsJWTSerializer` 為基礎的概略示意;`payload` 與 `kwargs` 的建立方式與它的 `to_string()`、`from_string()` 相同: - -```python -class KeyRotationJWTSerializer(CustomClaimsJWTSerializer): - def __init__( - self, config: SessionConfig, keys: dict[str, str], current_key_id: str - ) -> None: - super().__init__(config) - # 金鑰 ID -> 密鑰。舊金鑰請保留到它簽署的權杖都過期為止。 - self.keys = keys - self.current_key_id = current_key_id - - def to_string(self, token: SessionToken) -> str: - # 以目前的金鑰簽署,並在標頭中註明它 - return jwt.encode( - payload, - self.keys[self.current_key_id], - algorithm=self.algorithm, - headers={"kid": self.current_key_id}, - ) - - def from_string(self, token_str: str) -> SessionToken: - # 從(尚未驗證的)標頭讀取 kid,並挑選對應的金鑰 - kid = jwt.get_unverified_header(token_str).get("kid") - key = self.keys.get(kid) - if key is None: - msg = "Unknown key ID" - raise ValueError(msg) - - payload = jwt.decode(token_str, key, algorithms=[self.algorithm], **kwargs) - # ... -``` - -## 測試建議 {#testing-recommendations} - -為你的自訂序列化器加上測試: - -```python -import jwt -import pytest - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session import SessionConfig, SessionManager, SessionUser -from fastapi_cachex.session.exceptions import SessionTokenError - - -@pytest.mark.asyncio -async def test_custom_claims_included(): - """Custom claims are included in the JWT and the token round-trips.""" - backend = MemoryBackend() - config = SessionConfig(secret_key="a" * 32, token_format="jwt") - - serializer = MultiTenantJWTSerializer( - config=config, - tenant_id="test-tenant", - api_version="v1", - ) - manager = SessionManager(backend, config, token_serializer=serializer) - - user = SessionUser(user_id="u1", username="alice") - session, token = await manager.create_session(user=user) - - # 權杖可以解碼,且帶有自訂 claim - claims = jwt.decode(token, options={"verify_signature": False}) - assert claims["tenant_id"] == "test-tenant" - - # get_session 回傳 (session, renewed_token) - retrieved, _renewed = await manager.get_session(token) - assert retrieved.session_id == session.session_id - - -@pytest.mark.asyncio -async def test_custom_claims_validated(): - """A token whose custom claims fail validation is rejected.""" - backend = MemoryBackend() - config = SessionConfig(secret_key="a" * 32, token_format="jwt") - - # 建立 tenant_id="tenant-1" 的權杖 - manager1 = SessionManager( - backend, - config, - token_serializer=MultiTenantJWTSerializer(config, tenant_id="tenant-1"), - ) - _session, token = await manager1.create_session(user=SessionUser(user_id="u1")) - - # 嘗試以 tenant_id="tenant-2" 驗證它(必須失敗) - manager2 = SessionManager( - backend, - config, - token_serializer=MultiTenantJWTSerializer(config, tenant_id="tenant-2"), - ) - - # 序列化器的 ValueError 會以 SessionTokenError 呈現 - with pytest.raises(SessionTokenError, match="Invalid tenant_id"): - await manager2.get_session(token) -``` - -## 常見問題 {#faq} - -### Q:為什麼預設不實作 `jti`? {#q-why-isnt-jti-implemented-by-default} - -A:`jti` 主要用於撤銷無狀態的 JWT(透過黑名單)。FastAPI-CacheX 使用有狀態 Session,因此直接刪除伺服器端的 Session 資料就能撤銷權杖,不需要另外的黑名單機制。 - -### Q:我需要 `nbf` 嗎? {#q-do-i-need-nbf} - -A:大多數情況下不需要。`nbf` 用於預先發行、但稍後才生效的權杖。如果你的應用程式需要這個功能,我們建議在應用程式邏輯中處理(例如在 `session.data` 中記錄啟用時間),而不是在 JWT 層級處理。 - -### Q:可以不寫程式碼就加入 claim 嗎? {#q-can-i-add-claims-without-writing-code} - -A:目前不行;自訂 claim 需要自訂的 `token_serializer`,例如[擴充指南](#extension-guide-adding-custom-claims)中的類別。未來版本或許會加入像下面這樣的設定選項(這只是假設,目前並不存在,而且 `SessionConfig` 會拒絕未知的欄位): - -```python -SessionConfig( - token_format="jwt", - jwt_custom_claims={"tenant_id": "acme", "version": "v1"}, -) -``` - -不過這會增加複雜度。目前的設計在保持程式碼簡單的同時,已提供足夠的彈性。 - -### Q:自訂 claim 會影響效能嗎? {#q-do-custom-claims-affect-performance} - -A:影響很小。JWT 編碼/解碼的效能主要取決於: - -1. 簽章演算法(HS256 很快) -2. 權杖大小(claim 越多,權杖越大) -3. 網路傳輸(權杖越大,傳輸越多) - -只要不加入大量資料,影響可以忽略不計。 - -### Q:如何在 JWT 中包含使用者權限? {#q-how-do-i-include-user-permissions-in-the-jwt} - -A:我們不建議將權限放在 JWT 中。FastAPI-CacheX 使用有狀態 Session,因此你應該: - -```python -# ✅ 建議:存放在伺服器端的 Session 中 -session.user.roles = ["admin", "editor"] -session.user.permissions = ["read", "write", "delete"] -await manager.update_session(session) - -# ❌ 不建議:放在 JWT claim 中 -# 除非撤銷所有現有的權杖,否則權限變更無法立即生效 -``` - -## 參考資料 {#references} - -- [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) -- [PyJWT 文件](https://pyjwt.readthedocs.io/) -- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) -- [FastAPI-CacheX Session 文件](SESSION.md) - -## 總結 {#summary} - -FastAPI-CacheX 的 JWT 實作專注於**有狀態 Session** 的使用情境,並提供: - -- ✅ **已實作**:基本的 JWT claim(`sid`、`iat`、`exp`、`iss`、`aud`) -- ✅ **已實作**:簽章驗證與過期檢查 -- ✅ **已實作**:可擴充的設計(透過繼承與 `token_serializer` 參數) -- ⚠️ **未實作**:`jti`、`nbf`、`sub`(有狀態 Session 不需要它們) -- 🔧 **可擴充**:開發者可以輕鬆加入自訂 claim(見本文件中的範例) - -這個設計在安全性、效能與彈性之間取得了良好的平衡。如果你的應用程式有特殊需求,請參考本文件中的擴充範例。 diff --git a/i18n/zh-TW/docs/LOCK.md b/i18n/zh-TW/docs/LOCK.md index 7d02a9e..e26d8b2 100644 --- a/i18n/zh-TW/docs/LOCK.md +++ b/i18n/zh-TW/docs/LOCK.md @@ -40,7 +40,7 @@ if not await lock.acquire(): - **TTL 過期**:若工作花費的時間超過 `ttl` 且沒有續約,鎖的項目會在後端過期並被釋出。此時其他行程或容器就能在原本的程式碼仍在執行時取得這把鎖。原持有者之後呼叫 `extend()` 或 `release()` 會安全地回傳 `False`,而不會拋出錯誤。請選擇比預期工作時間更長的 `ttl`,或在長時間執行的操作中定期呼叫 `extend()`。 - **TTL 續約(`extend`)**:`extend(ttl)` 只在鎖仍由這個持有者實例擁有時才更新鍵的 TTL,避免在已過期的鎖上發生競爭條件。 - **每次取得使用一個實例**:單一 `CacheLock` 實例會追蹤自己目前的持有狀態。重複進入同一個 `CacheLock` 實例,或在並行的 task 之間共用它,都會拋出 `RuntimeError`。每次取得鎖時請建立新的 `CacheLock` 實例。 -- **命名空間**:鎖的鍵預設位於獨立的 `lock:` 前綴下(例如 `lock:report:123`),與 `cache:` 及 `oauth_state:` 分開。 +- **命名空間**:鎖的鍵預設位於獨立的 `lock:` 前綴下(例如 `lock:report:123`),與 `CacheManager` 的 `cache:` 分開。 - **後端**:未傳入 `backend=` 時,鎖會使用以 `BackendProxy.set()` 註冊的後端。與 `@cache` 不同,它不會改用 `MemoryBackend`:尚未註冊任何後端時,`acquire()` 會拋出 `BackendNotFoundError`。鎖只能排除共用同一個後端的行程,因此每個行程各自一份的 `MemoryBackend` 只能協調同一個行程內的 task。 > [!NOTE] diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index 251c7fe..d080d56 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -52,6 +52,9 @@ filterwarnings = [ `fastapi_cachex.session` 與 `fastapi_cachex.state` 在 0.4.0 已棄用,並將在 0.5.0 移除([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420)、[#421](https://github.com/allen0099/FastAPI-CacheX/issues/421))。FastAPI-CacheX 的範圍將收斂到 HTTP 快取與應用層快取。Session 處理與 OAuth state 都牽涉安全性,由專門的函式庫維護會更好。由於 0.3.9 沒有預告這項變更,兩個套件在整個 0.4.x 期間都能繼續使用,但只會收到安全性修正。 +> [!NOTE] +> 0.5.0 已移除這兩個套件,請參閱[遷移至 0.5.0](MIGRATING_0_5.md#session-state-removed)。下方的遷移方向表仍然適用。 + 匯入其中任一個套件,或從 `fastapi_cachex` 讀取它們的名稱(例如 `fastapi_cachex.SessionConfig`),都會發出 `FutureWarning`,並指向匯入的那一行。單純 `import fastapi_cachex` 不會發出警告,`@cache`、`CacheManager`、`CacheLock` 與各後端也不會。Session 與 state 的名稱已不在 `fastapi_cachex.__all__` 中,因此 `from fastapi_cachex import *` 不再提供它們。遷移完成前,請以名稱個別匯入。 遷移方向: @@ -170,7 +173,7 @@ async def profile(session: AuthenticatedSession): ... ### SessionMiddleware {#session-middleware} -只使用標頭的 `SessionMiddleware` 會被移除([#69](https://github.com/allen0099/FastAPI-CacheX/issues/69));0.3.x 已經會發出 `DeprecationWarning`。請改用 `FastAPICacheXSessionMiddleware`,它同樣讀取標頭與 `Authorization: Bearer` 權杖,並提供 `request.session`。它也會對沒有送出權杖的用戶端送出 Session Cookie,因此請依照 [Session Cookie](#session-cookie) 設定 Cookie 選項。另請參閱 [Session 管理](SESSION.md#migration-sessionmiddleware-fastapicachexsessionmiddleware)。 +只使用標頭的 `SessionMiddleware` 會被移除([#69](https://github.com/allen0099/FastAPI-CacheX/issues/69));0.3.x 已經會發出 `DeprecationWarning`。請改用 `FastAPICacheXSessionMiddleware`,它同樣讀取標頭與 `Authorization: Bearer` 權杖,並提供 `request.session`。它也會對沒有送出權杖的用戶端送出 Session Cookie,因此請依照 [Session Cookie](#session-cookie) 設定 Cookie 選項。另請參閱 [0.4.1 文件中的 Session 管理](https://github.com/allen0099/FastAPI-CacheX/blob/v0.4.1/i18n/zh-TW/docs/SESSION.md#migration-sessionmiddleware-fastapicachexsessionmiddleware)。 ```python # 修改前 @@ -340,7 +343,7 @@ await backend.clear_pattern("GET|||*") # 在 0.4.0 的鍵格式下為 "http:v2| ### delete() 的回傳值 {#backend-delete} -0.4.0 起 `BaseCacheBackend.delete()` 回傳是否移除了鍵,而不是 `None`([#71](https://github.com/allen0099/FastAPI-CacheX/issues/71)),基底類別的非原子性後備實作也會使用這個結果:`delete_many()` 改為計算實際存在的鍵,而不是嘗試刪除的鍵;`get_and_delete()` 與 `delete_if_equals()` 只在呼叫者的刪除確實移除了鍵時才算成功。仍宣告 `-> None` 的第三方後端無法通過型別檢查;執行時,後備實作會像 0.3.x 一樣把 `None` 視為已移除,並發出 `FutureWarning`。0.5.0 會把 `None` 視為 `False`。0.3.9 不會警告:子類別在該版本無法宣告 `-> bool` 而不與 0.3.x 的基底類別產生型別錯誤。 +0.4.0 起 `BaseCacheBackend.delete()` 回傳是否移除了鍵,而不是 `None`([#71](https://github.com/allen0099/FastAPI-CacheX/issues/71)),基底類別的非原子性後備實作也會使用這個結果:`delete_many()` 改為計算實際存在的鍵,而不是嘗試刪除的鍵;`get_and_delete()` 與 `delete_if_equals()` 只在呼叫者的刪除確實移除了鍵時才算成功。仍宣告 `-> None` 的第三方後端無法通過型別檢查;執行時,後備實作會像 0.3.x 一樣把 `None` 視為已移除,並發出 `FutureWarning`。0.5.0 會把 `None` 視為 `False`(請參閱[遷移至 0.5.0](MIGRATING_0_5.md#backend-delete-none))。0.3.9 不會警告:子類別在該版本無法宣告 `-> bool` 而不與 0.3.x 的基底類別產生型別錯誤。 ```python # 修改前 diff --git a/i18n/zh-TW/docs/MIGRATING_0_5.md b/i18n/zh-TW/docs/MIGRATING_0_5.md new file mode 100644 index 0000000..85f61f8 --- /dev/null +++ b/i18n/zh-TW/docs/MIGRATING_0_5.md @@ -0,0 +1,119 @@ +# 遷移至 0.5.0 {#migrating-to-050} + +0.5.0 包含破壞性變更,收錄於 [0.5.0 milestone](https://github.com/allen0099/FastAPI-CacheX/issues?q=milestone%3A0.5.0)。本頁列出每一項變更、需要修改的地方,以及 0.4.x 是否已經會發出警告。其中最大的一項是移除 0.4.0 已棄用的 Session 與 OAuth state。 + +## 升級之前 {#before-you-upgrade} + +請先升級到最新的 0.4.x 版本,並在把本函式庫的警告轉為錯誤的情況下執行測試: + +```bash +python -W error::FutureWarning -m pytest +``` + +或使用 pytest 本身的設定: + +```toml +[tool.pytest.ini_options] +filterwarnings = [ + "error::FutureWarning", +] +``` + +匯入 `fastapi_cachex.session` 或 `fastapi_cachex.state` 時,以及第三方後端的 `delete()` 回傳 `None` 時,0.4.x 會發出 `FutureWarning`。0.4.x 不再發出這些警告之後,「0.4.x 是否警告」欄位中有警告的變更就已處理完畢;本頁其餘部分說明警告偵測不到的變更。 + +## 摘要 {#summary} + +| 變更 | Issue | 0.4.x 是否警告 | 章節 | +|------|-------|----------------|------| +| 移除 Session 與 OAuth state | [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421) | `FutureWarning` | [Session 與 OAuth state](#session-state-removed) | +| 移除 `jwt` extra | [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421) | 否 | [jwt extra](#jwt-extra) | +| `@cache` 不再辨識已移除的中介軟體所載入的 Session | [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421) | 否 | [Session 權杖與 @cache](#cache-session-token) | +| `itsdangerous` 與 `starlette` 不再是直接依賴;`fastapi` 的最低版本降低 | [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421) | 否 | [依賴套件](#dependencies) | +| 後端 `delete()` 回傳 `None` 時視為未移除 | [#421](https://github.com/allen0099/FastAPI-CacheX/issues/421) | `FutureWarning` | [delete() 回傳 None](#backend-delete-none) | + +## Session 與 OAuth state 已移除 {#session-state-removed} + +0.4.0 已棄用([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420))的 `fastapi_cachex.session` 與 `fastapi_cachex.state` 已經移除([#421](https://github.com/allen0099/FastAPI-CacheX/issues/421))。一併移除的還有 `FastAPICacheXSessionMiddleware`、`SessionManager`、`SessionConfig`、Session 依賴項(`AuthenticatedSession`、`OptionalSession`、`get_session` 等)、權杖序列化器、`StateManager`,以及它們的 proxy 與例外。現在匯入其中任一個套件會拋出 `ModuleNotFoundError`,從 `fastapi_cachex` 讀取這些名稱(例如 `fastapi_cachex.SessionConfig`)則會拋出 `AttributeError`;兩者都不再先發出警告。 + +遷移方向與 0.4.0 相同:請參閱 0.4.0 遷移指南中的 [Session 與 OAuth state 已棄用](MIGRATING_0_4.md#session-state-deprecated)。簡單來說,簽署 Cookie 的 Session 改用 Starlette 的 `SessionMiddleware`,必須存放在伺服器端的 Session 改用伺服器端 Session 函式庫,API 改用你的身分驗證架構所發出的存取權杖,OAuth 的 `state` 則交給你的 OAuth 用戶端函式庫。`@cache`、`CacheManager`、`CacheLock` 與各後端都不受影響。 + +已存在後端中的 Session 與 state 不需要清理,它們會依自己的 TTL 過期。若要在 Redis 或記憶體後端上立即移除,請清除它們的前綴(預設為 `session:` 與 `oauth_state:`): + +```python +await backend.clear_pattern("session:*") +await backend.clear_pattern("oauth_state:*") +``` + +請先確認你自己的鍵沒有以這兩個前綴開頭。Memcached 無法列舉鍵,因此只能等它們過期。 + +已移除套件的 0.4.x 文件仍可在儲存庫的 [v0.4.1 標籤](https://github.com/allen0099/FastAPI-CacheX/tree/v0.4.1/i18n/zh-TW/docs)中閱讀。 + +### jwt extra {#jwt-extra} + +`jwt` extra 只是為了 `SessionConfig(token_format="jwt")` 安裝 `PyJWT`,因此一併移除。`uv add "fastapi-cachex[jwt]"`(或 pip)現在只會對未知的 extra 發出警告,並在不安裝 `PyJWT` 的情況下完成安裝。若你自己的程式碼使用 `PyJWT`,請直接依賴它: + +```bash +# Before +uv add "fastapi-cachex[redis,jwt]" + +# After +uv add "fastapi-cachex[redis]" PyJWT +``` + +## Session 權杖與 @cache {#cache-session-token} + +對於帶有憑證的請求,`@cache` 會繞過共用後端,並以 `private` 送出回應(見 [HTTP 快取](HTTP_CACHING.md#authenticated-endpoints))。現在只剩兩種憑證: + +- `Authorization` 標頭; +- 不是空的 `request.session`,來自任何 Session 中介軟體,例如 Starlette 的 `SessionMiddleware`。 + +0.4.x 另外會計入、0.5.0 不再計入的: + +- `FastAPICacheXSessionMiddleware` 載入的 Session(來自它的 `X-Session-Token` 標頭、Bearer 權杖或它的 Cookie),即使其中沒有資料也算。中介軟體已移除,這項檢查也隨之移除。 + +單獨的 `Cookie` 標頭仍然不會繞過快取,與以前相同。`@cache(vary=[...])` 中的 `X-Session-Token` 仍會像 `Authorization`、`Proxy-Authorization` 與 `Cookie` 一樣,以值的 SHA-256 摘要作為鍵,所以鍵不會改變。若取代 Session 中介軟體的做法以 `@cache` 看不到的標頭或 Cookie 辨識呼叫者,只加上 `@cache` 的路由會把第一位呼叫者的回應提供給所有人。請使用 `private=True`,或讓 `key_builder` 把已驗證的身分放進鍵中並搭配 `cache_authorized=True`;若是自訂的權杖標頭,請透過 `key_builder`(自行雜湊)而不是 `vary` 以它作為鍵: + +```python +# Before: a session the middleware loaded from X-Session-Token bypassed the backend +@cache(ttl=60, vary=["X-Session-Token"], cache_authorized=True) + +# After: hash the token yourself, or better, key on the verified user id +def per_user_key(request: Request) -> str: + return build_cache_key(request, request.state.user_id) + + +@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) +``` + +0.4.x 不會警告:它無法得知你會用什麼取代 Session 中介軟體。 + +## 依賴套件 {#dependencies} + +核心需要的套件變少,版本也更舊([#421](https://github.com/allen0099/FastAPI-CacheX/issues/421)): + +- `itsdangerous` 不再是必要依賴,只有 Session 中介軟體需要它。若你改用會匯入它的 Starlette `SessionMiddleware`,請把 `itsdangerous` 加入你自己的依賴。 +- `starlette` 不再是直接依賴。0.4.x 為了 Session 中介軟體要求 `starlette>=1.0.0`;0.5.0 使用你的 `fastapi` 所允許的任何版本。 +- `fastapi` 的最低版本從 `0.133.0` 降到 `0.128.2`,這是測試套件(搭配它接受的最舊 Starlette 0.40.0)能通過的最舊版本。`pydantic>=2.7.0` 不變。 + +除非專案中有其他部分依靠 `fastapi-cachex` 安裝 `itsdangerous` 或較新的 `starlette`,否則不需要任何修改;若有,請自行指定這些套件。 + +## delete() 回傳 None {#backend-delete-none} + +0.4.0 起 `BaseCacheBackend.delete()` 會回傳是否移除了鍵(見 [delete() 的回傳值](MIGRATING_0_4.md#backend-delete))。基底類別上的非原子性後備實作(`delete_many()`、`get_and_delete()` 與 `delete_if_equals()`)仍會像 0.3.x 一樣把第三方 `delete()` 回傳的 `None` 視為已移除,並發出 `FutureWarning`。0.5.0 以 `bool()` 讀取結果,因此 `None` 現在視為未移除,也不再警告:此時 `delete_many()` 回傳 `0`,`get_and_delete()` 與 `delete_if_equals()` 則回報呼叫者沒有成功,即使鍵已經不在了。`CacheManager.delete()` 建立在 `get_and_delete()` 之上,因此也會回傳 `False`。 + +內建的後端不受影響。第三方後端只要回傳 `bool` 即可: + +```python +# Before +class MyBackend(BaseCacheBackend): + async def delete(self, key: str) -> None: + await self._client.delete(key) + + +# After +class MyBackend(BaseCacheBackend): + async def delete(self, key: str) -> bool: + return await self._client.delete(key) > 0 +``` + +每當後備實作收到 `None`,0.4.x 都會發出 `FutureWarning`。 diff --git a/i18n/zh-TW/docs/MIGRATING_FROM_FASTAPI_CACHE2.md b/i18n/zh-TW/docs/MIGRATING_FROM_FASTAPI_CACHE2.md new file mode 100644 index 0000000..d15303e --- /dev/null +++ b/i18n/zh-TW/docs/MIGRATING_FROM_FASTAPI_CACHE2.md @@ -0,0 +1,136 @@ +# 從 fastapi-cache2 遷移 {#migrating-from-fastapi-cache2} + +[fastapi-cache2](https://github.com/long2ice/fastapi-cache)(以 `fastapi_cache` 匯入)是安裝數最多的 FastAPI 快取,最新版本 0.2.2 發行於 2024 年 7 月。本頁把它的 API 對應到 FastAPI-CacheX,列出行為上的差異,並指出沒有對應功能的部分。內容以 fastapi-cache2 0.2.2 與 FastAPI-CacheX 0.5.0 為準。如果你還在考慮是否要轉換,請見[何時使用](COMPARISON.md)。 + +FastAPI-CacheX 需要 Python 3.10 以上與 FastAPI 0.128.2 以上。 + +## 同一個路由,遷移前後 {#before-and-after} + +使用 fastapi-cache2: + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi_cache import FastAPICache +from fastapi_cache.backends.redis import RedisBackend +from fastapi_cache.decorator import cache +from redis import asyncio as aioredis + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + redis = aioredis.from_url("redis://localhost") + FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache") + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/items/{item_id}") +@cache(expire=60) +async def read_item(item_id: int) -> dict[str, int]: + return {"item_id": item_id} +``` + +使用 FastAPI-CacheX(`uv add "fastapi-cachex[redis]"`): + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from fastapi_cachex import BackendProxy, cache +from fastapi_cachex.backends import AsyncRedisCacheBackend + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + backend = AsyncRedisCacheBackend(host="localhost", key_prefix="fastapi-cache:") + BackendProxy.set(backend) + try: + yield + finally: + BackendProxy.set(None) + await backend.aclose() + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/items/{item_id}") +@cache(ttl=60) +async def read_item(item_id: int) -> dict[str, int]: + return {"item_id": item_id} +``` + +兩者的裝飾器順序相同:路由裝飾器在前,`@cache` 緊接在函式上方。完整的 Redis 設定請見[後端](BACKENDS.md#redis)。 + +## API 對照 {#api-mapping} + +| fastapi-cache2 | FastAPI-CacheX | 說明 | +|----------------|----------------|------| +| `FastAPICache.init(backend, prefix=...)` | `BackendProxy.set(backend)` | 前綴是後端的 `key_prefix`(預設 `fastapi_cachex:`)。沒有設定後端時,`@cache` 會退回使用 `MemoryBackend` 並記錄警告。 | +| `InMemoryBackend()` | `MemoryBackend()` | `MemoryBackend(max_entries=...)` 以 LRU 淘汰限制項目數量。 | +| `RedisBackend(redis)` | `AsyncRedisCacheBackend(host=..., port=..., password=..., db=...)` | 它依這些設定自行建立 client,而不是接收現成的 client。 | +| `MemcachedBackend(aiomcache.Client(...))` | `MemcachedBackend(servers=["host:port"])` | 使用 `pymemcache`(`memcached` extra)。Memcached 無法列舉鍵,所以依路徑或模式清除在它上面沒有作用。 | +| `DynamoBackend` | — | 沒有 DynamoDB 後端。 | +| `@cache(expire=60)` | `@cache(ttl=60)` | `ttl` 也接受 `timedelta`。沒有 `ttl` 時不會儲存任何東西。 | +| `FastAPICache.init(expire=...)` | — | `@cache` 沒有全域預設值,請為每個路由指定 `ttl`。應用層快取可用 `CacheManager(default_ttl=...)` 設定預設值。 | +| `@cache(namespace="items")` | — | 快取鍵由請求產生(見[快取鍵](#keys));改以路徑或模式清除一組路由。 | +| `@cache(key_builder=f)` | `@cache(key_builder=f)` | 函式只接收 `Request`,回傳 `str`。請用 `build_cache_key(request, *components)` 建立(見[在鍵中加入其他段](HTTP_CACHING.md#adding-components-to-the-key))。 | +| `@cache(coder=...)`、`JsonCoder`、`PickleCoder` | — | 直接儲存產生好的回應本文(見[儲存方式](#storage))。 | +| `FastAPICache.clear(namespace=...)` | `await backend.clear_path(path, include_params=True)` 或 `clear_pattern(...)` | 在 `CacheBackend` 依賴項或 `BackendProxy.get()` 取得的後端上呼叫。見[清除快取](HTTP_CACHING.md#clearing-the-cache)。 | +| `FastAPICache.clear(key=...)` | `await invalidate(request)` | 依請求重建快取鍵並刪除該項目。 | +| `FastAPICache.clear()` | `await backend.clear()` | 在 Memcached 上會清空整台伺服器。 | +| `X-FastAPI-Cache: HIT`/`MISS`(`cache_status_header=`) | `@cache(debug_header=True)` 送出 `X-Cache: HIT`、`MISS` 或 `BYPASS` | 預設關閉。 | +| 用在非端點函式上的 `@cache` | `@cached(ttl=60)` 或 `CacheManager.get_or_set()` | 和 fastapi-cache2 一樣以引數作為鍵。見[快取一個函式](APP_CACHE.md#caching-a-function)。 | +| `FastAPICache.init(enable=False)` | — | 沒有關閉快取的開關。測試時,請為每個測試設定新的 `MemoryBackend`。 | + +## 行為上的差異 {#behaviour-that-differs} + +### 快取鍵來自請求,而不是引數 {#keys} + +fastapi-cache2 以函式的模組、名稱與引數計算雜湊。FastAPI-CacheX 以請求作為鍵:`http:v2|method|host|path|query`,查詢參數會排序。實際上: + +- 服務同一個應用程式的兩個主機會得到不同的項目;被 FastAPI 解析成相同引數的兩個查詢字串(`?page=1` 與 `?page=01`)也一樣。 +- 回應取決於某個請求標頭時,快取鍵必須包含該標頭:`@cache(vary=["Accept-Language"])` 或自訂 `key_builder`。除非該標頭是函式引數,否則在 fastapi-cache2 中同樣需要自訂 key builder。 +- 不會讀取 fastapi-cache2 留下的鍵。轉換後的第一個請求會是快取未命中;舊的鍵會依它們的 TTL 自行過期,也可以依舊的前綴清除。 + +### 帶有憑證的請求會略過快取 {#credentials} + +fastapi-cache2 對帶 `Authorization` 標頭的請求和其他請求一樣快取,所以每個使用者各自不同的端點必須自行把使用者放進快取鍵。FastAPI-CacheX 對帶 `Authorization` 或非空 `request.session` 的請求,不讀取也不寫入共用後端,並以 `Cache-Control: private` 回應。如果你原本依賴 fastapi-cache2 快取這類路由: + +- 回應對每個使用者都相同時,設定 `@cache(ttl=60, public=True)`。 +- 回應依使用者而不同時,設定 `cache_authorized=True`,並搭配把驗證過的使用者放進快取鍵的 `key_builder`。見[帶有憑證的請求](HTTP_CACHING.md#requests-with-credentials)。 + +設定 cookie 的路由也永遠不會被儲存。 + +### 忽略用戶端的 `Cache-Control` {#request-cache-control} + +fastapi-cache2 遇到帶 `Cache-Control: no-store` 的請求會略過快取,遇到 `no-cache` 則重新產生回應。FastAPI-CacheX 忽略請求的 `Cache-Control`,所以沒有任何用戶端能把每個請求都直接送到你的 handler。相符的 `If-None-Match` 仍會得到 `304`。 + +### 儲存方式 {#storage} + +fastapi-cache2 以 coder 儲存回傳值(預設 JSON,可選 pickle),讀取時再解碼回端點的回傳型別註記。FastAPI-CacheX 儲存 FastAPI 產生的回應:本文位元組、狀態碼、媒體類型與標頭,包在一個 JSON 結構中。不使用 pickle,所以從共用 Redis 讀出的項目不會執行程式碼;任何回應類別都能快取,包括 `HTMLResponse` 與 `PlainTextResponse`。`StreamingResponse` 或 `FileResponse` 會照常送出,但不會被儲存。 + +應用層快取(`CacheManager`、`@cached`)儲存的是 JSON 值;讀回來的內容請見 [JSON 往返](APP_CACHE.md#json-round-trip)。 + +### 標頭 {#headers} + +兩者都會送出 `Cache-Control: max-age` 與弱 `ETag`,並在 `If-None-Match` 相符時回 `304`。FastAPI-CacheX 也會依裝飾器的參數寫出其他指令(`no_cache`、`no_store`、`private`、`public`、`immutable`、`must_revalidate`、`stale`),命中時送出 `Age`,並以快取的 `GET` 回應 `HEAD`。handler 不需要為此宣告 `Response` 參數。見 [Cache-Control 指令](HTTP_CACHING.md#cache-control-directives)。 + +### 只儲存 GET {#methods} + +兩者都只快取 `GET`。在同時接受 `HEAD` 的路由上,FastAPI-CacheX 會以 `GET` 的項目回應 `HEAD`。 + +## 沒有對應功能的部分 {#no-equivalent} + +- DynamoDB 後端。 +- `namespace=` 以及依命名空間清除。請改以路徑、模式或 `invalidate()` 清除。 +- Coder,以及把快取值解碼回回傳型別註記。 +- 全域預設的 `expire`,以及 `enable=False`。 +- Python 3.8 與 3.9,以及早於 0.128.2 的 FastAPI。 diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md deleted file mode 100644 index 68affeb..0000000 --- a/i18n/zh-TW/docs/SESSION.md +++ /dev/null @@ -1,431 +0,0 @@ -# Session 管理擴充 {#session-management-extension} - -> [!WARNING] -> **已棄用。** `fastapi_cachex.session` 在 0.4.0 已棄用,並將在 0.5.0 移除([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420))。匯入時會發出 `FutureWarning`。遷移方向請見[遷移至 0.4.0](MIGRATING_0_4.md#session-state-deprecated)。 - -FastAPI-CacheX 的 Session 管理提供完整的使用者 Session 處理,包括簽署過的權杖、滑動過期,以及可選的 IP / User-Agent 綁定。Session 內容一律存放在快取後端;用戶端只持有一個簽署過的權杖。 - -**`FastAPICacheXSessionMiddleware` 如何傳遞權杖:** - -| 權杖來源 | 回應端 | -|--------------|---------------| -| 自訂標頭(預設 `X-Session-Token`)/`Authorization: Bearer`/**Cookie**(預設名稱 `__Host-session`) | 依來源決定:送出標頭或 Bearer 權杖的請求(即使該權杖已無法解析)會在回應標頭中收到權杖;其他情況(Cookie,或完全沒有權杖)則使用 `Set-Cookie` | - -只支援標頭的 `SessionMiddleware` 自 0.3.1 起已棄用,**已於 0.4.0 移除**;見[遷移](#migration-sessionmiddleware-fastapicachexsessionmiddleware)。 - -`SessionConfig` 的六個 `cookie_*` 設定(`cookie_name`、`cookie_max_age`、`cookie_path`、`cookie_same_site`、`cookie_https_only`、`cookie_domain`)**只有 `FastAPICacheXSessionMiddleware` 會讀取**;不透過它而直接使用 `SessionManager` 時,設定它們不會有任何效果,但 `SessionConfig` 仍會驗證它們(見 [Cookie 預設值](#cookie-defaults))。 - -完整可執行範例(英文):[`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py)、[`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py)。 - -## 功能特點 {#features} - -- ✅ **Session 生命週期管理**:建立、讀取、更新、刪除、使其失效 -- ✅ **安全性**: - - HMAC-SHA256 權杖簽署 - - IP 位址綁定(可選) - - User-Agent 綁定(可選) - - 登入後重新產生 Session ID -- ✅ **多種權杖來源**:自訂標頭、`Authorization: Bearer`、Cookie(只有 `FastAPICacheXSessionMiddleware` 支援 Cookie) -- ✅ **可選的 JWT 格式**:以 JWT 作為 Session 權杖(需要 `jwt` extra) -- ✅ **滑動過期**,以及可選的絕對逾時 -- ✅ **Flash 訊息**:在請求之間傳遞訊息 -- ✅ **多種後端**:Redis、Memcached、記憶體 -- ✅ **API 優先或以瀏覽器為主的架構**:用戶端可以自行保存權杖(標頭/Bearer),也可以交給瀏覽器以 Cookie 保存(`FastAPICacheXSessionMiddleware`) - -## 快速開始 {#quick-start} - -### 1. 安裝 {#1-installation} - -Session 管理已內建於 FastAPI-CacheX: - -```bash -uv add fastapi-cachex -``` - -若要啟用 JWT 權杖格式: - -```bash -uv add "fastapi-cachex[jwt]" -``` - -### 2. 基本用法 {#2-basic-usage} - -以下是 [`examples/session_api.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_api.py)(程式碼註解為英文):API 用戶端登入後保存取得的權杖,並在之後的請求中送出它。 - - -```python ---8<-- "examples/session_api.py" -``` - - -範例也把管理器註冊到 `SessionManagerProxy`。管理器註冊在那裡之後,中介軟體可以從 proxy 取得,而不必以參數傳入。省略 `config` 時,中介軟體會使用 `session_manager.config`: - -```python -app.add_middleware(FastAPICacheXSessionMiddleware) # 從 proxy 取得 -``` - -當請求沒有帶著有效的 Session 時,`get_session`(及其別名 `require_session`)會拋出 `401 Authentication required`,並附上 `WWW-Authenticate: Bearer` 標頭。格式錯誤、偽造、已過期、已失效或未通過綁定檢查的權杖,在中介軟體層級都不會被視為錯誤:請求只會在沒有 Session 的情況下繼續處理。 - -依賴項回傳的 Session 物件是後端的 `Session` 模型。由 `FastAPICacheXSessionMiddleware` 從 `request.session` 建立的 Session(見下方的遷移一節)是匿名的,因此 `session.user` 為 `None`。 - -`get_session` 也接受這種 Session,因此它只能證明請求帶著「某個」Session,而不能證明有人登入。任何訪客只要進入會寫入 `request.session` 的路由(購物車、CSRF 值),就會得到一個。需要已登入使用者的路由,請改用 `require_user_session`(或其型別註記形式 `AuthenticatedSession`)保護,它在 `session.user` 為 `None` 時同樣回應 `401`,上方的 `/profile` 就是這樣做的。`/logout` 只會刪除 Session,因此使用 `SessionDep`(`get_session` 的型別註記形式)就足夠;`/public` 則使用 `OptionalSession`(`get_optional_session`),沒有 Session 時得到 `None`,而不是回應 `401`。 - -`UserSessionDep` 與 `AuthenticatedSession` 相同:匿名 Session 會得到 `401`。0.4.0 之前它是 `SessionDep` 的別名,也接受匿名 Session;路由確實需要接受匿名 Session 時,請使用 `SessionDep`(見[遷移至 0.4.0](MIGRATING_0_4.md#user-session-dep))。 - -在 `FastAPICacheXSessionMiddleware` 底下,請以 `await login(request, user)` 讓使用者登入。它會以新的 Session ID 附加 `require_user_session`/`AuthenticatedSession` 檢查的 `SessionUser`,並由中介軟體送出權杖;見[登入後重新產生 Session ID](#5-regenerate-the-session-id-after-login)。上面的 `/login` 則是在本文中把權杖交給 API 用戶端:`create_session(user=...)` 同樣會設定 `session.user`,但中介軟體不會為不是由它載入或建立的 Session 送出任何東西。寫入 `request.session` 的鍵(`request.session["user_id"] = ...`)是應用程式資料:函式庫不會把它視為登入,因此這種 Session 仍會讓 `AuthenticatedSession` 回應 `401`。`session.user` 本身是唯讀的:指定它會拋出 `AttributeError`,因此除了建立時就帶有使用者的 `Session` 之外,Session 只能從 `login()` 或 `create_session(user=...)` 得到使用者。登出請使用 `await logout(request)`。 - -### 3. 完整範例(Redis 後端) {#3-full-example-redis-backend} - -以下是 [`examples/session_redis.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_redis.py)(程式碼註解為英文)。它與 [`examples/redis_backend.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/redis_backend.py) 一樣,從 `REDIS_HOST`、`REDIS_PORT`、`REDIS_DB` 與 `REDIS_PASSWORD` 讀取 Redis 設定。 - - -```python ---8<-- "examples/session_redis.py" -``` - - -在 handler 中對 `Session` 物件所做的變更(Flash 訊息、`session.data`),只有在呼叫 `session_manager.update_session(session)` 時才會被儲存。這個儲存是有條件的:只有在後端仍存放這個物件最後一次讀到或寫入的內容時才會寫入,否則回傳 `False`。期間被另一個請求刪除、使其失效或輪替的 Session 不會被復活;兩個請求同時修改同一個 Session 時,先儲存的成功(見 [Session 寫入](MIGRATING_0_4.md#session-writes))。 - -`delete_user_sessions()` 與 `clear_expired_sessions()` 會透過 `get_all_keys()` 列舉後端中的每一個鍵,並載入 `backend_key_prefix` 底下的每個 Session,因此其成本會隨後端的大小增加。在無法列舉鍵的 Memcached 後端上,它們找不到任何東西並回傳 `0`(後端會發出 `RuntimeWarning`)。 - -`clear_expired_sessions()` 會移除所有已無法使用的 Session:超過 `expires_at` 的,以及不再是 `ACTIVE` 的(以 `invalidate_session()` 作廢,或先前讀取時已標記為過期),否則它們會留在後端直到 TTL 到期。兩個方法都以單一次 `backend.delete_many()` 呼叫刪除找到的 Session。 - -## 遷移:SessionMiddleware → FastAPICacheXSessionMiddleware {#migration-sessionmiddleware-fastapicachexsessionmiddleware} - -`SessionMiddleware` 自 0.3.1 起已棄用,已於 0.4.0 移除。請改用 `FastAPICacheXSessionMiddleware`: - -- **`SessionMiddleware`**(一個 `BaseHTTPMiddleware`,已移除):以自訂標頭(預設 `X-Session-Token`)和/或 `Authorization: Bearer` 傳遞權杖,適合由用戶端管理權杖的 API 優先架構。不支援以 Cookie 傳輸。 -- **`FastAPICacheXSessionMiddleware`**(一個純 ASGI 中介軟體):與 Starlette 內建的 `SessionMiddleware` 相容,提供相同的類 dict `request.session`。它以 Cookie(預設 Cookie 名稱 `__Host-session`)傳遞簽署過的 Session 權杖,而 Session 內容則存放在後端(`SessionManager` 的快取後端),而不是像 Starlette 自己的實作那樣編碼進 Cookie 本身。權杖解析採「標頭優先、Cookie 其次」:它會先讀取自訂標頭(預設 `X-Session-Token`)和/或 `Authorization: Bearer`,只有兩者都不存在時才退回使用 Cookie,因此原本搭配 `SessionMiddleware` 使用 `X-Session-Token` 的用戶端不需修改即可繼續運作。回應端同樣依來源決定:請求送出標頭或 Bearer 權杖時(即使該權杖已無法解析),新的或更新後的權杖會在 `header_name` 回應標頭中傳回,且不會發出 `Set-Cookie`;從 Cookie 傳入的權杖(或沒有權杖的請求所建立的全新匿名 Session)則使用 `Set-Cookie`。 - -`FastAPICacheXSessionMiddleware` 和 `SessionMiddleware` 一樣會將載入的 `Session` 物件放進 `request.state`,因此 Session 依賴項 `get_session`、`get_optional_session`、`require_session` 與 `require_user_session` 都能直接運作,不需任何修改: - -```python -from fastapi import Depends -from fastapi_cachex.session import FastAPICacheXSessionMiddleware, require_user_session - -app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config -) - - -@app.get("/me") -async def me(session=Depends(require_user_session)): - return {"user_id": session.user.user_id} -``` - -### 搭配 `FastAPICacheXSessionMiddleware` 使用 `request.session` {#requestsession-with-fastapicachexsessionmiddleware} - -`request.session` 是後端 Session 的 `data` dict 的一個視圖: - -- 在沒有載入任何 Session 時寫入 `request.session`,會建立一個新的**匿名** Session(`SessionManager.create_anonymous_session()`,並依設定套用 IP / User-Agent 綁定),並透過該請求的傳輸方式傳回其權杖。 -- 修改已載入 Session 的 `request.session`,會透過 `update_session()` 將新內容儲存到後端,以 dict 的內容取代 `Session.data`。若這個請求執行期間,另一個請求刪除、使其失效或輪替了這個 Session,或先一步儲存了它,這次儲存會被捨棄並記錄 log。回應不附 Session 權杖,除非這個請求已儲存了續期,且 Session 仍然有效,此時會送出續期後的權杖。 -- 在已載入的 Session 上清除它(`request.session.clear()`)即為登出:即使資料原本就是空的,也會刪除後端的 Session;Cookie 用戶端還會收到一個使 Cookie 過期的 `Set-Cookie`。同一個請求中在 `clear()` 之後寫入的鍵,會存進一個使用新 ID 的新匿名 Session。`await logout(request)` 的效果相同,但會立即刪除後端的 Session,而不是等到送出回應時,且在該請求剩下的處理中,`get_session` 找不到 Session。 -- 以 `del` 或 `pop()` 移除最後一個鍵並不是登出。帶有使用者的 Session 會以空資料儲存;匿名 Session 已無任何內容,會和 `clear()` 一樣被刪除。 -- 以寫入 `request.session` 的方式登入時,會沿用請求帶來的 Session ID。Starlette 的中介軟體中 Cookie *就是* Session,因此登入回應會取代任何被植入的 Cookie;這裡的 Cookie 只是指向伺服器端紀錄的名稱,被植入的 Cookie 會跟著受害者一起登入。請以 `await login(request, user)` 登入,它會為 Session 換一個新 ID 並附加使用者(見[登入後重新產生 Session ID](#5-regenerate-the-session-id-after-login))。 -- 只要存取 `request.session`,或透過 Session 依賴項(`get_session`、`get_optional_session`,以及建立在它們之上的依賴項,例如 `AuthenticatedSession`)讀取 Session,就會為了尋找權杖而讀取過的每個請求標頭加入 `Vary`:依 `token_source_priority` 順序檢查的標頭(`header_name`,以及啟用 Bearer 權杖時的 `Authorization`),直到攜帶權杖的那一個為止。只有在沒有任何標頭攜帶權杖時才會讀取 Cookie,因此也只有這時才會加入 `Cookie`。 -- 帶有 Session 權杖的回應(新建立的 Session、滑動續期、重新產生的 ID),或帶有讓 Session Cookie 失效之 `Set-Cookie` 的回應,一律不可快取。中介軟體會設定 `Cache-Control: private, no-store`,取代路由原本設定的值(包括 `@cache(public=True)` 的路由),並且即使 handler 沒有碰過 `request.session`,也會加入與上一項相同的 `Vary` 名稱。否則 CDN 或反向 proxy 可能存下權杖,再交給下一位訪客。不帶權杖的回應則維持原本的標頭。 -- 對帶有 Session 的請求(中介軟體從任何來源載入的 Session,有沒有使用者都算,或不是空的 `request.session`),`@cache` 不會讀寫後端,並像 `Authorization` 一樣以 `private` 回應。`public=True` 讓路由在各 Session 間共用;`cache_authorized=True` 搭配包含 Session 使用者的 `key_builder` 則依使用者快取,回應仍帶有 `private`。見[需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 - -Cookie 一律為 `HttpOnly`;`Secure`、`SameSite`、`Domain`、`Path` 與 `Max-Age` 則依 `cookie_*` 設定(`cookie_max_age=None` 或 `0` 時不設 `Max-Age`)。 - -## 設定 {#configuration} - -### SessionConfig {#sessionconfig} - -`SessionConfig` 是會拒絕未知欄位(`extra="forbid"`)的 Pydantic 模型,因此欄位名稱打錯字會拋出 `ValidationError`。下列值皆為預設值,唯獨 `secret_key` 是必填欄位。 - -```python -SessionConfig( - # Session 存活時間 - session_ttl=3600, # Session TTL(秒) - absolute_timeout=None, # 從 created_at 起算的硬性上限(秒);None = 無上限 - sliding_expiration=True, # 滑動過期 - sliding_threshold=0.5, # 0.0-1.0;剩餘時間少於 TTL 的這個比例時更新 - # 權杖來源(API 優先架構) - token_format="simple", # "simple"(預設)或 "jwt" - header_name="X-Session-Token", - token_source_priority=["header", "bearer"], # "cookie" 只能放在最後(見下文) - # JWT(token_format == "jwt" 時使用) - jwt_algorithm="HS256", # 拒絕 "none" - jwt_issuer=None, # 若有設定,會寫入 iss 並在解析時驗證 - jwt_audience=None, # 若有設定,會寫入 aud 並在解析時驗證 - jwt_leeway=0, # exp/iat 檢查的容許誤差秒數(nbf 既不發行也不驗證) - # 安全性 - secret_key="...", # 必填:至少 32 個字元 - ip_binding=False, # IP 綁定 - user_agent_binding=False, # User-Agent 綁定 - trusted_proxies=[], # 受信任的反向 proxy 位址(見「用戶端 IP 與反向 proxy」) - # 後端 - backend_key_prefix="session:", - # Cookie(只有 FastAPICacheXSessionMiddleware 會讀取) - cookie_name="__Host-session", # 見下方「Cookie 預設值」 - cookie_max_age=14 - * 24 - * 60 - * 60, # None = 不設 Max-Age(Cookie 隨瀏覽器工作階段結束) - cookie_path="/", - cookie_same_site="lax", # "lax" / "strict" / "none"("none" 需要 cookie_https_only=True) - cookie_https_only=True, # Secure 旗標:Cookie 只透過 HTTPS 傳送 - cookie_domain=None, # None = 不設 Domain 屬性 -) -``` - -#### Cookie 預設值 {#cookie-defaults} - -Session Cookie 預設命名為 `__Host-session`,並帶有 `Secure` 旗標。瀏覽器只接受帶 `Secure`、`Path=/` 且沒有 `Domain` 的 `__Host-` Cookie,也不接受子網域設定的這種 Cookie,因此移除了植入 Session Cookie 最常見的途徑(Session 固定攻擊(session fixation))。 - -`Secure` Cookie 不會透過純 HTTP 傳送。沒有 TLS 的本機開發環境,請使用不帶前綴的名稱並關閉此旗標:`cookie_name="session", cookie_https_only=False`。 - -對瀏覽器會拒絕的 Cookie,`SessionConfig` 會引發 `ValidationError`:`__Host-` 名稱搭配 `cookie_https_only=False`、`/` 以外的 `cookie_path` 或 `cookie_domain`,以及 `__Secure-` 名稱未搭配 `cookie_https_only=True`。預設名稱帶有 `__Host-` 前綴,因此只變更其中一項設定也會引發錯誤;請一併變更 `cookie_name`。從 0.3.x 升級會變更 Cookie 名稱,使所有 Cookie Session 被登出一次;請參閱[遷移至 0.4.0](MIGRATING_0_4.md#session-cookie)。 - -Session 會在 `session_ttl` 秒後過期。啟用 `sliding_expiration` 時,每個發現剩餘時間少於 `session_ttl * sliding_threshold` 秒的請求,都會將過期時間重新延長為完整的 `session_ttl`,並發行一個更新後的權杖,由中介軟體傳回給用戶端(回應標頭或 `Set-Cookie`,見上表)。標頭/Bearer 用戶端在回應帶有 `header_name` 標頭時,應以它取代已保存的權杖。`absolute_timeout` 會在 Session 建立後經過該秒數時結束 Session,不論是否有滑動更新:過期時間、後端 TTL 與 JWT 的 `exp` 都不會超過 `created_at + absolute_timeout`,過期時間到達這個上限後也不再發行更新後的權杖。 - -#### `token_source_priority` 與 Session Cookie {#token_source_priority-and-the-session-cookie} - -`FastAPICacheXSessionMiddleware` 先依照 `token_source_priority` 的順序讀取標頭來源,只有它們都沒有產生權杖時才退回使用 Cookie。回應端依權杖的來源決定(標頭進、標頭出;Cookie 進、`Set-Cookie` 出)。 - -不論清單是否列出 Cookie,都會讀取 Cookie。`"cookie"` 只能放在清單的最後一項,也就是它本來就被讀取的位置,因此列出它不會改變任何行為;放在其他位置會引發 `ValidationError`。(0.3.9 曾預告 0.4.0 會讓清單列出所有權杖來源;Session 棄用後,這項變更已取消。請參閱[遷移至 0.4.0](MIGRATING_0_4.md#token-source-priority)。) - -`use_bearer_token` 已棄用,並將在 0.5.0 隨本套件一起移除([#377](https://github.com/allen0099/FastAPI-CacheX/issues/377)):傳入它會發出 `DeprecationWarning`。請以不在清單中列出 `"bearer"`(`token_source_priority=["header", "cookie"]`)取代 `use_bearer_token=False`;`use_bearer_token=True` 是預設值,直接拿掉即可。 - -**標頭/Bearer 用戶端**應將權杖存放在 `localStorage` 或 `sessionStorage`,並以 `Authorization: Bearer ` 或 `X-Session-Token: ` 送出。**Cookie 用戶端**(瀏覽器)不需要自行處理權杖,但要留意 CSRF:瀏覽器會自動附上 Cookie,因此請將 `cookie_same_site` 與你自己的 CSRF 防護搭配使用。 - -### 使用 JWT 權杖格式 {#using-the-jwt-token-format} - -設定 `token_format="jwt"` 時,Session 權杖會以帶有下列 claim 的 JWT 發行: - -- `sid`:Session ID(自訂 claim,對應到伺服器端的 Session) -- `iat`:發行時間(epoch 秒數) -- `exp`:過期時間——Session 目前的 `expires_at`(因此會隨滑動更新移動),若無則退回 `iat + session_ttl` -- `iss`/`aud`:有設定時寫入,並在解析時驗證 - -設定範例: - -```python -config = SessionConfig( - secret_key="your-secret-key-at-least-32-characters", - token_format="jwt", - jwt_algorithm="HS256", - jwt_issuer="your-issuer", - jwt_audience="your-audience", -) -``` - -`jwt_algorithm` 必須是 `HS256`、`HS384`、`HS512`、`RS256`、`RS384`、`RS512`、`ES256`、`ES384`、`ES512`、`PS256`、`PS384`、`PS512` 或 `EdDSA` 其中之一;其他任何值(包括 `none`)都會拋出 `ValidationError`。內建的序列化器以同一把 `secret_key` 簽署與驗證,因此只支援 `HS256`、`HS384` 與 `HS512`:使用非對稱演算法時,除非你傳入持有金鑰對的自訂 `token_serializer`,否則 `SessionManager` 會拋出 `ValueError`。 - -HMAC 金鑰的長度至少須等於雜湊輸出(RFC 7518 §3.2):`HS256` 為 32 位元組、`HS384` 為 48、`HS512` 為 64,以 UTF-8 編碼後計算。`secret_key` 只要求 32 個字元,因此搭配 `HS384` 或 `HS512` 時,較短的金鑰會讓 `SessionManager` 在建立內建序列化器時拋出 `ValueError`(自訂的 `token_serializer` 自行持有金鑰)。請使用更長的金鑰,例如 `secrets.token_urlsafe(64)`,或改用 `HS256`。 - -安全性注意事項: - -- 伺服器保存的是**有狀態**的 Session(JWT 只是帶著 `sid` 的憑證),因此權杖中不需要放入任何敏感資料 -- 解析時會驗證簽章與必要的 claim(`sid`/`iat`/`exp`,有設定時還包括 `iss`/`aud`) -- 正式環境請使用 HTTPS 與金鑰輪替策略(使用 `kid` 與多把金鑰的進階方案是未來可能的擴充) - -**進階主題**:關於 JWT claim 的設計、為何未實作 `jti`/`nbf` 等可選 claim,以及如何加入自訂 claim,請參閱 **[JWT Claims 實作說明與擴充指南](JWT_CLAIMS.md)**。 - -## 安全性最佳實務 {#security-best-practices} - -### 1. 密鑰 {#1-secret-key} - -```python -import secrets - -# 產生安全的密鑰 -secret_key = secrets.token_urlsafe(32) - -config = SessionConfig(secret_key=secret_key) -``` - -`secret_key` 以 `SecretStr` 儲存,且長度至少須為 32 個字元(`jwt_algorithm="HS384"` 時至少 48 位元組,`"HS512"` 時至少 64;見上方的 JWT 一節)。請從環境變數或密鑰儲存服務載入,而不要寫死在程式碼中;變更它會使至今發行的所有權杖失效。 - -### 2. 僅限 HTTPS {#2-https-only} - -正式環境中一律透過 HTTPS 傳輸權杖。對 Cookie 用戶端,請保留 Cookie 的 `Secure` 旗標(預設即是如此): - -```python -config = SessionConfig( - secret_key="...", - cookie_name="__Host-session", # 預設值;瀏覽器只接受帶 Secure、Path=/ 且沒有 Domain 的這種 Cookie - cookie_https_only=True, # 預設值;為 Session Cookie 加上 Secure 旗標 -) -``` - -**用戶端注意事項**: - -- 只透過 HTTPS 傳送權杖 -- `FastAPICacheXSessionMiddleware` 設定的 Session Cookie 一律為 `HttpOnly`,因此頁面腳本無法讀取;存放在 `localStorage`/`sessionStorage` 的權杖可被腳本讀取,因此要防範 XSS -- 避免在 URL 中傳遞權杖 - -### 3. 用戶端 IP 與反向 proxy {#3-client-ip-and-reverse-proxies} - -`ip_binding` 與稽核日誌所使用的「用戶端 IP」**預設只信任直接連線的對端位址**;`X-Forwarded-For` 與 `X-Real-IP` 會被忽略,因為任何人都可以送出這些標頭。 - -部署在反向 proxy 後方時,請將 proxy 的位址放進 `trusted_proxies`: - -```python -config = SessionConfig( - secret_key="...", - ip_binding=True, - trusted_proxies=["10.0.0.8"], # 直接連線的那一跳 -) -``` - -項目可以是單一位址或 CIDR 範圍,後者適用於從某個子網路連線的負載平衡器: - -```python -config = SessionConfig( - secret_key="...", - ip_binding=True, - trusted_proxies=["10.0.0.0/8", "2001:db8::/32"], -) -``` - -此時用戶端位址是 **`X-Forwarded-For` 中最右邊、且未列在 `trusted_proxies` 中的項目**:proxy 會附加到這個標頭後面,因此最左邊的項目是呼叫者自行選擇送出的內容,無法信任。當標頭分成多行送達時,它們會被當作一條以逗號分隔的鏈來讀取。若鏈中的每個項目都是受信任的 proxy,則使用直接連線的對端位址。`X-Real-IP` 由 proxy 自己寫入,沒有鏈可以走訪,因此只有在 `X-Forwarded-For` 無法產生可用的值時才會使用。 - -> [!NOTE] -> 以 IPv4 對應形式(`::ffff:10.0.0.8`,雙堆疊 socket 會這樣回報)回報的 IPv4 對端,會與 IPv4 項目比對相符。不是 IP 位址的項目(例如 TestClient 的 `testclient`)只會與完全相同的對端字串相符;而含有 `/` 但不是有效 CIDR 範圍的項目,會在建立設定時被拒絕。 - -中介軟體在檢查綁定時會套用這套邏輯,但 `create_session()` 綁定的是你傳入的任何 `ip_address`。在受信任的 proxy 後方,`request.client.host` 是 proxy 的位址,永遠不會相符,因此下一個請求的綁定檢查就會失敗。請改為傳入中介軟體推導出的位址,可以透過 `ClientIPDep` 依賴項,或以相同的設定呼叫 `get_client_ip()`: - -```python -from fastapi_cachex.session import get_client_ip -from fastapi_cachex.session.dependencies import ClientIPDep, SessionManagerDep - - -@app.post("/login") -async def login(manager: SessionManagerDep, client_ip: ClientIPDep): - session, token = await manager.create_session(user, ip_address=client_ip) - return {"token": token} - - -# 在路由之外,使用你交給中介軟體的 SessionConfig: -client_ip = get_client_ip(request, config) -``` - -### 4. IP 綁定(可選) {#4-ip-binding-optional} - -可提升安全性,但可能影響使用者體驗(例如用戶端的 IP 改變時): - -```python -config = SessionConfig( - secret_key="...", - ip_binding=True, # 將 Session 綁定到用戶端 IP -) -``` - -綁定會在建立 Session 時記錄,取自傳給 `create_session()` 的 `ip_address`(`user_agent_binding` 則取自 `user_agent`)。若建立時缺少該值,會記錄一則警告,並建立未綁定的 Session。位址與綁定位址不符(或沒有位址)的請求,會被視為沒有 Session。 - -### 5. 登入後重新產生 Session ID {#5-regenerate-the-session-id-after-login} - -防止 Session 固定攻擊(session fixation)。用戶端帶來的權杖可能是別人預先植入的(例如從同網域的其他子網域);若登入時沿用它,植入者就會拿到一個已登入的 Session。在 `FastAPICacheXSessionMiddleware` 底下,`login()` 一次就會為 Session 換一個新 ID 並附加使用者: - -```python -from fastapi import Request - -from fastapi_cachex.session import SessionUser, login - - -# Credentials 是基本用法中的請求本文模型 -@app.post("/login") -async def log_in(credentials: Credentials, request: Request): - ... # 驗證 credentials.password - await login(request, SessionUser(user_id=credentials.username)) - return {"ok": True} -``` - -請求帶來的 Session 會如何處理,取決於它屬於誰: - -- **匿名**(例如訪客的購物車):以新 ID 保留其資料並得到使用者。 -- **相同的 `user_id`**(重新登入):同上,並以你傳入的 `SessionUser` 取代儲存的使用者,讓變更後的角色或 metadata 生效。 -- **其他使用者的**:它會被刪除,連同該請求中先前寫入 `request.session` 的內容,`login()` 會建立新的 Session。前一位使用者的資料(購物車、`elevated` 旗標)都不會帶給新使用者。 -- **沒有**(新訪客,或權杖無法解析):`login()` 會建立帶有使用者的 Session,並依設定綁定用戶端 IP 與 User-Agent。 - -若只想帶入部分資料,請列出鍵:`login(request, user, keep=["cart"])` 會丟棄其他所有鍵,包括已載入 Session 中的鍵,以及該請求中先前寫入 `request.session` 的鍵;`keep=[]` 則全部丟棄。呼叫之後寫入的鍵會保留。`keep` 必須是鍵的集合,因此傳入字串會拋出 `TypeError`。 - -無論哪種情況,舊的權杖都無法再解析出 Session。接著中介軟體會儲存該 Session(包括呼叫之後寫入 `request.session` 的鍵;除非已載入的 Session 屬於其他使用者,也包括呼叫之前寫入的鍵),並透過該請求使用的傳輸方式送出權杖:以標頭或 `Authorization: Bearer` 權杖送來的請求使用回應標頭,否則使用帶有所有 `cookie_*` 屬性的 HttpOnly `Set-Cookie`。和每個帶有權杖的回應一樣,它會加上 `Cache-Control: private, no-store`。之後帶著該權杖的請求會通過 `require_user_session` 與 `AuthenticatedSession`。`login()` 會回傳該 Session,在該請求剩下的處理中,`get_session` 也會回傳它。 - -完全沒有帶權杖的請求只會收到 Cookie,頁面上的腳本讀不到它。不要把權杖複製到瀏覽器登入回應的標頭或本文中。沒有權杖就登入的 API 用戶端需要從本文取得權杖:對 `login()` 回傳的 Session 回傳 `manager.issue_token(session)`,或由另一個端點發出權杖,如 [`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py) 所示。完整的瀏覽器版本請見 [`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py)。 - -登出請呼叫 `await logout(request)`。它會立即從後端刪除 Session,因此權杖在回應送出之前就已失效,Cookie 用戶端也會收到讓 Cookie 過期的回應。它回傳 `True`;若該請求中沒有載入或建立任何 Session(包括權杖無法解析的情況),則回傳 `False`。之後寫入 `request.session` 的鍵會存進新的匿名 Session,之後呼叫的 `login()` 則會建立新的 Session。 - -在同一個請求中,`login()` 之後呼叫 `request.session.clear()` 就是登出:新的 Session 會被刪除,也不會送出權杖(Cookie 用戶端的 Cookie 會被設為過期)。在 `login()` 之前呼叫 `clear()` 會讓已載入的 Session 登出,`login()` 接著會建立新的 Session,而不是為它換 ID。沒有 `FastAPICacheXSessionMiddleware` 時,`login()` 與 `logout()` 會拋出 `RuntimeError`,因為沒有人會送出權杖或讓 Cookie 過期;請改以 `create_session(user=...)` 建立 Session 並回傳其權杖,並以 `delete_session()` 結束它。 - -`request.session["user_id"] = "123"` 不是登入。它是應用程式資料,`require_user_session` 與 `AuthenticatedSession` 不會認得它,而且它會沿用請求帶來的 Session ID。 - -若要在不登入的情況下更換 ID(例如權限變更之後),請呼叫 `await rotate_session_id(request)`: - -```python -from fastapi_cachex.session import rotate_session_id -from fastapi_cachex.session.dependencies import AuthenticatedSession - - -@app.post("/sudo") -async def sudo(request: Request, session: AuthenticatedSession): - ... # 再次檢查密碼 - await rotate_session_id(request) - request.session["elevated"] = True - return {"ok": True} -``` - -`rotate_session_id()` 會對請求的 Session 呼叫 `SessionManager.regenerate_session_id()`,刪除舊 ID 底下的後端紀錄,並以新 ID 儲存該 Session,保留其資料、使用者、`created_at` 與過期時間。中介軟體會看到新 ID,並透過該請求使用的傳輸方式送出對應的權杖:Cookie 使用 `Set-Cookie`,標頭權杖則使用回應標頭。之後舊的權杖就無法再解析出 Session。新訪客沒有可換 ID 的 Session,因此它會回傳 `False`。若這個請求執行期間,另一個請求刪除、使其失效或輪替了這個 Session,則不會儲存或送出任何東西,並回應 `401`;`login()` 則改為替使用者建立新的 Session(見 [Session 寫入](MIGRATING_0_4.md#session-writes))。 - -已經取得請求 Session 物件的 handler,也可以直接呼叫 `await manager.regenerate_session_id(session)`。請從 `get_optional_session` 取得 Session,並在它為 `None` 時略過呼叫;`SessionDep` 會對還沒有 Session 的訪客回應 `401`。與 `rotate_session_id()` 不同,若期間另一個請求結束了這個 Session,直接呼叫會拋出 `SessionNotFoundError` 或 `SessionInvalidError`,因此請捕捉 `SessionError`,並比照沒有 Session 的情況回應。 - -在中介軟體之外,請以中介軟體會傳入的相同綁定值載入 Session,並自行將回傳的權杖交給用戶端: - -```python -session, _ = await manager.get_session( - current_token, ip_address=client_ip, user_agent=user_agent -) -session, new_token = await manager.regenerate_session_id(session) -``` - -## SessionManager 概覽 {#sessionmanager-at-a-glance} - -`SessionManager(backend, config, token_serializer=None)` 處理整個生命週期:`create_session()`/`create_anonymous_session()` 回傳 `(session, token)`;`get_session()` 回傳 `(session, renewed_token)`,其中 `renewed_token` 只有在滑動過期更新了權杖時才會有值,並應傳回給用戶端。`get_session()` 失敗時會拋出 `SessionError` 的子類別:`SessionTokenError`(權杖格式錯誤;JWT 還包括簽章錯誤、`exp` 已過期或 `iss`/`aud` 不符)、`SessionSecurityError`(`simple` 權杖簽章錯誤或綁定不符)、`SessionNotFoundError`、`SessionInvalidError`(Session 不是啟用狀態)或 `SessionExpiredError`(超過 TTL 或絕對逾時)。從 0.3.8 起,`SessionError` 繼承自 `CacheXError`,因此 `except CacheXError` 也會捕捉 Session 錯誤。 - -`get_session()` 只有在滑動過期更新了 Session 時才寫入後端,因此只讀取 Session 的請求只需一次後端讀取。回傳的 Session 中 `last_accessed` 是目前時間,但儲存的值只會在 Session 下一次被寫入(建立、修改、更新或重新產生)時更新。傳入 `touch=True` 可在每次查詢時都儲存它。0.3.8 之前,每次查詢都會儲存 Session。 - -每個方法及其簽名請見自動產生的 [Session API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/session/)(英文)。 - -## 依賴項 {#dependencies} - -```python -from fastapi_cachex.session import ( - get_session, # 需要驗證(沒有 Session 時回應 401) - get_optional_session, # 可選驗證(沒有 Session 時為 None) - require_session, # get_session 的別名 - require_user_session, # Session 沒有使用者時也回應 401 - get_session_manager, # 中介軟體註冊的 SessionManager - login, # 不是依賴項:await 它以新的 Session ID 讓使用者登入 - rotate_session_id, # 不是依賴項:await 它以取得新的 Session ID -) - -# 型別註記 -from fastapi_cachex.session.dependencies import ( - OptionalSession, # Session | None - RequiredSession, # Session - SessionDep, # Session - UserSessionDep, # 自 0.4.0 起與 AuthenticatedSession 相同 - AuthenticatedSession, # 帶有使用者的 Session(require_user_session) - SessionManagerDep, # SessionManager -) -``` - -`get_session_manager` 回傳中介軟體在處理第一個請求時存放在 `app.state` 上的管理器;若尚未有任何 Session 中介軟體執行過,它會回應 `500`。使用它可以避免在路由模組中匯入管理器。 - -```python -from fastapi_cachex.session import SessionUser -from fastapi_cachex.session.dependencies import SessionManagerDep - - -# Credentials 是基本用法中的請求本文模型 -@app.post("/login") -async def login(credentials: Credentials, manager: SessionManagerDep): - ... # 驗證 credentials.password - user = SessionUser(user_id=credentials.username) - session, token = await manager.create_session(user=user) - return {"token": token} -``` - -`get_session` 與 `get_optional_session` 也會宣告一個 `HTTPBearer` 安全性方案(`SessionBearer`),因此 Swagger UI 會顯示 **Authorize** 按鈕;權杖本身仍由中介軟體讀取。 diff --git a/i18n/zh-TW/docs/STATE.md b/i18n/zh-TW/docs/STATE.md deleted file mode 100644 index 60a43cf..0000000 --- a/i18n/zh-TW/docs/STATE.md +++ /dev/null @@ -1,123 +0,0 @@ -# State 管理擴充 {#state-management-extension} - -> [!WARNING] -> **已棄用。** `fastapi_cachex.state` 在 0.4.0 已棄用,並將在 0.5.0 移除([#420](https://github.com/allen0099/FastAPI-CacheX/issues/420))。匯入時會發出 `FutureWarning`。遷移方向請見[遷移至 0.4.0](MIGRATING_0_4.md#session-state-deprecated)。 - -`fastapi_cachex.state` 為 OAuth / OIDC 授權流程提供**一次性 state 權杖**。開始授權之前,先產生一個隨機 state 並存入快取後端;回呼(callback)回來時,再將它**消耗**掉。已消耗的 state 無法再使用第二次。 - -只有當 state **綁定到發起流程的瀏覽器**時,它才能保護流程免於 CSRF 攻擊(RFC 6749 §10.12)。光是儲存並不夠:攻擊者可以在自己的瀏覽器中發起流程,再把受害者導向帶有攻擊者 state 與 code 的回呼,使受害者登入攻擊者的帳號。請在建立 state 時傳入 `binding`(一個同時設為 Cookie 的隨機 nonce),並在消耗時傳入相同的值,如下方快速開始所示。 - -State 與 HTTP 快取存放在同一個後端,但使用自己的鍵前綴(預設為 `oauth_state:`),因此像 `CacheManager.clear_prefix()` 這類依命名空間的操作不會動到它們。不過後端層級的 `clear()`(例如 `BackendProxy.get().clear()`)會移除它們,因為它會清除後端命名空間底下的所有內容。 - -本指南中的所有內容也都可以從頂層的 `fastapi_cachex` 套件匯入。 - -下方的快速開始就是完整可執行的範例 [`examples/oauth_state.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/oauth_state.py),程式碼註解為英文。 - -## 快速開始 {#quick-start} - -以下是 [`examples/oauth_state.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/oauth_state.py)(程式碼註解為英文): - - -```python ---8<-- "examples/oauth_state.py" -``` - - -若提供者以 POST 送出回呼(`response_mode=form_post`),`SameSite=Lax` 的 Cookie 不會隨這個跨站 POST 送出;此時綁定用的 Cookie 請改用 `samesite="none"`(需要 `secure=True`)。在另一個分頁再次開始登入會覆寫這個 Cookie,因此第一個分頁的回呼會被拒絕;使用者只要重新登入即可。 - -## StateManager {#statemanager} - -```python -from fastapi_cachex.state import StateManager - -states = StateManager( - backend=None, # None 表示使用 BackendProxy.get() - key_prefix="oauth_state:", # 鍵前綴 - default_ttl=600, # 預設:10 分鐘 -) -``` - -當 `backend=None` 時,後端是在**建構 `StateManager` 時**解析,而不是每次呼叫時才解析。若尚未呼叫 `BackendProxy.set(...)`,建構子會拋出 `BackendNotFoundError`。請先設定後端。 - -### `create_state(ttl=None, metadata=None, *, binding=None) -> str` {#create_statettlnone-metadatanone-bindingnone-str} - -以 `secrets.token_urlsafe(32)`(256 位元熵)產生 state 字串,存入後端並回傳。`metadata` 是與 state 一併儲存的任意可 JSON 序列化 dict(例如授權完成後要重新導向的路徑)。省略 `ttl` 時使用 `default_ttl`。同一個 TTL 會同時作為後端 TTL 與 state 的 `expires_at`。 - -`binding` 將 state 綁定到發起流程的用戶端:一個同時設為 Cookie 的隨機 nonce,或任何只有該用戶端會在回呼時提交的秘密值。後端只會儲存它的 SHA-256。空字串會拋出 `ValueError`,因為把缺少的 Cookie 讀成 `""` 時,所有這類用戶端都會綁定到同一個值。 - -### `consume_state(state, *, binding=None) -> StateData` {#consume_statestate-bindingnone-statedata} - -**一次性消耗。** 項目透過後端的原子操作 `get_and_delete()` 取出並移除,因此當多個並行呼叫提交同一個 state 時,**只有一個**會取得它。重送的回呼無法通過第二次。 - -| 情況 | 行為 | -|------|------| -| 不存在、已被消耗,或已因後端 TTL 而被淘汰 | `InvalidStateError` | -| 建立時有綁定、消耗時綁定不同或未提供;或建立時沒有綁定、消耗時卻提供了綁定 | `InvalidStateError`(項目也已被刪除) | -| 已取出但超過其 `expires_at` | `StateExpiredError`(項目也已被刪除,不會殘留) | -| 已取出但內容不是有效的 `StateData` JSON | `StateDataError`(項目也已被刪除) | -| 其他情況 | 回傳 `StateData` | - -一般情況下,後端 TTL 會先移除已過期的 state,因此過期的 state 通常會以 `InvalidStateError` 而非 `StateExpiredError` 呈現;兩者都請捕捉。在 Redis 與 Memcached 上,完全無法解碼成快取項目的儲存值會被後端視為未命中,同樣會以 `InvalidStateError` 呈現。 - -### `validate_state(state) -> bool` {#validate_statestate-bool} - -不會消耗 state 的唯讀檢查:state 存在、可解析且尚未過期時回傳 `True`,否則回傳 `False`。它不會拋出 state 相關例外。 - -> [!WARNING] -> `validate_state()` **不會**消耗 state,因此單獨使用時無法防止重送攻擊。真正的防護是 `consume_state()`。`validate_state()` 只應用於與安全無關的判斷,例如「先探測,再決定要顯示哪個 UI」。 - -### `get_state_metadata(state) -> dict | None` {#get_state_metadatastate-dict-none} - -同樣不會消耗 state。回傳建立時儲存的 `metadata`;若 state 不存在、已過期或無法解析,則回傳 `None`。 - -### `delete_state(state) -> bool` {#delete_statestate-bool} - -手動刪除 state(例如使用者取消授權時)。回傳該 state 是否存在。它使用相同的原子操作 `get_and_delete()`,因此即使與 `consume_state()` 競爭,也最多只有一個呼叫者會得到 `True`。 - -## StateData {#statedata} - -```python -class StateData(BaseModel): - state: str # state 字串本身 - created_at: datetime # 建立時間(UTC) - expires_at: datetime # 過期時間(UTC) - metadata: dict[str, Any] # 建立時附加的 metadata - binding_hash: str | None # binding 的 SHA-256;未綁定的 state 為 None -``` - -`expires_at` 是儲存在資料內的邏輯過期時間,與後端 TTL 無關。後端 TTL 到期時,項目就會消失;`expires_at` 則確保後端仍保留、但在邏輯上已過期的項目同樣會被拒絕。 - -## 依賴注入與 proxy {#dependency-injection-and-proxy} - -```python -from fastapi_cachex.state import StateManagerDep, StateManagerProxy, get_state_manager - - -# 1. 直接使用型別註記(最常見) -@app.get("/login") -async def login(states: StateManagerDep): ... - - -# 2. 自訂實例(例如不同的前綴或 TTL):在啟動時註冊, -# 依賴注入就會回傳它 -StateManagerProxy.set(StateManager(key_prefix="csrf:", default_ttl=300)) -``` - -尚未註冊任何實例時,`get_state_manager()`(`StateManagerDep` 背後的依賴項)會在第一次使用時延遲建立一個以 `BackendProxy` 的後端為基礎的預設 `StateManager`,並將它註冊。它不會退回使用 `MemoryBackend`:若尚未設定後端,請求會以 `BackendNotFoundError` 失敗。 - -## 例外 {#exceptions} - -``` -CacheXError -└── StateError - ├── InvalidStateError # 不存在、已被消耗或綁定不符 - ├── StateExpiredError # 已過期 - └── StateDataError # 內容格式錯誤 -``` - -## 注意事項 {#notes} - -- **後端必須在多個行程之間共用。** 多 worker 部署請使用 Redis 或 Memcached。使用 `MemoryBackend` 時,state 只存在於建立它的行程中,因此落到其他 worker 的授權回呼會失敗。 -- **一次性保證來自後端的原子操作。** `get_and_delete()` 在 Redis 上是 `GETDEL`(需要 Redis 伺服器 6.2 或更新版本);在 Memcached 上是 `gets` 後接 `cas(..., exptime=-1)`(若中間有其他寫入者替換了值則會重試;連續 16 次都被替換時會拋出 `CacheXError`,而不是當成 state 不存在);在記憶體後端上則是在鎖內 `pop`。只實作抽象方法的自訂後端會退回使用 `BaseCacheBackend` 的非原子性版本,因此並行的重送可能兩邊都成功。這種情況請覆寫 `get_and_delete()`。 -- 不要在 state 中存放敏感資料。`metadata` 會以明文 JSON 存放在快取後端中。 -- **日誌中絕不會出現 state 本身。** 來自 `fastapi_cachex.state.manager` 的日誌以 `state_ref` 識別 state,也就是其 SHA-256 的前 12 個十六進位字元;你可以從已知的 state 計算出它來比對。被 `consume_state()` 拒絕的未知、已過期或綁定不符的 state 以 INFO 等級記錄,因為那是常見的用戶端輸入(`validate_state()` 與 `get_state_metadata()` 對不存在或已過期的 state 則以 DEBUG 等級記錄);格式錯誤的儲存資料則以 WARNING 等級記錄一次。 diff --git a/i18n/zh-TW/docs/index.md b/i18n/zh-TW/docs/index.md index 6e8ed20..638ab25 100644 --- a/i18n/zh-TW/docs/index.md +++ b/i18n/zh-TW/docs/index.md @@ -23,7 +23,6 @@ FastAPI-CacheX 是 FastAPI 的高效能快取擴充套件:提供支援 `Cache- - **HTTP 快取**:GET 路由專用的 `@cache` 裝飾器,支援 `Cache-Control`、`ETag` / `If-None-Match`(304)與單一路由的快取失效。 - **應用層快取**:`CacheManager` 可在自己的程式碼中快取任意 JSON 值,提供未命中時才計算的 `get_or_set()` 與原子性的「不存在才寫入」`add()`。 - **後端**:記憶體、Redis 與 Memcached,支援原子操作的計數器、一次性取值與鎖。 -- **Session 與 OAuth state(已棄用)**:簽署過的 Session 權杖與一次性的 OAuth state 權杖。兩者在 0.4.0 已棄用,並將在 0.5.0 移除,遷移方向請見[這裡](MIGRATING_0_4.md#session-state-deprecated)。 ## 安裝 {#installation} @@ -31,15 +30,14 @@ FastAPI-CacheX 是 FastAPI 的高效能快取擴充套件:提供支援 `Cache- uv add fastapi-cachex ``` -核心套件搭配記憶體後端即可使用;其他後端與 Session 的選用傳輸方式以 extra 提供: +核心套件搭配記憶體後端即可使用;其他後端以 extra 提供: | Extra | 安裝 | 帶入套件 | 用途 | |-------|------|---------|------| | `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`、`orjson` | `AsyncRedisCacheBackend` | | `memcached` | `uv add "fastapi-cachex[memcached]"` | `pymemcache` | `MemcachedBackend` | -| `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` | -Extra 可以組合:`uv add "fastapi-cachex[redis,jwt]"`。 +Extra 可以組合:`uv add "fastapi-cachex[redis,memcached]"`。 ## 快速開始 {#quick-start} @@ -84,17 +82,18 @@ async def report(cache: AppCache): > ``` > [!WARNING] -> 預設的快取鍵不包含使用者身分。需要驗證身分的端點請使用 `private=True`,或依使用者區分的 key builder 搭配 `cache_authorized=True`(否則帶有 `Authorization` 或 Session 的請求會繞過後端),詳見 [需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 +> 預設的快取鍵不包含使用者身分。需要驗證身分的端點請使用 `private=True`,或依使用者區分的 key builder 搭配 `cache_authorized=True`(否則帶有 `Authorization` 或不是空的 `request.session` 的請求會繞過後端),詳見 [需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 ## 文件 {#documentation} +- [遷移至 0.5.0](MIGRATING_0_5.md):0.5.0 移除的功能,以及如何從 0.4.x 升級 - [遷移至 0.4.0](MIGRATING_0_4.md):0.4.0 的變更,以及如何從 0.3.x 升級 +- [從 fastapi-cache2 遷移](MIGRATING_FROM_FASTAPI_CACHE2.md):它的 API 對應到本套件的哪些功能,以及行為上的差異 - [何時使用](COMPARISON.md):與 fastapi-cache2、cashews、aiocache 及 CDN 的比較,以及什麼時候其他選擇更合適 - [HTTP 快取](HTTP_CACHING.md):`@cache` 裝飾器、Cache-Control 指令、快取鍵、快取失效與監控路由 - [應用層快取](APP_CACHE.md):`CacheManager` - [後端](BACKENDS.md):選擇與設定後端、原子操作的基本功能 - [分散式鎖](LOCK.md):以 `CacheLock` 在多個行程之間互斥 -- 已棄用、0.5.0 移除:[Session 管理](SESSION.md)、[OAuth state](STATE.md)(一次性的 OAuth / CSRF state 權杖)與 [JWT claims](JWT_CLAIMS.md) - [快取流程](CACHE_FLOW.md):快取請求內部的處理流程 - [可執行範例](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples)(英文):每個功能一個完整的應用程式,皆由測試套件涵蓋 - [API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)(英文) diff --git a/pyproject.toml b/pyproject.toml index 5662409..55b232f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,13 +26,16 @@ classifiers = [ "Typing :: Typed", ] keywords = ["fastapi", "cache", "etag", "cache-control", "redis", "memcached", "in-memory"] -# Floors are checked by the `lowest` tox env (`uv_resolution = lowest-direct`). +# Floors are checked by the `lowest` tox env (`uv_resolution = lowest-direct`), +# which also pins starlette to the oldest release the fastapi floor accepts. +# Starlette is not a direct requirement: the package imports only long-stable +# starlette APIs and takes whatever version fastapi allows. 0.128.2 is the +# oldest fastapi the whole suite passes on; 0.128.1 and older reject a +# dependency that returns a `Response`, and 0.127.x and older call pydantic +# APIs that pydantic 2.7 deprecates while building the OpenAPI schema. dependencies = [ - "fastapi>=0.133.0", - "itsdangerous>=1.1.0", + "fastapi>=0.128.2", "pydantic>=2.7.0", - # Imported directly; `starlette.middleware.sessions.Session` is new in 1.0. - "starlette>=1.0.0", ] [project.urls] @@ -45,10 +48,11 @@ Documentation = "https://fastapi-cachex.readthedocs.io/" dev = [ "coverage>=7.8.0", "httpx2>=2.13.1", + # Starlette's SessionMiddleware, which tests of the session bypass use. + "itsdangerous>=1.1.0", "mypy>=1.15.0", "orjson>=3.10.16", "pre-commit>=4.2.0", - "PyJWT>=2.9.0", "pymemcache>=4.0.0", "pytest>=8.3.5", "pytest-asyncio>=0.26.0", @@ -71,7 +75,6 @@ docs = [ [project.optional-dependencies] memcached = ["pymemcache>=4.0.0"] redis = ["redis[hiredis]>=5.3.0", "orjson>=3.4.7"] -jwt = ["PyJWT>=2.9.0"] [build-system] requires = ["uv_build>=0.12,<0.13"] @@ -95,15 +98,11 @@ asyncio_default_fixture_loop_scope = "function" # with pytest.warns or opts out with a narrow filterwarnings mark. filterwarnings = [ "error", - # starlette 1.0.0 (our floor, run by the `lowest` tox env) imports - # TestClient through an alias that newer anyio deprecates. Later starlette - # releases no longer use it; nothing in this package or its tests does. + # starlette 0.40.0 (the oldest the fastapi floor accepts, run by the + # `lowest` tox env) imports TestClient through an alias that newer anyio + # deprecates. Later starlette releases no longer use it; nothing in this + # package or its tests does. "ignore:The anyio.abc.BlockingPortal alias is deprecated:DeprecationWarning", - # Session and OAuth state are deprecated (#420) and warn once, on first - # import; their own tests keep running until 0.5.0 removes them. - # tests/test_session_state_deprecation.py checks the warnings themselves. - "ignore:fastapi_cachex.session is deprecated:FutureWarning", - "ignore:fastapi_cachex.state is deprecated:FutureWarning", ] [tool.ruff.lint] @@ -112,7 +111,6 @@ ignore = [ "ANN401", "COM812", # redundant rule, use formatter to fix "FBT001", "FBT002", # Boolean arguments are part of Cache-Control API design - "C901", # allow slightly complex session flow "CPY001", # no copyright header convention in this project (stabilized in ruff 0.16) ] preview = true @@ -132,10 +130,7 @@ keep-runtime-typing = true [tool.ruff.lint.per-file-ignores] "scripts/**" = ["INP001"] # Standalone tooling, not an importable package -"examples/**" = [ - "INP001", # Standalone apps, run by path; not an importable package - "S106", # token_format="jwt" is not a password -] +"examples/**" = ["INP001"] # Standalone apps, run by path; not an importable package "tests/**" = [ "D", "S101", @@ -151,7 +146,7 @@ keep-runtime-typing = true "PT031" # Single statement in pytest.warns is acceptable ] "fastapi_cachex/cache.py" = [ - "PLR0913", "PLR0915", "PLR0911", "PLR0912", # Many arguments/statements/returns/branches needed for flexible caching logic + "PLR0913", "PLR0915", "PLR0911", "PLR0912", "C901", # Many arguments/statements/returns/branches needed for flexible caching logic "S101", # Internal invariant guard, not a validation shortcut ] "fastapi_cachex/_cache_control.py" = ["PLR0913"] # One keyword per @cache option @@ -161,10 +156,6 @@ keep-runtime-typing = true # keyword-only would break the public API. "fastapi_cachex/backends/redis.py" = ["PLR0913", "PLR0917", "PLC0415"] # Optional dependency, Redis config "fastapi_cachex/lock.py" = ["PLR0913", "PLR0917"] # Configurable lock parameters -"fastapi_cachex/session/manager.py" = [ - "PLR0915", # Session/security validation branches needed - "S105", # token_format = "simple" / "jwt" is not a password -] [tool.ruff.format] docstring-code-format = true diff --git a/tests/backends/test_base.py b/tests/backends/test_base.py index 03f8e8a..9a32cb6 100644 --- a/tests/backends/test_base.py +++ b/tests/backends/test_base.py @@ -1,6 +1,7 @@ """The non-abstract helpers on ``BaseCacheBackend`` must work for third-party subclasses that only implement the abstract methods.""" +import warnings from datetime import timedelta from typing import Any @@ -214,24 +215,26 @@ async def delete(self, key: str) -> None: # type: ignore[override] @pytest.mark.parametrize( ("call", "expected"), [ - (lambda b, e: b.get_and_delete("k"), "entry"), - (lambda b, e: b.delete_if_equals("k", e), True), - (lambda b, e: b.delete_many(["k"]), 1), + (lambda b, e: b.get_and_delete("k"), None), + (lambda b, e: b.delete_if_equals("k", e), False), + (lambda b, e: b.delete_many(["k"]), 0), ], ids=["get_and_delete", "delete_if_equals", "delete_many"], ) -async def test_fallbacks_count_a_none_delete_as_removed_and_warn( +async def test_fallbacks_count_a_none_delete_as_not_removed( call: Any, expected: object ) -> None: - """0.3.x semantics until 0.5.0: the key is gone, so the caller won.""" + """Since 0.5.0 a ``None`` from ``delete`` is falsy like ``False``, with no warning. + + 0.4.x counted it as removed and warned (#421). + """ backend = LegacyDictBackend() entry = CacheEntry(fingerprint="e", content=b"v") await backend.set("k", entry) - with pytest.warns( - FutureWarning, match=r"LegacyDictBackend\.delete\(\) returned None" - ): + with warnings.catch_warnings(): + warnings.simplefilter("error") result = await call(backend, entry) - assert result == (entry if expected == "entry" else expected) + assert result == expected assert "k" not in backend.store diff --git a/tests/backends/test_memory.py b/tests/backends/test_memory.py index 63d5c11..6074828 100644 --- a/tests/backends/test_memory.py +++ b/tests/backends/test_memory.py @@ -385,7 +385,7 @@ async def test_memory_backend_clear_pattern_separator_less_keys( memory_backend: MemoryBackend, ): """clear_pattern must also match keys with no http:v2|method|host|path format, - e.g. CacheManager ("cache:...") or StateManager ("oauth_state:...") keys. + e.g. CacheManager ("cache:...") or CacheLock ("lock:...") keys. """ value1 = CacheEntry(fingerprint="e1", content=b"v1") value2 = CacheEntry(fingerprint="e2", content=b"v2") @@ -754,8 +754,8 @@ async def test_write_only_use_starts_the_cleanup_task( ): """A backend that is only written to still needs its sweeper running. - Only `get` used to start it, so a write-mostly caller — `StateManager` - creates states without ever reading them back through `get` — accumulated + Only `get` used to start it, so a write-mostly caller (one that stores + entries without ever reading them back through `get`) accumulated expired entries with nothing to remove them. """ backend = MemoryBackend(cleanup_interval=1) diff --git a/tests/backends/test_ttl_contract.py b/tests/backends/test_ttl_contract.py index f321861..2a134b3 100644 --- a/tests/backends/test_ttl_contract.py +++ b/tests/backends/test_ttl_contract.py @@ -26,7 +26,6 @@ from fastapi_cachex.backends.base import validate_ttl from fastapi_cachex.backends.memory import MemoryBackend from fastapi_cachex.manager import CacheManager -from fastapi_cachex.state import StateManager from fastapi_cachex.types import CacheEntry from tests.backends.test_base import DictBackend from tests.live_servers import UNCONNECTED_PORT @@ -115,20 +114,6 @@ async def test_cache_manager_rejects_invalid_ttl( assert backend.store == {} -@pytest.mark.parametrize(("ttl", "error", "match"), BAD_TTLS) -async def test_state_manager_rejects_invalid_ttl( - ttl: Any, error: type[Exception], match: str -) -> None: - backend = DictBackend() - with pytest.raises(error, match=match): - StateManager(backend, default_ttl=ttl) - - manager = StateManager(backend) - with pytest.raises(error, match=match): - await manager.create_state(ttl=ttl) - assert backend.store == {} - - @pytest.mark.parametrize( ("delta", "error", "match"), [ diff --git a/tests/conftest.py b/tests/conftest.py index 1b67250..e742de7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,8 +4,6 @@ import os import time from collections.abc import AsyncGenerator -from datetime import datetime -from datetime import tzinfo from types import SimpleNamespace from typing import Any @@ -21,12 +19,6 @@ from fastapi_cachex.backends.redis import AsyncRedisCacheBackend from fastapi_cachex.manager_proxy import CacheManagerProxy from fastapi_cachex.proxy import BackendProxy -from fastapi_cachex.session import manager as session_manager -from fastapi_cachex.session import models as session_models -from fastapi_cachex.session.proxy import SessionManagerProxy -from fastapi_cachex.state import manager as state_manager -from fastapi_cachex.state import models as state_models -from fastapi_cachex.state.proxy import StateManagerProxy _PENDING_TASK_MESSAGE = "Task was destroyed but it is pending!" @@ -84,7 +76,7 @@ def pytest_terminal_summary(terminalreporter: Any) -> None: # Every proxy is a process-wide singleton, so whatever one test installs is # still installed for the next one. -_PROXIES = (CacheManagerProxy, SessionManagerProxy, StateManagerProxy) +_PROXIES = (CacheManagerProxy,) @pytest_asyncio.fixture @@ -183,9 +175,9 @@ def record(cls: type[Any], *args: Any, **kwargs: Any) -> Any: def reset_proxy_singletons(): """Clear the remaining proxy singletons around every test. - `BackendProxy` has `setup_default_backend`; the other three had nothing, - so a test that failed before reaching its own cleanup left its manager - installed for every test that ran afterwards. + `BackendProxy` has `setup_default_backend`; `CacheManagerProxy` had + nothing, so a test that failed before reaching its own cleanup left its + manager installed for every test that ran afterwards. """ for proxy in _PROXIES: proxy.set(None) @@ -228,19 +220,11 @@ async def wait(self, seconds: float, backend: BaseCacheBackend) -> None: def clock(monkeypatch: pytest.MonkeyPatch) -> Clock: """Point every time read behind a TTL or expiry check at a `Clock`. - That is `time.time()` in the memory backend and `datetime.now()` in the - session and state modules. PyJWT and live servers keep real time. + That is `time.time()` in the memory backend and the monotonic clock and + sleep of `CacheManager`. Live servers keep real time. """ clock = Clock() - - class ClockDatetime(datetime): - @classmethod - def now(cls, tz: tzinfo | None = None) -> datetime: # type: ignore[override] - return datetime.fromtimestamp(clock.now, tz) - monkeypatch.setattr(memory, "time", SimpleNamespace(time=clock.time)) monkeypatch.setattr(manager, "_monotonic", clock.monotonic) monkeypatch.setattr(manager, "_sleep", clock.sleep) - for module in (session_manager, session_models, state_manager, state_models): - monkeypatch.setattr(module, "datetime", ClockDatetime) return clock diff --git a/tests/session/__init__.py b/tests/session/__init__.py deleted file mode 100644 index e3e1ad7..0000000 --- a/tests/session/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Session management tests.""" diff --git a/tests/session/conftest.py b/tests/session/conftest.py deleted file mode 100644 index 42b6b6c..0000000 --- a/tests/session/conftest.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Shared fixtures for the session tests.""" - -import pytest - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.manager import SessionManager - - -@pytest.fixture -def backend() -> MemoryBackend: - """Create a memory backend for testing.""" - return MemoryBackend() - - -@pytest.fixture -def config() -> SessionConfig: - """Create session config for testing. - - TestClient talks plain HTTP, which the default ``__Host-session`` cookie - with the Secure flag is not sent back over, so the cookie settings are - explicit (#256). - """ - return SessionConfig( - secret_key="a" * 32, cookie_name="session", cookie_https_only=False - ) - - -@pytest.fixture -def manager(backend: MemoryBackend, config: SessionConfig) -> SessionManager: - """Create session manager for testing.""" - return SessionManager(backend, config) diff --git a/tests/session/test_cache_authorized_private.py b/tests/session/test_cache_authorized_private.py deleted file mode 100644 index 200e220..0000000 --- a/tests/session/test_cache_authorized_private.py +++ /dev/null @@ -1,183 +0,0 @@ -"""`cache_authorized` answers are private, and a session read sets `Vary` (#372). - -`cache_authorized=True` lets a request with credentials use the backend under -a per-caller key, but its response kept the decorator's header: no `private`, -and no `Vary` unless the handler read `request.session`. A CDN in front of the -app keys on the URL alone, so `max-age=60, must-revalidate` (or any header, for -a session token in `X-Session-Token` or a cookie) let it serve one user's -response to the next. -""" - -import pytest -from fastapi import FastAPI -from fastapi import Request -from fastapi.testclient import TestClient - -from fastapi_cachex import build_cache_key -from fastapi_cachex import cache -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.dependencies import AuthenticatedSession -from fastapi_cachex.session.dependencies import OptionalSession -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import SessionUser - -_AUTH = {"Authorization": "Bearer alice"} - - -def _per_caller(request: Request) -> str: - session = getattr(request.state, "__fastapi_cachex_session", None) - user = session.user.user_id if session and session.user else "" - return build_cache_key(request, request.headers.get("authorization", ""), user) - - -def _vary(response: object) -> set[str]: - header = response.headers.get("vary", "") # type: ignore[attr-defined] - return {name.strip().lower() for name in header.split(",") if name.strip()} - - -@pytest.mark.parametrize( - ("cache_kwargs", "expected"), - [ - ({}, "private, max-age=60"), - ({"must_revalidate": True}, "private, max-age=60, must-revalidate"), - ( - {"stale": "revalidate", "stale_ttl": 30}, - "private, max-age=60, stale-while-revalidate=30", - ), - ], -) -def test_authorization_answers_are_private_on_miss_hit_and_304( - cache_kwargs: dict[str, object], expected: str -) -> None: - app = FastAPI() - calls: list[str] = [] - - @app.get("/me") - @cache(ttl=60, key_builder=_per_caller, cache_authorized=True, **cache_kwargs) # type: ignore[arg-type] - async def me(request: Request) -> dict[str, str]: - calls.append(request.headers["authorization"]) - return {"me": request.headers["authorization"]} - - client = TestClient(app) - miss = client.get("/me", headers=_AUTH) - hit = client.get("/me", headers=_AUTH) - revalidated = client.get( - "/me", headers={**_AUTH, "If-None-Match": miss.headers["etag"]} - ) - - assert calls == ["Bearer alice"] # the backend is still used - assert "age" in hit.headers - assert revalidated.status_code == 304 - for response in (miss, hit, revalidated): - assert response.headers["cache-control"] == expected - - -def test_requests_without_credentials_keep_the_decorator_header() -> None: - app = FastAPI() - - @app.get("/me") - @cache(ttl=60, key_builder=_per_caller, cache_authorized=True) - async def me() -> dict[str, bool]: - return {"ok": True} - - client = TestClient(app) - client.get("/me") - - assert client.get("/me").headers["cache-control"] == "max-age=60" - - -def test_public_routes_keep_public() -> None: - app = FastAPI() - - @app.get("/catalog") - @cache(ttl=60, public=True) - async def catalog() -> dict[str, bool]: - return {"ok": True} - - response = TestClient(app).get("/catalog", headers=_AUTH) - - assert response.headers["cache-control"] == "public, max-age=60" - - -def _session_app(manager: SessionManager, config: SessionConfig) -> FastAPI: - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/me") - @cache(ttl=60, key_builder=_per_caller, cache_authorized=True) - async def me(session: AuthenticatedSession) -> dict[str, str]: - assert session.user is not None - return {"user": session.user.user_id} - - @app.get("/greeting") - async def greeting(session: OptionalSession) -> dict[str, str]: - return {"hello": session.user.user_id if session and session.user else "guest"} - - @app.get("/plain") - async def plain() -> dict[str, bool]: - return {"ok": True} - - return app - - -async def _token(manager: SessionManager, user_id: str) -> str: - _session, token = await manager.create_session(user=SessionUser(user_id=user_id)) - return token - - -async def test_header_session_answer_is_private_and_varies( - manager: SessionManager, config: SessionConfig -) -> None: - client = TestClient(_session_app(manager, config)) - token = await _token(manager, "alice") - - for _ in range(2): # miss, then hit - response = client.get("/me", headers={config.header_name: token}) - assert response.json() == {"user": "alice"} - assert response.headers["cache-control"] == "private, max-age=60" - assert config.header_name.lower() in _vary(response) - - -async def test_cookie_session_answer_is_private_and_varies_on_cookie( - manager: SessionManager, config: SessionConfig -) -> None: - client = TestClient(_session_app(manager, config)) - client.cookies.set(config.cookie_name, await _token(manager, "bob")) - - response = client.get("/me") - - assert response.json() == {"user": "bob"} - assert response.headers["cache-control"] == "private, max-age=60" - assert "cookie" in _vary(response) - - -async def test_a_session_dependency_read_varies_without_a_session( - manager: SessionManager, config: SessionConfig -) -> None: - """A guest's answer depends on the token sources as much as a user's does.""" - client = TestClient(_session_app(manager, config)) - - guest = client.get("/greeting") - alice = client.get( - "/greeting", headers={config.header_name: await _token(manager, "alice")} - ) - - assert guest.json() == {"hello": "guest"} - assert alice.json() == {"hello": "alice"} - assert {config.header_name.lower(), "cookie"} <= _vary(guest) - assert config.header_name.lower() in _vary(alice) - - -async def test_routes_that_do_not_read_the_session_do_not_vary( - manager: SessionManager, config: SessionConfig -) -> None: - client = TestClient(_session_app(manager, config)) - - response = client.get( - "/plain", headers={config.header_name: await _token(manager, "alice")} - ) - - assert "vary" not in response.headers diff --git a/tests/session/test_cache_session_bypass.py b/tests/session/test_cache_session_bypass.py deleted file mode 100644 index c483adb..0000000 --- a/tests/session/test_cache_session_bypass.py +++ /dev/null @@ -1,268 +0,0 @@ -"""`@cache` must not share a response to a request that arrived with a session (#319). - -Only ``Authorization`` bypassed the shared backend, so a session token sent as -the ``X-Session-Token`` header or the session cookie gave one user's cached -response to the next, and an anonymous session's cart to every visitor. -""" - -import logging - -import pytest -from fastapi import FastAPI -from fastapi import Request -from fastapi.testclient import TestClient -from starlette.middleware.sessions import ( - SessionMiddleware as StarletteSessionMiddleware, -) - -from fastapi_cachex import cache -from fastapi_cachex.proxy import BackendProxy -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.dependencies import AuthenticatedSession -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import SessionUser -from fastapi_cachex.types import CACHE_KEY_SEPARATOR - -_PRIVATE = "private, max-age=60" - - -def _key(path: str) -> str: - """The key `default_key_builder` produces for a TestClient GET.""" - return f"http:v2|GET|testserver|{path}|" - - -def _app( - manager: SessionManager, config: SessionConfig, **cache_kwargs: object -) -> FastAPI: - """Routes that answer from the session, under the session middleware.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/whoami") - @cache(ttl=60, **cache_kwargs) # type: ignore[arg-type] - async def whoami(session: AuthenticatedSession) -> dict[str, str]: - assert session.user is not None - return {"user": session.user.user_id} - - @app.get("/cart") - @cache(ttl=60) - async def cart(request: Request) -> dict[str, list[str]]: - return {"cart": request.session.get("cart", [])} - - @app.get("/add") - async def add(request: Request, item: str) -> dict[str, bool]: - request.session["cart"] = [*request.session.get("cart", []), item] - return {"ok": True} - - return app - - -async def _token(manager: SessionManager, user_id: str) -> str: - _session, token = await manager.create_session(user=SessionUser(user_id=user_id)) - return token - - -async def test_issue_repro_session_header( - manager: SessionManager, config: SessionConfig -) -> None: - """The report in #319: bob must not get alice's /whoami.""" - client = TestClient(_app(manager, config)) - alice = {config.header_name: await _token(manager, "alice")} - bob = {config.header_name: await _token(manager, "bob")} - - assert client.get("/whoami", headers=alice).json() == {"user": "alice"} - response = client.get("/whoami", headers=bob) - - assert response.json() == {"user": "bob"} - assert response.headers["Cache-Control"] == _PRIVATE - assert await BackendProxy.get().get(_key("/whoami")) is None - - -async def test_session_cookie_bypasses_the_backend( - manager: SessionManager, config: SessionConfig -) -> None: - app = _app(manager, config) - alice = TestClient( - app, cookies={config.cookie_name: await _token(manager, "alice")} - ) - bob = TestClient(app, cookies={config.cookie_name: await _token(manager, "bob")}) - - assert alice.get("/whoami").json() == {"user": "alice"} - response = bob.get("/whoami") - - assert response.json() == {"user": "bob"} - assert response.headers["Cache-Control"] == _PRIVATE - assert await BackendProxy.get().get(_key("/whoami")) is None - - -async def test_bearer_session_bypasses_the_backend( - manager: SessionManager, config: SessionConfig -) -> None: - """Already covered by the `Authorization` rule; kept so it stays that way.""" - client = TestClient(_app(manager, config)) - alice = {"Authorization": f"Bearer {await _token(manager, 'alice')}"} - bob = {"Authorization": f"Bearer {await _token(manager, 'bob')}"} - - client.get("/whoami", headers=alice) - - assert client.get("/whoami", headers=bob).json() == {"user": "bob"} - - -async def test_anonymous_session_is_not_shared( - manager: SessionManager, config: SessionConfig -) -> None: - """A cart in an anonymous session reaches neither bob nor a new visitor.""" - app = _app(manager, config) - alice = TestClient(app) - alice.get("/add", params={"item": "apple"}) - - assert alice.get("/cart").json() == {"cart": ["apple"]} - assert TestClient(app).get("/cart").json() == {"cart": []} - bob = TestClient(app) - bob.get("/add", params={"item": "pear"}) - assert bob.get("/cart").json() == {"cart": ["pear"]} - - -async def test_request_without_a_session_is_still_cached( - manager: SessionManager, config: SessionConfig -) -> None: - app = _app(manager, config) - calls = {"n": 0} - - @app.get("/news") - @cache(ttl=60) - async def news() -> dict[str, int]: - calls["n"] += 1 - return {"n": calls["n"]} - - client = TestClient(app) - client.get("/news") - response = client.get("/news") - - assert response.json() == {"n": 1} - assert response.headers["Cache-Control"] == "max-age=60" - - -@pytest.mark.parametrize("transport", ["header", "cookie"]) -async def test_token_that_resolves_to_no_session_does_not_bypass( - manager: SessionManager, config: SessionConfig, transport: str -) -> None: - """Random or expired tokens must not keep requests away from the cache.""" - app = _app(manager, config) - calls = {"n": 0} - - @app.get("/news") - @cache(ttl=60) - async def news() -> dict[str, int]: - calls["n"] += 1 - return {"n": calls["n"]} - - TestClient(app).get("/news") - if transport == "header": - response = TestClient(app).get("/news", headers={config.header_name: "bogus"}) - else: - response = TestClient(app, cookies={config.cookie_name: "bogus"}).get("/news") - - assert response.json() == {"n": 1} - assert calls["n"] == 1 - - -async def test_public_route_is_shared_across_sessions( - manager: SessionManager, config: SessionConfig -) -> None: - """As for `Authorization`: `public` says the response suits every caller.""" - app = _app(manager, config) - calls = {"n": 0} - - @app.get("/catalog") - @cache(ttl=60, public=True) - async def catalog() -> dict[str, int]: - calls["n"] += 1 - return {"n": calls["n"]} - - client = TestClient(app) - client.get("/catalog", headers={config.header_name: await _token(manager, "alice")}) - response = client.get( - "/catalog", headers={config.header_name: await _token(manager, "bob")} - ) - - assert response.json() == {"n": 1} - assert response.headers["Cache-Control"] == "public, max-age=60" - - -async def test_cache_authorized_caches_per_session_entries( - manager: SessionManager, config: SessionConfig -) -> None: - def per_user_key(request: Request) -> str: - session = request.state.__fastapi_cachex_session - return f"{request.url.path}{CACHE_KEY_SEPARATOR}{session.user.user_id}" - - client = TestClient( - _app(manager, config, key_builder=per_user_key, cache_authorized=True) - ) - alice = {config.header_name: await _token(manager, "alice")} - bob = {config.header_name: await _token(manager, "bob")} - - client.get("/whoami", headers=alice) - - assert client.get("/whoami", headers=bob).json() == {"user": "bob"} - assert await BackendProxy.get().get("/whoami|alice") is not None - assert await BackendProxy.get().get("/whoami|bob") is not None - - -def test_bypass_is_logged( - manager: SessionManager, config: SessionConfig, caplog: pytest.LogCaptureFixture -) -> None: - app = _app(manager, config) - client = TestClient(app) - client.get("/add", params={"item": "apple"}) - - with caplog.at_level(logging.DEBUG, logger="fastapi_cachex.cache"): - client.get("/cart") - - assert "Session present; bypassing the backend for path=/cart" in caplog.text - - -def _starlette_app() -> tuple[FastAPI, dict[str, int]]: - app = FastAPI() - app.add_middleware(StarletteSessionMiddleware, secret_key="s" * 32) - calls = {"n": 0} - - @app.get("/cart") - @cache(ttl=60) - async def cart(request: Request) -> dict[str, object]: - calls["n"] += 1 - return {"cart": request.session.get("cart", []), "n": calls["n"]} - - @app.get("/add") - async def add(request: Request, item: str) -> dict[str, bool]: - request.session["cart"] = [item] - return {"ok": True} - - return app, calls - - -async def test_starlette_session_with_data_bypasses_the_backend() -> None: - """Any session middleware: a non-empty `request.session` is per visitor.""" - app, _ = _starlette_app() - alice = TestClient(app) - alice.get("/add", params={"item": "apple"}) - - response = alice.get("/cart") - - assert response.json()["cart"] == ["apple"] - assert response.headers["Cache-Control"] == _PRIVATE - assert await BackendProxy.get().get(_key("/cart")) is None - - -def test_empty_starlette_session_is_still_cached() -> None: - app, calls = _starlette_app() - client = TestClient(app) - - client.get("/cart") - client.get("/cart") - - assert calls["n"] == 1 diff --git a/tests/session/test_client_ip.py b/tests/session/test_client_ip.py deleted file mode 100644 index 1e052bc..0000000 --- a/tests/session/test_client_ip.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tests for the public client-IP helpers used with session IP binding.""" - -import pytest -from fastapi import FastAPI -from fastapi import Request -from fastapi.testclient import TestClient -from pydantic import ValidationError -from starlette.requests import HTTPConnection - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session import FastAPICacheXSessionMiddleware -from fastapi_cachex.session import SessionConfig -from fastapi_cachex.session import SessionManager -from fastapi_cachex.session import SessionUser -from fastapi_cachex.session import get_client_ip -from fastapi_cachex.session.dependencies import ClientIPDep -from fastapi_cachex.session.dependencies import OptionalSession -from fastapi_cachex.session.dependencies import SessionManagerDep -from fastapi_cachex.session.proxy import SessionManagerProxy - - -def _connection(peer: str | None, headers: dict[str, str]) -> HTTPConnection: - return HTTPConnection( - { - "type": "http", - "client": (peer, 1234) if peer is not None else None, - "headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()], - } - ) - - -def test_get_client_ip_uses_forwarded_address_behind_trusted_proxy(): - config = SessionConfig(secret_key="a" * 32, trusted_proxies=["10.0.0.9"]) - connection = _connection("10.0.0.9", {"X-Forwarded-For": "198.51.100.5"}) - - assert get_client_ip(connection, config) == "198.51.100.5" - - -def test_get_client_ip_ignores_forwarded_address_from_untrusted_peer(): - config = SessionConfig(secret_key="a" * 32) - connection = _connection("10.0.0.9", {"X-Forwarded-For": "198.51.100.5"}) - - assert get_client_ip(connection, config) == "10.0.0.9" - - -def test_get_client_ip_without_peer(): - config = SessionConfig(secret_key="a" * 32) - - assert get_client_ip(_connection(None, {}), config) is None - - -@pytest.fixture -def proxied_app() -> FastAPI: - """An app with IP binding whose only peer (TestClient) is a trusted proxy.""" - config = SessionConfig( - secret_key="a" * 32, - ip_binding=True, - trusted_proxies=["testclient"], - cookie_name="session", - cookie_https_only=False, - ) - manager = SessionManager(MemoryBackend(), config) - SessionManagerProxy.set(manager) - - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.post("/login") - async def login( - manager: SessionManagerDep, client_ip: ClientIPDep - ) -> dict[str, str | None]: - session, token = await manager.create_session( - user=SessionUser(user_id="u1"), ip_address=client_ip - ) - return {"token": token, "ip": session.ip_address} - - @app.post("/login-peer") - async def login_peer( - request: Request, manager: SessionManagerDep - ) -> dict[str, str | None]: - _, token = await manager.create_session( - user=SessionUser(user_id="u1"), - ip_address=request.client.host if request.client else None, - ) - return {"token": token} - - @app.get("/me") - async def me(session: OptionalSession) -> dict[str, bool]: - return {"authenticated": session is not None} - - return app - - -def test_client_ip_dep_binds_the_address_the_middleware_checks(proxied_app: FastAPI): - client = TestClient(proxied_app) - forwarded = {"X-Forwarded-For": "198.51.100.5"} - - login = client.post("/login", headers=forwarded).json() - - assert login["ip"] == "198.51.100.5" - me = client.get("/me", headers={**forwarded, "X-Session-Token": login["token"]}) - assert me.json() == {"authenticated": True} - - -def test_peer_address_binding_fails_behind_trusted_proxy(proxied_app: FastAPI): - """The problem ClientIPDep solves: the proxy's address never matches.""" - client = TestClient(proxied_app) - forwarded = {"X-Forwarded-For": "198.51.100.5"} - - token = client.post("/login-peer", headers=forwarded).json()["token"] - - me = client.get("/me", headers={**forwarded, "X-Session-Token": token}) - assert me.json() == {"authenticated": False} - - -@pytest.mark.parametrize( - ("entry", "peer"), - [ - ("10.0.0.0/8", "10.20.30.40"), - ("10.0.0.8", "10.0.0.8"), - ("10.0.0.8/24", "10.0.0.200"), # host bits are ignored - ("2001:db8::/32", "2001:db8:1::5"), - ("2001:db8::1", "2001:DB8::1"), - ("10.0.0.0/8", "::ffff:10.1.2.3"), # dual-stack socket - ("testclient", "testclient"), - ], -) -def test_trusted_proxy_matches(entry: str, peer: str): - config = SessionConfig(secret_key="a" * 32, trusted_proxies=[entry]) - - assert config.is_trusted_proxy(peer) - - -@pytest.mark.parametrize( - ("entry", "peer"), - [ - ("10.0.0.0/8", "11.0.0.1"), - ("10.0.0.8", "10.0.0.9"), - ("2001:db8::/32", "2001:db9::1"), - ("10.0.0.0/8", "2001:db8::1"), - ("10.0.0.0/8", "testclient"), - ("testclient", "10.0.0.1"), - ], -) -def test_trusted_proxy_rejects(entry: str, peer: str): - config = SessionConfig(secret_key="a" * 32, trusted_proxies=[entry]) - - assert not config.is_trusted_proxy(peer) - - -@pytest.mark.parametrize("entry", ["10.0.0.0/33", "10.0.0.0/x", "proxy/8"]) -def test_malformed_cidr_entry_is_rejected(entry: str): - with pytest.raises(ValidationError, match="not a valid CIDR range"): - SessionConfig(secret_key="a" * 32, trusted_proxies=[entry]) - - -def test_forwarded_chain_skips_every_hop_inside_a_trusted_range(): - config = SessionConfig(secret_key="a" * 32, trusted_proxies=["10.0.0.0/8"]) - connection = _connection( - "10.0.0.9", {"X-Forwarded-For": "198.51.100.66, 203.0.113.5, 10.1.1.1"} - ) - - assert get_client_ip(connection, config) == "203.0.113.5" - - -def test_model_copy_update_uses_the_new_ranges(): - """model_copy(update=...) skips validation; matching must not be stale.""" - config = SessionConfig(secret_key="a" * 32, trusted_proxies=["10.0.0.0/8"]) - copied = config.model_copy(update={"trusted_proxies": ["192.168.0.0/16"]}) - - assert copied.is_trusted_proxy("192.168.1.1") - assert not copied.is_trusted_proxy("10.0.0.1") - - -def test_forwarded_chain_spans_every_header_line(): - """A caller-sent first line must not hide the line the proxy added.""" - config = SessionConfig(secret_key="a" * 32, trusted_proxies=["10.0.0.9"]) - connection = HTTPConnection( - { - "type": "http", - "client": ("10.0.0.9", 1234), - "headers": [ - (b"x-forwarded-for", b"198.51.100.5"), # sent by the caller - (b"x-forwarded-for", b"203.0.113.7"), # added by the proxy - ], - } - ) - - assert get_client_ip(connection, config) == "203.0.113.7" diff --git a/tests/session/test_conditional_saves.py b/tests/session/test_conditional_saves.py deleted file mode 100644 index fe9163c..0000000 --- a/tests/session/test_conditional_saves.py +++ /dev/null @@ -1,645 +0,0 @@ -"""Ordinary session saves are conditional (#128). - -A request that loaded a session must not bring it back after another request -deleted, invalidated or rotated it, whether by saving it or by rotating its -ID. Deleting, invalidating and expiring stay unconditional, so a security -action always wins. -""" - -import logging -from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING -from typing import Any - -import pytest -import pytest_asyncio -from fastapi import FastAPI -from fastapi import Request -from fastapi.testclient import TestClient - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session import login -from fastapi_cachex.session import rotate_session_id -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.exceptions import SessionInvalidError -from fastapi_cachex.session.exceptions import SessionNotFoundError -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import Session -from fastapi_cachex.session.models import SessionStatus -from fastapi_cachex.session.models import SessionUser -from fastapi_cachex.session.proxy import SessionManagerProxy -from fastapi_cachex.types import CacheEntry -from tests.live_servers import MEMCACHED_SERVER -from tests.live_servers import REDIS_HOST -from tests.live_servers import REDIS_PORT -from tests.live_servers import flush_memcached -from tests.live_servers import requires_memcached -from tests.live_servers import requires_redis -from tests.live_servers import requires_redis_package - -from .test_login import _auth -from .test_login import _token_sent - -if TYPE_CHECKING: - from fastapi_cachex.backends.base import BaseCacheBackend - - -@pytest_asyncio.fixture( - params=[ - pytest.param("memory", id="memory"), - pytest.param( - "redis", id="redis", marks=[requires_redis, requires_redis_package] - ), - pytest.param("memcached", id="memcached", marks=[requires_memcached]), - ] -) -async def any_backend(request: Any) -> AsyncGenerator["BaseCacheBackend", Any]: - """A memory backend, and live Redis / Memcached ones when available.""" - if request.param == "memory": - yield MemoryBackend() - return - if request.param == "redis": - from fastapi_cachex.backends import AsyncRedisCacheBackend - - redis_backend = AsyncRedisCacheBackend( - host=REDIS_HOST, port=REDIS_PORT, key_prefix="test_conditional_saves:" - ) - await redis_backend.clear() - yield redis_backend - await redis_backend.clear() - return - from fastapi_cachex.backends import MemcachedBackend - - memcached_backend = MemcachedBackend(servers=[MEMCACHED_SERVER]) - await flush_memcached(memcached_backend) - yield memcached_backend - await flush_memcached(memcached_backend) - - -@pytest.fixture -def any_manager( - any_backend: "BaseCacheBackend", config: SessionConfig -) -> SessionManager: - return SessionManager(any_backend, config) - - -async def _two_copies(manager: SessionManager) -> tuple[Session, Session, str]: - """A stored session read by two requests, and its token.""" - _session, token = await manager.create_session(SessionUser(user_id="alice")) - first, _ = await manager.get_session(token) - second, _ = await manager.get_session(token) - return first, second, token - - -# --- SessionManager ----------------------------------------------------------- - - -async def test_a_stale_save_cannot_restore_a_deleted_session( - any_manager: SessionManager, -) -> None: - stale, _other, token = await _two_copies(any_manager) - await any_manager.delete_session(stale.session_id) - - stale.data["cart"] = ["book"] - assert await any_manager.update_session(stale) is False - - assert await any_manager._load_session(stale.session_id) is None - with pytest.raises(SessionNotFoundError): - await any_manager.get_session(token) - - -async def test_a_stale_save_cannot_reactivate_an_invalidated_session( - any_manager: SessionManager, -) -> None: - stale, other, token = await _two_copies(any_manager) - await any_manager.invalidate_session(other) - - stale.data["cart"] = ["book"] - assert await any_manager.update_session(stale) is False - - stored = await any_manager._load_session(stale.session_id) - assert stored is not None - assert stored.status == SessionStatus.INVALIDATED - assert "cart" not in stored.data - with pytest.raises(SessionInvalidError): - await any_manager.get_session(token) - - -async def test_a_stale_save_cannot_restore_the_id_before_a_rotation( - any_manager: SessionManager, -) -> None: - """After a rotation, the old token must stay dead.""" - stale, other, old_token = await _two_copies(any_manager) - old_id = stale.session_id - _rotated, new_token = await any_manager.regenerate_session_id(other) - - stale.data["cart"] = ["book"] - assert await any_manager.update_session(stale) is False - - assert await any_manager._load_session(old_id) is None - with pytest.raises(SessionNotFoundError): - await any_manager.get_session(old_token) - assert (await any_manager.get_session(new_token))[0].data == {} - - -async def test_the_first_of_two_concurrent_saves_wins( - any_manager: SessionManager, -) -> None: - """Two tabs change one session: the second save is dropped, not merged (#376).""" - first, second, _token = await _two_copies(any_manager) - - first.data["cart"] = ["book"] - assert await any_manager.update_session(first) is True - second.data["cart"] = ["pen"] - assert await any_manager.update_session(second) is False - - stored = await any_manager._load_session(first.session_id) - assert stored is not None - assert stored.data == {"cart": ["book"]} - - -async def test_saves_compare_against_the_objects_own_last_write( - any_manager: SessionManager, -) -> None: - session, _other, _token = await _two_copies(any_manager) - - for count in range(3): - session.data["count"] = count - assert await any_manager.update_session(session) is True - - stored = await any_manager._load_session(session.session_id) - assert stored is not None - assert stored.data == {"count": 2} - - -async def test_unconditional_writes_win_over_a_newer_save( - manager: SessionManager, -) -> None: - """Invalidating a stale copy still invalidates: security actions always win.""" - stale, newer, token = await _two_copies(manager) - newer.data["cart"] = ["book"] - assert await manager.update_session(newer) is True - - await manager.invalidate_session(stale) - - with pytest.raises(SessionInvalidError): - await manager.get_session(token) - - -async def test_a_session_never_read_is_saved_only_if_absent( - manager: SessionManager, -) -> None: - """A hand-built ``Session`` holds no stored entry, so it expects no record.""" - fresh = Session(data={"a": 1}) - assert await manager.update_session(fresh) is True - assert (await manager._load_session(fresh.session_id)) is not None - - existing, _token = await manager.create_session(SessionUser(user_id="alice")) - impostor = Session(session_id=existing.session_id, data={"planted": True}) - assert await manager.update_session(impostor) is False - stored = await manager._load_session(existing.session_id) - assert stored is not None - assert stored.data == {} - - -async def test_a_dropped_save_is_logged( - manager: SessionManager, caplog: pytest.LogCaptureFixture -) -> None: - stale, _other, _token = await _two_copies(manager) - await manager.delete_session(stale.session_id) - - with caplog.at_level(logging.INFO, logger="fastapi_cachex.session.manager"): - assert await manager.update_session(stale) is False - - assert any( - record.levelno == logging.INFO and "Session save dropped" in record.message - for record in caplog.records - ) - - -# --- Sliding renewal ---------------------------------------------------------- - - -@pytest.fixture -def renewing_config() -> SessionConfig: - """A config under which every lookup renews the session.""" - return SessionConfig( - secret_key="a" * 32, - cookie_name="session", - cookie_https_only=False, - sliding_threshold=1.0, - ) - - -@pytest.fixture -def renewing(backend: MemoryBackend, renewing_config: SessionConfig) -> SessionManager: - """A manager whose every lookup renews the session.""" - return SessionManager(backend, renewing_config) - - -async def test_a_save_after_a_renewal_compares_against_the_renewal( - renewing: SessionManager, -) -> None: - _session, token = await renewing.create_session(SessionUser(user_id="alice")) - session, renewed = await renewing.get_session(token) - assert renewed is not None - - session.data["cart"] = ["book"] - assert await renewing.update_session(session) is True - - -def _before_first_conditional_write( - backend: MemoryBackend, monkeypatch: pytest.MonkeyPatch, action: Any -) -> None: - """Run ``action`` once, just before the first ``set_if_equals`` compares.""" - original = backend.set_if_equals - pending = [action] - - async def racing( - key: str, expected: CacheEntry, value: CacheEntry, ttl: int | None = None - ) -> bool: - if pending: - await pending.pop()() - return await original(key, expected, value, ttl=ttl) - - monkeypatch.setattr(backend, "set_if_equals", racing) - - -async def test_a_renewal_that_loses_to_an_invalidation_is_refused( - renewing: SessionManager, - backend: MemoryBackend, - monkeypatch: pytest.MonkeyPatch, -) -> None: - session, token = await renewing.create_session(SessionUser(user_id="alice")) - - async def invalidate_elsewhere() -> None: - other = await renewing._load_session(session.session_id) - assert other is not None - await renewing.invalidate_session(other) - - _before_first_conditional_write(backend, monkeypatch, invalidate_elsewhere) - - with pytest.raises(SessionInvalidError): - await renewing.get_session(token) - stored = await renewing._load_session(session.session_id) - assert stored is not None - assert stored.status == SessionStatus.INVALIDATED - - -async def test_a_renewal_that_loses_to_a_save_reads_the_session_again( - renewing: SessionManager, - backend: MemoryBackend, - monkeypatch: pytest.MonkeyPatch, -) -> None: - session, token = await renewing.create_session(SessionUser(user_id="alice")) - - async def save_elsewhere() -> None: - other = await renewing._load_session(session.session_id) - assert other is not None - other.data["cart"] = ["book"] - assert await renewing.update_session(other) is True - - _before_first_conditional_write(backend, monkeypatch, save_elsewhere) - - loaded, _renewed = await renewing.get_session(token) - - assert loaded.data == {"cart": ["book"]} - stored = await renewing._load_session(session.session_id) - assert stored is not None - assert stored.data == {"cart": ["book"]} - - -async def test_a_renewal_that_loses_twice_is_dropped( - renewing: SessionManager, - backend: MemoryBackend, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The session was valid when read again, so the request keeps it.""" - session, token = await renewing.create_session(SessionUser(user_id="alice")) - calls = 0 - - async def always_lose(*_args: object, **_kwargs: object) -> bool: - nonlocal calls - calls += 1 - return False - - monkeypatch.setattr(backend, "set_if_equals", always_lose) - - loaded, renewed = await renewing.get_session(token) - - assert calls == 2 - assert loaded.session_id == session.session_id - assert renewed is None - - -# --- FastAPICacheXSessionMiddleware --------------------------------------------- - - -def _app(manager: SessionManager, config: SessionConfig, action: str) -> FastAPI: - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.post("/cart") - async def add_to_cart(request: Request) -> dict[str, bool]: - request.session["cart"] = ["book"] - # Another request logs the session out while this one is running. - session_id = request.session.backend.session_id # type: ignore[attr-defined] - if action == "delete": - await manager.delete_session(session_id) - elif action == "save": - other = await manager._load_session(session_id) - assert other is not None - other.data["cart"] = ["pen"] - assert await manager.update_session(other) is True - else: - other = await manager._load_session(session_id) - assert other is not None - await manager.invalidate_session(other) - return {"ok": True} - - return app - - -@pytest.mark.parametrize("action", ["delete", "invalidate"]) -@pytest.mark.parametrize("transport", ["cookie", "header"]) -async def test_the_middleware_drops_a_stale_save_and_sends_no_token( - manager: SessionManager, config: SessionConfig, action: str, transport: str -) -> None: - session, token = await manager.create_session(SessionUser(user_id="alice")) - client = TestClient(_app(manager, config, action)) - if transport == "cookie": - client.cookies.set(config.cookie_name, token) - response = client.post("/cart") - else: - response = client.post("/cart", headers=_auth(transport, config, token)) - - assert response.status_code == 200 - assert "set-cookie" not in response.headers - assert config.header_name.lower() not in response.headers - stored = await manager._load_session(session.session_id) - if action == "delete": - assert stored is None - else: - assert stored is not None - assert stored.status == SessionStatus.INVALIDATED - assert "cart" not in stored.data - - -@pytest.mark.parametrize("transport", ["cookie", "header"]) -async def test_a_dropped_save_still_sends_a_stored_renewal( - backend: MemoryBackend, transport: str -) -> None: - """The save lost to another save, but the renewal was stored: send its token. - - Without it, a JWT client would keep a token whose ``exp`` comes before the - renewed record's expiry. The session starts with a short TTL, so the - renewed JWT differs from the one the client sent. - """ - settings: dict[str, Any] = { - "secret_key": "a" * 32, - "cookie_name": "session", - "cookie_https_only": False, - "token_format": "jwt", - } - short = SessionManager(backend, SessionConfig(**settings, session_ttl=60)) - renewing_config = SessionConfig(**settings, sliding_threshold=1.0) - renewing = SessionManager(backend, renewing_config) - session, token = await short.create_session(SessionUser(user_id="alice")) - client = TestClient(_app(renewing, renewing_config, "save")) - if transport == "cookie": - client.cookies.set(renewing_config.cookie_name, token) - response = client.post("/cart") - else: - response = client.post( - "/cart", headers=_auth(transport, renewing_config, token) - ) - - assert response.status_code == 200 - sent = _token_sent(response, transport, renewing_config) - assert sent != token - loaded, _ = await renewing.get_session(sent) - assert loaded.session_id == session.session_id - assert loaded.data == {"cart": ["pen"]} - - -def test_sessions_compare_equal_whatever_entry_they_last_saw() -> None: - """``_stored`` is bookkeeping, not part of the session's value.""" - session = Session(data={"a": 1}) - loaded = Session.model_validate_json(session.model_dump_json()) - loaded._stored = CacheEntry(fingerprint="session", content=b"x") - - assert loaded == session - assert loaded != Session(data={"a": 2}) - assert session.__eq__("not a session") is NotImplemented - - -async def test_a_stored_renewal_is_not_sent_for_an_invalidated_session( - renewing: SessionManager, renewing_config: SessionConfig -) -> None: - _session, token = await renewing.create_session(SessionUser(user_id="alice")) - client = TestClient(_app(renewing, renewing_config, "invalidate")) - client.cookies.set(renewing_config.cookie_name, token) - - response = client.post("/cart") - - assert response.status_code == 200 - assert "set-cookie" not in response.headers - - -# --- ID rotation ---------------------------------------------------------------- - - -async def test_a_stale_copy_of_a_deleted_session_cannot_be_rotated( - any_manager: SessionManager, -) -> None: - stale, _other, token = await _two_copies(any_manager) - old_id = stale.session_id - await any_manager.delete_session(old_id) - - with pytest.raises(SessionNotFoundError): - await any_manager.regenerate_session_id(stale) - - assert stale.session_id == old_id - assert await any_manager._load_session(old_id) is None - with pytest.raises(SessionNotFoundError): - await any_manager.get_session(token) - - -async def test_a_stale_copy_of_an_invalidated_session_cannot_be_rotated( - any_manager: SessionManager, -) -> None: - stale, other, token = await _two_copies(any_manager) - old_id = stale.session_id - await any_manager.invalidate_session(other) - - with pytest.raises(SessionInvalidError): - await any_manager.regenerate_session_id(stale) - - assert stale.session_id == old_id - with pytest.raises(SessionNotFoundError): - await any_manager.get_session(token) - - -async def test_a_stale_copy_of_an_expired_session_cannot_be_rotated( - manager: SessionManager, -) -> None: - """``is_valid()`` covers expiry too, not only the status.""" - stale, other, _token = await _two_copies(manager) - other.expires_at = other.created_at - assert await manager.update_session(other) is True - - with pytest.raises(SessionInvalidError): - await manager.regenerate_session_id(stale) - - -async def test_only_one_of_two_concurrent_rotations_succeeds( - any_manager: SessionManager, -) -> None: - first, second, _token = await _two_copies(any_manager) - - _rotated, new_token = await any_manager.regenerate_session_id(first) - with pytest.raises(SessionNotFoundError): - await any_manager.regenerate_session_id(second) - - assert (await any_manager.get_session(new_token))[0].session_id == ( - first.session_id - ) - - -async def test_a_rotation_after_another_save_goes_ahead( - any_manager: SessionManager, -) -> None: - """The session is still valid; only its data changed.""" - stale, other, _token = await _two_copies(any_manager) - other.data["cart"] = ["book"] - assert await any_manager.update_session(other) is True - - rotated, new_token = await any_manager.regenerate_session_id(stale) - - assert (await any_manager.get_session(new_token))[0].session_id == ( - rotated.session_id - ) - - -async def test_a_session_never_read_is_rotated_without_the_check( - manager: SessionManager, -) -> None: - fresh = Session(data={"a": 1}) - - _rotated, new_token = await manager.regenerate_session_id(fresh) - - assert (await manager.get_session(new_token))[0].data == {"a": 1} - - -def _rotating_app(manager: SessionManager, config: SessionConfig) -> FastAPI: - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - async def log_out_elsewhere(request: Request) -> None: - session_id = request.session.backend.session_id # type: ignore[attr-defined] - other = await manager._load_session(session_id) - assert other is not None - await manager.invalidate_session(other) - - @app.post("/sudo") - async def sudo(request: Request) -> dict[str, bool]: - request.session["seen"] = True - await log_out_elsewhere(request) - await rotate_session_id(request) - request.session["elevated"] = True - return {"ok": True} - - @app.post("/sudo-quiet") - async def sudo_quiet(request: Request) -> dict[str, bool]: - await log_out_elsewhere(request) - await rotate_session_id(request) - return {"ok": True} - - @app.post("/login") - async def log_in(request: Request) -> dict[str, bool]: - request.session["before"] = 1 - await log_out_elsewhere(request) - await login(request, SessionUser(user_id="alice")) - request.session["after"] = 2 - return {"ok": True} - - return app - - -async def test_rotate_session_id_refuses_a_session_logged_out_meanwhile( - manager: SessionManager, config: SessionConfig -) -> None: - SessionManagerProxy.set(manager) - session, token = await manager.create_session(SessionUser(user_id="alice")) - client = TestClient(_rotating_app(manager, config)) - client.cookies.set(config.cookie_name, token) - - response = client.post("/sudo") - - assert response.status_code == 401 - assert "set-cookie" not in response.headers - assert await manager.backend.get_all_keys() == [] - assert await manager._load_session(session.session_id) is None - - -async def test_a_refused_rotation_does_not_send_a_renewed_token( - renewing: SessionManager, renewing_config: SessionConfig -) -> None: - """The winner of a concurrent rotation may already hold the new cookie.""" - SessionManagerProxy.set(renewing) - _session, token = await renewing.create_session(SessionUser(user_id="alice")) - client = TestClient(_rotating_app(renewing, renewing_config)) - client.cookies.set(renewing_config.cookie_name, token) - - response = client.post("/sudo-quiet") - - assert response.status_code == 401 - assert "set-cookie" not in response.headers - - -@pytest.mark.parametrize("transport", ["cookie", "header"]) -async def test_login_starts_a_new_session_when_the_loaded_one_ended_meanwhile( - manager: SessionManager, config: SessionConfig, transport: str -) -> None: - session, token = await manager.create_anonymous_session(cart=["book"]) - client = TestClient(_rotating_app(manager, config)) - if transport == "cookie": - client.cookies.set(config.cookie_name, token) - response = client.post("/login") - else: - response = client.post("/login", headers=_auth(transport, config, token)) - - assert response.status_code == 200 - logged_in, _ = await manager.get_session(_token_sent(response, transport, config)) - assert logged_in.session_id != session.session_id - assert logged_in.user is not None - assert logged_in.user.user_id == "alice" - assert logged_in.data == {"after": 2} - assert await manager.backend.get_all_keys() == [ - manager._get_backend_key(logged_in.session_id) - ] - - -async def test_rotate_session_id_refuses_an_ended_session_without_the_middleware( - manager: SessionManager, -) -> None: - """A session put on ``request.state`` by hand is refused the same way.""" - SessionManagerProxy.set(manager) - stale, _other, _token = await _two_copies(manager) - await manager.delete_session(stale.session_id) - app = FastAPI() - setattr(app.state, "__fastapi_cachex_session_manager", manager) - - @app.post("/sudo") - async def sudo(request: Request) -> dict[str, bool]: - setattr(request.state, "__fastapi_cachex_session", stale) - return {"rotated": await rotate_session_id(request)} - - response = TestClient(app).post("/sudo") - - assert response.status_code == 401 diff --git a/tests/session/test_config.py b/tests/session/test_config.py deleted file mode 100644 index af10182..0000000 --- a/tests/session/test_config.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Tests for SessionConfig validation.""" - -import warnings - -import pytest -from pydantic import ValidationError - -from fastapi_cachex.session.config import SessionConfig - - -def test_session_config_accepts_known_fields() -> None: - config = SessionConfig(secret_key="a" * 32, session_ttl=1800) - assert config.session_ttl == 1800 - - -def test_session_config_rejects_unknown_fields() -> None: - """Unknown/misspelled fields must raise, not be silently dropped. - - Regression test: SessionConfig previously used pydantic's default - extra="ignore", so passing a nonexistent option like the docs' former - ``regenerate_on_login``/``enable_csrf`` examples silently did nothing - instead of surfacing a startup-time error. - """ - with pytest.raises(ValidationError): - SessionConfig(secret_key="a" * 32, regenerate_on_login=True) # type: ignore[call-arg] - - with pytest.raises(ValidationError): - SessionConfig(secret_key="a" * 32, enable_csrf=True) # type: ignore[call-arg] - - -def test_same_site_none_without_https_only_warns() -> None: - """Browsers drop a SameSite=None cookie that is not Secure (#167).""" - with pytest.warns(UserWarning, match='cookie_same_site="none"'): - SessionConfig( - secret_key="a" * 32, - cookie_name="session", - cookie_same_site="none", - cookie_https_only=False, - ) - - -@pytest.mark.parametrize( - ("same_site", "https_only"), - [("none", True), ("lax", False), ("strict", False)], -) -def test_other_cookie_settings_do_not_warn(same_site, https_only) -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - SessionConfig( - secret_key="a" * 32, - cookie_name="session", - cookie_same_site=same_site, - cookie_https_only=https_only, - ) - - -# --- Cookie prefixes (#256) ----------------------------------------------------- - - -_PLAIN_HTTP_HINT = "cookie_name='session' with cookie_https_only=False" -_SECURE_PREFIX_HINT = "use cookie_name='__Secure-session', which keeps the Secure flag" - - -@pytest.mark.parametrize( - ("settings", "requirements", "hints"), - [ - ( - {"cookie_https_only": False}, - ["cookie_https_only=True"], - [_PLAIN_HTTP_HINT], - ), - ( - {"cookie_name": "__Secure-session", "cookie_https_only": False}, - ["cookie_https_only=True"], - [_PLAIN_HTTP_HINT], - ), - ({"cookie_path": "/app"}, ['cookie_path="/"'], [_SECURE_PREFIX_HINT]), - ( - {"cookie_domain": "example.com"}, - ["cookie_domain=None"], - [_SECURE_PREFIX_HINT], - ), - ( - {"cookie_https_only": False, "cookie_path": "/app"}, - ["cookie_https_only=True", 'cookie_path="/"'], - [_PLAIN_HTTP_HINT, _SECURE_PREFIX_HINT], - ), - ], -) -def test_prefixed_cookie_names_browsers_refuse_are_rejected( - settings: dict[str, object], requirements: list[str], hints: list[str] -) -> None: - """A cookie browsers would refuse never sticks, so the config is an error. - - The default name is ``__Host-session``, so changing only the Secure flag, - path or domain is enough to hit this, and the message says the name is - the default. The hint fits the problem: dropping Secure is advice for - plain HTTP only, not for someone setting a path or domain. - """ - with pytest.raises(ValidationError, match="cookie_name=") as excinfo: - SessionConfig(secret_key="a" * 32, **settings) - - message = str(excinfo.value) - for requirement in requirements: - assert requirement in message - for hint in (_PLAIN_HTTP_HINT, _SECURE_PREFIX_HINT): - assert (hint in message) is (hint in hints) - assert ("(the default)" in message) is ("cookie_name" not in settings) - assert "SESSION/#cookie-defaults" in message - - -def test_same_site_none_with_a_prefixed_name_is_only_rejected() -> None: - """The prefix check covers the missing Secure flag; no extra warning.""" - with warnings.catch_warnings(): - warnings.simplefilter("error") - with pytest.raises(ValidationError, match="cookie_https_only=True"): - SessionConfig( - secret_key="a" * 32, - cookie_same_site="none", - cookie_https_only=False, - ) - - -@pytest.mark.parametrize( - "settings", - [ - {}, - { - "cookie_name": "__Secure-session", - "cookie_path": "/app", - "cookie_domain": "example.com", - }, - { - "cookie_name": "session", - "cookie_https_only": False, - "cookie_path": "/app", - "cookie_domain": "example.com", - }, - ], -) -def test_valid_cookie_prefixes_are_accepted(settings: dict[str, object]) -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - SessionConfig(secret_key="a" * 32, **settings) diff --git a/tests/session/test_dependencies.py b/tests/session/test_dependencies.py deleted file mode 100644 index 4b0477c..0000000 --- a/tests/session/test_dependencies.py +++ /dev/null @@ -1,115 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from fastapi import Depends -from fastapi import FastAPI -from fastapi import HTTPException -from fastapi import Request -from fastapi.testclient import TestClient - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.dependencies import get_optional_session -from fastapi_cachex.session.dependencies import get_session -from fastapi_cachex.session.dependencies import require_session -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import SessionUser - - -class TestSessionDependencies: - """Test session dependencies.""" - - def test_get_session(self): - """Test get_session dependency.""" - - # Create a mock request with session - request_with_session = MagicMock(spec=Request) - mock_session = MagicMock() - setattr(request_with_session.state, "__fastapi_cachex_session", mock_session) - - session = get_session(request_with_session) - assert session == mock_session - - # Create a mock request without session - request_without_session = MagicMock(spec=Request) - setattr(request_without_session.state, "__fastapi_cachex_session", None) - - with pytest.raises(HTTPException) as exc_info: - get_session(request_without_session) - assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "Authentication required" - - def test_get_optional_session(self): - """Test get_optional_session dependency.""" - - # Create a mock request with session - request_with_session = MagicMock(spec=Request) - mock_session = MagicMock() - setattr(request_with_session.state, "__fastapi_cachex_session", mock_session) - - session = get_optional_session(request_with_session) - assert session == mock_session - - # Create a mock request without session - request_without_session = MagicMock(spec=Request) - setattr(request_without_session.state, "__fastapi_cachex_session", None) - - session = get_optional_session(request_without_session) - assert session is None - - -class TestRequireSessionAlias: - """Test the require_session alias behaves identically to get_session.""" - - def test_require_session_is_get_session_alias(self) -> None: - assert require_session is get_session - - def test_require_session_via_http_endpoint(self) -> None: - """require_session used as a route dependency must return 401 without session.""" - config = SessionConfig( - secret_key="a" * 32, cookie_name="session", cookie_https_only=False - ) - backend = MemoryBackend() - manager = SessionManager(backend, config) - - dep_app = FastAPI() - dep_app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @dep_app.get("/protected") - async def protected(session=Depends(require_session)): - return {"user_id": session.user.user_id if session.user else None} - - dep_client = TestClient(dep_app, raise_server_exceptions=False) - - # No session token → 401 - r = dep_client.get("/protected") - assert r.status_code == 401 - - async def test_require_session_with_valid_session(self) -> None: - """require_session passes when a valid session is present.""" - config = SessionConfig( - secret_key="a" * 32, cookie_name="session", cookie_https_only=False - ) - backend = MemoryBackend() - manager = SessionManager(backend, config) - - dep_app = FastAPI() - dep_app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @dep_app.get("/me") - async def me(session=Depends(require_session)): - return {"user_id": session.user.user_id if session.user else None} - - dep_client = TestClient(dep_app) - - user = SessionUser(user_id="u1", username="alice") - _session, token = await manager.create_session(user=user) - - r = dep_client.get("/me", headers={"X-Session-Token": token}) - assert r.status_code == 200 - assert r.json()["user_id"] == "u1" diff --git a/tests/session/test_dropped_0_4_0_changes.py b/tests/session/test_dropped_0_4_0_changes.py deleted file mode 100644 index a4f59a8..0000000 --- a/tests/session/test_dropped_0_4_0_changes.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Session changes 0.3.9 announced for 0.4.0 and dropped with #420. - -Sessions are deprecated and leave in 0.5.0, so 0.4.0 keeps 0.3.9's behaviour -and no longer warns about these changes: - -- #131: ``get_session_manager`` keeps returning the middleware's manager. -- #75: the cookie is read whether or not ``token_source_priority`` lists it. -- #377: ``SessionConfig.use_bearer_token`` stays deprecated, now until 0.5.0. -""" - -import warnings - -import pytest -from fastapi import FastAPI -from fastapi import Request -from fastapi.testclient import TestClient -from pydantic import ValidationError - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session import FastAPICacheXSessionMiddleware -from fastapi_cachex.session import SessionConfig -from fastapi_cachex.session import SessionManager -from fastapi_cachex.session.dependencies import ClientIPDep -from fastapi_cachex.session.dependencies import OptionalSession -from fastapi_cachex.session.dependencies import SessionManagerDep -from fastapi_cachex.session.dependencies import rotate_session_id -from fastapi_cachex.session.models import SessionUser -from fastapi_cachex.session.proxy import SessionManagerProxy - -SECRET = "a" * 32 - - -def _middleware(config: SessionConfig) -> FastAPICacheXSessionMiddleware: - manager = SessionManager(MemoryBackend(), config) - return FastAPICacheXSessionMiddleware(FastAPI(), session_manager=manager) - - -# --- #131: get_session_manager keeps the middleware's manager ----------------- - - -def _manager_app(manager: SessionManager, config: SessionConfig) -> FastAPI: - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/manager") - async def read_manager(mgr: SessionManagerDep) -> dict[str, bool]: - return {"is_same": mgr is manager} - - @app.get("/ip") - async def read_ip(client_ip: ClientIPDep) -> dict[str, str | None]: - return {"ip": client_ip} - - @app.post("/rotate") - async def rotate(request: Request) -> dict[str, bool]: - return {"rotated": await rotate_session_id(request)} - - return app - - -@pytest.mark.parametrize("proxy", ["empty", "other"]) -def test_get_session_manager_returns_the_middleware_manager_silently( - manager: SessionManager, config: SessionConfig, proxy: str -) -> None: - """Whatever the proxy holds, 0.4.0 answers with the middleware's manager.""" - if proxy == "other": - SessionManagerProxy.set(SessionManager(MemoryBackend(), config)) - client = TestClient(_manager_app(manager, config)) - - with warnings.catch_warnings(): - warnings.simplefilter("error") - response = client.get("/manager") - ip = client.get("/ip") - rotated = client.post("/rotate") - - assert response.json() == {"is_same": True} - assert ip.status_code == 200 - assert rotated.json() == {"rotated": False} - - -def test_middleware_from_the_proxy_is_silent(config: SessionConfig) -> None: - """The recommended wiring: the middleware picks the manager up from the proxy.""" - manager = SessionManager(MemoryBackend(), config) - SessionManagerProxy.set(manager) - app = FastAPI() - app.add_middleware(FastAPICacheXSessionMiddleware) - - @app.get("/manager") - async def read_manager(mgr: SessionManagerDep) -> dict[str, bool]: - return {"is_same": mgr is manager} - - with warnings.catch_warnings(): - warnings.simplefilter("error") - response = TestClient(app).get("/manager") - - assert response.json() == {"is_same": True} - - -# --- #75: the cookie is read whether or not the list names it ------------------ - -_COOKIE = {"cookie_name": "session", "cookie_https_only": False} - - -@pytest.mark.parametrize( - "settings", - [ - {}, - {"token_source_priority": ["header", "bearer"]}, - {"token_source_priority": ["header"]}, - {"token_source_priority": []}, - {"token_source_priority": ["header", "bearer", "cookie"]}, - ], -) -def test_middleware_is_silent_with_or_without_cookie( - settings: dict[str, object], -) -> None: - config = SessionConfig(secret_key=SECRET, **settings, **_COOKIE) - - with warnings.catch_warnings(): - warnings.simplefilter("error") - _middleware(config) - - -@pytest.mark.parametrize( - "priority", [["cookie", "header"], ["header", "cookie", "bearer"]] -) -def test_cookie_is_accepted_only_as_the_last_source(priority: list[str]) -> None: - with pytest.raises(ValidationError, match="must be the last entry"): - SessionConfig(secret_key=SECRET, token_source_priority=priority) - - -async def test_the_cookie_is_read_with_or_without_the_entry() -> None: - """Listing "cookie" last is the order it is read in; leaving it out changes nothing.""" - backend = MemoryBackend() - for priority in (["header", "cookie"], ["header"]): - with warnings.catch_warnings(): - warnings.simplefilter("error") - config = SessionConfig( - secret_key=SECRET, token_source_priority=priority, **_COOKIE - ) - manager = SessionManager(backend, config) - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/") - async def read(session: OptionalSession) -> dict[str, object]: - return { - "user": session.user.user_id if session and session.user else None - } - - _session, token = await manager.create_session( - user=SessionUser(user_id="u") - ) - client = TestClient(app) - client.cookies.set("session", token) - - assert client.get("/").json() == {"user": "u"}, priority - - -# --- #377: use_bearer_token is deprecated until 0.5.0 ---------------------------- - - -@pytest.mark.parametrize("value", [True, False]) -def test_passing_use_bearer_token_warns(value: bool) -> None: - with pytest.warns(DeprecationWarning, match="use_bearer_token") as record: - SessionConfig(secret_key=SECRET, use_bearer_token=value) - - message = str(record[0].message) - assert "removed in version 0.5.0" in message - assert "issues/377" in message - - -def test_leaving_use_bearer_token_out_is_silent() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - SessionConfig(secret_key=SECRET, token_source_priority=["header", "cookie"]) diff --git a/tests/session/test_get_session_manager.py b/tests/session/test_get_session_manager.py deleted file mode 100644 index 92164b9..0000000 --- a/tests/session/test_get_session_manager.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Tests for get_session_manager dependency.""" - -from typing import Annotated - -import pytest -from fastapi import Depends -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from fastapi_cachex.exceptions import BackendNotFoundError -from fastapi_cachex.session import FastAPICacheXSessionMiddleware -from fastapi_cachex.session import SessionConfig -from fastapi_cachex.session import SessionManager -from fastapi_cachex.session import SessionUser -from fastapi_cachex.session import get_session_manager -from fastapi_cachex.session.proxy import SessionManagerProxy - - -def test_get_session_manager_dependency( - manager: SessionManager, config: SessionConfig -) -> None: - """Test get_session_manager dependency retrieves manager from app state.""" - app = FastAPI() - SessionManagerProxy.set(manager) - - # Add middleware which stores manager in app.state - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - # Create endpoint that uses get_session_manager - @app.get("/test") - async def test_endpoint( - session_manager: Annotated[SessionManager, Depends(get_session_manager)], - ): - return {"has_manager": session_manager is not None} - - client = TestClient(app) - response = client.get("/test") - - assert response.status_code == 200 - assert response.json()["has_manager"] is True - - -async def test_get_session_manager_allows_create_session( - manager: SessionManager, config: SessionConfig -) -> None: - """Test using get_session_manager to create sessions.""" - app = FastAPI() - SessionManagerProxy.set(manager) - - # Add middleware - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - # Create login endpoint - @app.post("/login") - async def login( - username: str, - session_manager: Annotated[SessionManager, Depends(get_session_manager)], - ): - user = SessionUser(user_id="123", username=username) - session, token = await session_manager.create_session(user=user) - user_id = session.user.user_id if session.user else None - return {"token": token, "user_id": user_id} - - client = TestClient(app) - response = client.post("/login?username=testuser") - - assert response.status_code == 200 - data = response.json() - assert "token" in data - assert data.get("user_id") == "123" - - -def test_get_session_manager_without_middleware_raises_error() -> None: - """Test get_session_manager raises 500 if middleware not added.""" - app = FastAPI() - - # Don't add middleware - manager not in app.state - - @app.get("/test") - async def test_endpoint( - session_manager: Annotated[SessionManager, Depends(get_session_manager)], - ): - return {"manager": "ok"} - - client = TestClient(app) - response = client.get("/test") - - # Should return 500 Internal Server Error - assert response.status_code == 500 - assert "SessionManager not initialized" in response.json()["detail"] - - -async def test_get_session_manager_full_workflow( - manager: SessionManager, config: SessionConfig -) -> None: - """Test complete workflow: create, get, delete session using dependency.""" - app = FastAPI() - SessionManagerProxy.set(manager) - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.post("/login") - async def login( - username: str, - session_manager: Annotated[SessionManager, Depends(get_session_manager)], - ): - user = SessionUser(user_id="user123", username=username) - _, token = await session_manager.create_session(user=user) - return {"token": token} - - @app.post("/logout") - async def logout( - session_id: str, - session_manager: Annotated[SessionManager, Depends(get_session_manager)], - ): - await session_manager.delete_session(session_id) - return {"message": "logged out"} - - client = TestClient(app) - - # Login - login_response = client.post("/login?username=testuser") - assert login_response.status_code == 200 - token = login_response.json()["token"] - - # Extract session_id from token (format: session_id.signature.timestamp) - session_id = token.split(".")[0] - - # Logout - logout_response = client.post(f"/logout?session_id={session_id}") - assert logout_response.status_code == 200 - assert logout_response.json()["message"] == "logged out" - - -def test_session_manager_type_annotation( - manager: SessionManager, config: SessionConfig -) -> None: - """Test SessionManagerDep type annotation works.""" - from fastapi_cachex.session.dependencies import SessionManagerDep - - app = FastAPI() - SessionManagerProxy.set(manager) - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/test") - async def test_endpoint(session_manager: SessionManagerDep): - return {"manager_type": type(session_manager).__name__} - - client = TestClient(app) - response = client.get("/test") - - assert response.status_code == 200 - assert response.json()["manager_type"] == "SessionManager" - - -class TestSessionManagerProxy: - """Test SessionManagerProxy class-level proxy behavior.""" - - def setup_method(self) -> None: - """Clear proxy state before each test.""" - SessionManagerProxy.set(None) - - def teardown_method(self) -> None: - """Clear proxy state after each test.""" - SessionManagerProxy.set(None) - - def test_instantiation_raises_type_error(self) -> None: - """SessionManagerProxy cannot be instantiated directly.""" - with pytest.raises(TypeError): - SessionManagerProxy() - - def test_get_raises_when_not_set(self) -> None: - """get() raises BackendNotFoundError when no manager has been set.""" - with pytest.raises(BackendNotFoundError): - SessionManagerProxy.get() - - def test_set_and_get_round_trip(self, manager: SessionManager) -> None: - """set() + get() round-trip returns the same instance.""" - SessionManagerProxy.set(manager) - assert SessionManagerProxy.get() is manager - - def test_set_none_clears_instance(self, manager: SessionManager) -> None: - """set(None) clears the stored instance.""" - SessionManagerProxy.set(manager) - SessionManagerProxy.set(None) - with pytest.raises(BackendNotFoundError): - SessionManagerProxy.get() diff --git a/tests/session/test_hardening.py b/tests/session/test_hardening.py deleted file mode 100644 index 4e0723e..0000000 --- a/tests/session/test_hardening.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Security hardening tests for session token handling and client identification.""" - -import pytest -from fastapi import FastAPI -from fastapi import Request -from fastapi.testclient import TestClient -from pydantic import ValidationError - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import SessionUser -from fastapi_cachex.session.security import SecurityManager - - -@pytest.fixture -def config() -> SessionConfig: - """Session config with IP binding on, trusting nothing by default.""" - return SessionConfig( - secret_key="a" * 32, - ip_binding=True, - cookie_name="session", - cookie_https_only=False, - ) - - -def test_non_ascii_signature_is_rejected_not_raised(): - """`hmac.compare_digest` refuses non-ASCII str; that must not escape.""" - security = SecurityManager("a" * 32) - - assert security.verify_signature("session-id", "簽章") is False - assert security.verify_signature("session-id", "a.\xe9x.1") is False - - -def test_valid_signature_still_verifies(): - """The bytes comparison must not break the happy path.""" - security = SecurityManager("a" * 32) - signature = security.sign_session_id("session-id") - - assert security.verify_signature("session-id", signature) is True - assert security.verify_signature("other-id", signature) is False - - -def test_non_ascii_token_does_not_crash_the_middleware( - manager: SessionManager, config: SessionConfig -): - """An unauthenticated caller must not be able to provoke a 500.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/whoami") - async def whoami(request: Request) -> dict[str, bool]: - # `scope["session"]` is the dict of user-set values, which is empty for - # a session carrying only a user, so it says nothing about whether one - # was loaded. The backend session is attached only after every check - # passed, which is the signal this test needs. - loaded = request.scope["state"].get("__fastapi_cachex_session") - return {"authenticated": loaded is not None} - - client = TestClient(app) - # Sent as raw bytes: Starlette decodes header bytes as latin-1, so a token - # can carry bytes that httpx would refuse to encode from a str. - response = client.get( - "/whoami", headers={b"x-session-token": b"a.\xe9x.1699999999"} - ) - - assert response.status_code == 200 - assert response.json() == {"authenticated": False} - - -async def test_forged_forwarded_header_cannot_satisfy_ip_binding( - manager: SessionManager, config: SessionConfig -): - """A stolen token plus a forged X-Forwarded-For must not pass IP binding.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/me") - async def me(request: Request) -> dict[str, bool]: - # The backend session is attached only once every binding check passed. - loaded = request.scope["state"].get("__fastapi_cachex_session") - return {"authenticated": loaded is not None} - - client = TestClient(app) - - # TestClient presents itself as "testclient"; bind a session to some other - # address and then try to claim that address via the header. - _, token = await manager.create_session( - user=SessionUser(user_id="u1"), - ip_address="203.0.113.7", - ) - - response = client.get( - "/me", - headers={ - "X-Session-Token": token, - "X-Forwarded-For": "203.0.113.7", - }, - ) - - # The header is ignored, the peer address does not match the binding, and - # the session is refused rather than honoured. - assert response.status_code == 200 - assert response.json() == {"authenticated": False} - - # Positive control: the same token bound to the address the request really - # comes from is honoured. Without this, an implementation that refused - # every session would pass the assertion above. - _, bound_token = await manager.create_session( - user=SessionUser(user_id="u2"), - ip_address="testclient", - ) - - honoured = client.get("/me", headers={"X-Session-Token": bound_token}) - - assert honoured.json() == {"authenticated": True} - - -async def test_prepended_forwarded_entry_cannot_satisfy_ip_binding( - manager: SessionManager, -): - """Behind a trusted proxy, the attacker's own entry must not be believed. - - `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` appends, so a - caller who sends the header themselves ends up with their chosen address in - front of the one the proxy added. - """ - # The proxy in this test is TestClient itself, which presents as - # "testclient"; trusting it puts us in the deployment the setting exists for. - config = SessionConfig( - secret_key="a" * 32, - ip_binding=True, - trusted_proxies=["testclient"], - cookie_name="session", - cookie_https_only=False, - ) - manager = SessionManager(MemoryBackend(), config) - - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/me") - async def me(request: Request) -> dict[str, bool]: - # The backend session is only attached once every binding check passed. - loaded = request.scope["state"].get("__fastapi_cachex_session") - return {"authenticated": loaded is not None} - - _, token = await manager.create_session( - user=SessionUser(user_id="u1"), - ip_address="198.51.100.5", - ) - - client = TestClient(app) - forged = client.get( - "/me", - headers={ - "X-Session-Token": token, - # Left entry forged by the attacker, right entry added by the proxy. - "X-Forwarded-For": "198.51.100.5, 203.0.113.99", - }, - ) - - assert forged.status_code == 200 - assert forged.json() == {"authenticated": False} - - # The genuine client arrives with only the proxy's own entry. - genuine = client.get( - "/me", - headers={"X-Session-Token": token, "X-Forwarded-For": "198.51.100.5"}, - ) - - assert genuine.json() == {"authenticated": True} - - -def test_jwt_algorithm_none_is_rejected(): - """An unsigned JWT would make every session forgeable.""" - with pytest.raises(ValidationError, match="jwt_algorithm must be one of"): - SessionConfig(secret_key="a" * 32, token_format="jwt", jwt_algorithm="none") - - -def test_unknown_jwt_algorithm_is_rejected(): - """Typos must fail at config time, not at first decode.""" - with pytest.raises(ValidationError, match="jwt_algorithm must be one of"): - SessionConfig(secret_key="a" * 32, jwt_algorithm="HS255") - - -def test_supported_jwt_algorithms_are_accepted(): - """The documented default and the common asymmetric options still work.""" - for algorithm in ("HS256", "HS512", "RS256", "ES256", "EdDSA"): - config = SessionConfig(secret_key="a" * 32, jwt_algorithm=algorithm) - assert config.jwt_algorithm == algorithm - - -def test_trusted_proxies_defaults_to_empty(): - """The safe default is to believe no forwarded headers at all.""" - assert SessionConfig(secret_key="a" * 32).trusted_proxies == [] - - -def test_jwt_leeway_description_does_not_promise_nbf(): - """`nbf` is neither issued nor verified; the field text must say so.""" - description = SessionConfig.model_fields["jwt_leeway"].description or "" - - assert "nbf" in description - assert "not" in description diff --git a/tests/session/test_jwt.py b/tests/session/test_jwt.py deleted file mode 100644 index 348c058..0000000 --- a/tests/session/test_jwt.py +++ /dev/null @@ -1,137 +0,0 @@ -"""JWT token serializer integration tests. - -These tests are skipped if PyJWT is not installed. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.exceptions import SessionTokenError -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.models import SessionUser - -if TYPE_CHECKING: - from tests.conftest import Clock - -jwt = pytest.importorskip("jwt") - - -async def test_jwt_create_and_get_session() -> None: - backend = MemoryBackend() - config = SessionConfig( - secret_key="a" * 32, - token_format="jwt", - jwt_algorithm="HS256", - jwt_issuer="test-iss", - jwt_audience="test-aud", - session_ttl=3600, - ) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="u1", username="alice") - created, token = await manager.create_session(user=user) - - retrieved, _ = await manager.get_session(token) - assert retrieved.session_id == created.session_id - assert retrieved.user is not None - assert retrieved.user.user_id == "u1" - - -async def test_jwt_invalid_signature_rejected() -> None: - backend = MemoryBackend() - config = SessionConfig(secret_key="a" * 32, token_format="jwt") - manager = SessionManager(backend, config) - - _session, token = await manager.create_session(user=SessionUser(user_id="u1")) - - # Tamper token by flipping a character near the end - tampered = token[:-2] + ("A" if token[-2] != "A" else "B") + token[-1] - - with pytest.raises(SessionTokenError): - await manager.get_session(tampered) - - -async def test_jwt_wrong_audience_rejected() -> None: - backend = MemoryBackend() - config1 = SessionConfig( - secret_key="a" * 32, - token_format="jwt", - jwt_audience="aud1", - ) - manager1 = SessionManager(backend, config1) - _session, token = await manager1.create_session(user=SessionUser(user_id="u1")) - - # A different manager expecting different audience should reject - config2 = SessionConfig( - secret_key="a" * 32, - token_format="jwt", - jwt_audience="aud2", - ) - manager2 = SessionManager(backend, config2) - - with pytest.raises(SessionTokenError): - await manager2.get_session(token) - - -async def test_jwt_expiration_enforced(clock: Clock) -> None: - backend = MemoryBackend() - config = SessionConfig(secret_key="a" * 32, token_format="jwt", session_ttl=1) - manager = SessionManager(backend, config) - - # PyJWT checks `exp` against the real time, which `clock` cannot move, so - # the session is created in the past instead: its `exp` has already gone - # by. The backend entry and the session itself go by `clock` and are still - # current, so only the token check can reject it. - clock.advance(-5) - _session, token = await manager.create_session(user=SessionUser(user_id="u1")) - - # JWT should be expired before reaching session checks - with pytest.raises(SessionTokenError): - await manager.get_session(token) - - -async def test_jwt_sliding_renewal_returns_new_token_with_updated_exp() -> None: - """Sliding renewal must extend the session's expires_at back to a full TTL.""" - from datetime import datetime - from datetime import timedelta - from datetime import timezone - - backend = MemoryBackend() - config = SessionConfig( - secret_key="a" * 32, - token_format="jwt", - jwt_algorithm="HS256", - session_ttl=3600, - sliding_expiration=True, - sliding_threshold=0.5, - ) - manager = SessionManager(backend, config) - - created, original_token = await manager.create_session( - user=SessionUser(user_id="u1") - ) - - # Shorten expires_at so time_remaining < 50% of 3600 s → triggers renewal - shortened_expiry = datetime.now(timezone.utc) + timedelta(seconds=1000) - created.expires_at = shortened_expiry - await manager._save_session(created, conditional=False) - - renewed_session, renewed_token = await manager.get_session(original_token) - - # A renewed token must be returned (not None) - assert renewed_token is not None - - # The session's expires_at must have been extended beyond the shortened value. - # JWT strings may be identical if both calls land in the same wall-clock second - # (same iat → same exp), so we verify the session state, not the string. - assert renewed_session.expires_at is not None - assert renewed_session.expires_at > shortened_expiry - - # The renewed token must be immediately usable - retrieved, _ = await manager.get_session(renewed_token) - assert retrieved.session_id == created.session_id diff --git a/tests/session/test_login.py b/tests/session/test_login.py deleted file mode 100644 index 1b5906a..0000000 --- a/tests/session/test_login.py +++ /dev/null @@ -1,414 +0,0 @@ -"""Tests for ``login()``: attaching a user through FastAPICacheXSessionMiddleware (#293).""" - -from typing import Any - -import pytest -from fastapi import FastAPI -from fastapi import Request -from fastapi.testclient import TestClient - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session import login -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.dependencies import AuthenticatedSession -from fastapi_cachex.session.dependencies import OptionalSession -from fastapi_cachex.session.exceptions import SessionNotFoundError -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import SessionUser - -TRANSPORTS = ["cookie", "header", "bearer"] - - -def _login_app(manager: SessionManager, config: SessionConfig) -> FastAPI: - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.post("/cart") - async def add_to_cart(request: Request) -> dict[str, bool]: - request.session["cart"] = ["book"] - return {"ok": True} - - @app.post("/login") - async def log_in(request: Request, name: str = "alice") -> dict[str, Any]: - session = await login(request, SessionUser(user_id=name)) - return {"session_id": session.session_id} - - @app.post("/write-then-login") - async def write_then_login(request: Request, name: str) -> dict[str, Any]: - request.session["before"] = 1 - session = await login(request, SessionUser(user_id=name, roles=["fresh"])) - request.session["after"] = 2 - return {"session_id": session.session_id} - - @app.post("/login-then-write") - async def login_then_write(request: Request) -> dict[str, bool]: - request.session["before"] = 1 - await login(request, SessionUser(user_id="alice")) - request.session["after"] = 2 - return {"ok": True} - - @app.post("/login-then-clear") - async def login_then_clear(request: Request) -> dict[str, bool]: - await login(request, SessionUser(user_id="alice")) - request.session.clear() - return {"ok": True} - - @app.post("/clear-then-login") - async def clear_then_login(request: Request) -> dict[str, bool]: - request.session.clear() - await login(request, SessionUser(user_id="alice")) - return {"ok": True} - - @app.post("/login-twice") - async def login_twice(request: Request) -> dict[str, bool]: - await login(request, SessionUser(user_id="alice")) - await login(request, SessionUser(user_id="bob")) - return {"ok": True} - - @app.post("/login-and-read") - async def login_and_read(request: Request) -> dict[str, Any]: - session = await login(request, SessionUser(user_id="alice")) - # What the session dependencies see for the rest of the request. - seen = getattr(request.state, "__fastapi_cachex_session", None) - return {"same": seen is session} - - @app.get("/me") - async def me(session: AuthenticatedSession) -> dict[str, Any]: - assert session.user is not None - return {"user": session.user.user_id, "data": session.data} - - @app.get("/whoami") - async def whoami(session: OptionalSession) -> dict[str, Any]: - return {"session_id": session.session_id if session else None} - - return app - - -def _auth(transport: str, config: SessionConfig, token: str) -> dict[str, str]: - """Request headers that carry ``token`` over a header transport.""" - if transport == "header": - return {config.header_name: token} - return {"Authorization": f"Bearer {token}"} - - -def _token_sent(response: Any, transport: str, config: SessionConfig) -> str: - """The token the login response sent, asserting it used ``transport``.""" - if transport == "cookie": - assert config.header_name.lower() not in response.headers - value = response.cookies.get(config.cookie_name) - else: - assert "set-cookie" not in response.headers - value = response.headers.get(config.header_name) - assert value - return str(value) - - -def _me(client: TestClient, transport: str, config: SessionConfig, token: str) -> Any: - """GET /me carrying ``token`` over ``transport`` and nothing else.""" - client.cookies.clear() - if transport == "cookie": - client.cookies.set(config.cookie_name, token) - return client.get("/me") - return client.get("/me", headers=_auth(transport, config, token)) - - -@pytest.mark.parametrize("transport", TRANSPORTS) -async def test_new_visitor_logs_in( - manager: SessionManager, config: SessionConfig, transport: str -) -> None: - """With no session loaded, login() starts one and the next request is a user. - - A header or Bearer client counts as new when its token no longer resolves; - one that sent no token at all is answered with the cookie. - """ - client = TestClient(_login_app(manager, config)) - headers = {} if transport == "cookie" else _auth(transport, config, "stale") - - response = client.post("/login", headers=headers) - - assert response.status_code == 200 - assert response.headers["cache-control"] == "private, no-store" - token = _token_sent(response, transport, config) - session, _ = await manager.get_session(token) - assert session.session_id == response.json()["session_id"] - assert session.user is not None - assert session.user.user_id == "alice" - - me = _me(client, transport, config, token) - assert me.status_code == 200 - assert me.json() == {"user": "alice", "data": {}} - - -@pytest.mark.parametrize("transport", TRANSPORTS) -async def test_anonymous_session_keeps_its_data_under_a_new_id( - manager: SessionManager, config: SessionConfig, transport: str -) -> None: - """Logging in a loaded session rotates its ID; the old token stops working.""" - anonymous, old_token = await manager.create_anonymous_session(cart=["book"]) - client = TestClient(_login_app(manager, config)) - if transport == "cookie": - client.cookies.set(config.cookie_name, old_token) - response = client.post("/login") - else: - response = client.post("/login", headers=_auth(transport, config, old_token)) - - assert response.status_code == 200 - assert response.headers["cache-control"] == "private, no-store" - token = _token_sent(response, transport, config) - assert token != old_token - assert response.json()["session_id"] != anonymous.session_id - with pytest.raises(SessionNotFoundError): - await manager.get_session(old_token) - - me = _me(client, transport, config, token) - assert me.status_code == 200 - assert me.json() == {"user": "alice", "data": {"cart": ["book"]}} - assert _me(client, transport, config, old_token).status_code == 401 - - -def test_tokenless_request_gets_only_the_cookie( - manager: SessionManager, config: SessionConfig -) -> None: - """A request with no token is answered as a cookie client, as for any new session.""" - response = TestClient(_login_app(manager, config)).post("/login") - - assert config.cookie_name in response.cookies - assert config.header_name.lower() not in response.headers - - -def test_cart_then_login_over_cookies( - manager: SessionManager, config: SessionConfig -) -> None: - """The browser flow: an anonymous cart, then login, then a user route.""" - client = TestClient(_login_app(manager, config)) - client.post("/cart") - assert client.get("/me").status_code == 401 - - assert client.post("/login").status_code == 200 - - assert client.get("/me").json() == {"user": "alice", "data": {"cart": ["book"]}} - - -async def test_writes_around_login_are_saved( - manager: SessionManager, config: SessionConfig -) -> None: - """Keys written to request.session before and after login() are both kept.""" - client = TestClient(_login_app(manager, config)) - - response = client.post("/login-then-write") - - token = _token_sent(response, "cookie", config) - session, _ = await manager.get_session(token) - assert session.data == {"before": 1, "after": 2} - assert session.user is not None - - -async def test_login_then_clear_logs_out( - manager: SessionManager, config: SessionConfig, backend: MemoryBackend -) -> None: - """clear() after login() in the same request is a logout: nothing survives.""" - client = TestClient(_login_app(manager, config)) - client.post("/cart") - old_token = client.cookies[config.cookie_name] - - response = client.post("/login-then-clear") - - assert response.status_code == 200 - assert response.headers["cache-control"] == "private, no-store" - assert "expires=Thu, 01 Jan 1970" in response.headers["set-cookie"] - assert not client.cookies.get(config.cookie_name) - assert await backend.get_all_keys() == [] - with pytest.raises(SessionNotFoundError): - await manager.get_session(old_token) - assert client.get("/me").status_code == 401 - - -async def test_login_then_clear_over_the_header_sends_no_token( - manager: SessionManager, config: SessionConfig, backend: MemoryBackend -) -> None: - """A header client logged out in the same request gets no token at all.""" - client = TestClient(_login_app(manager, config)) - - response = client.post("/login-then-clear", headers={config.header_name: "stale"}) - - assert config.header_name.lower() not in response.headers - assert "set-cookie" not in response.headers - assert await backend.get_all_keys() == [] - - -async def test_clear_then_login_starts_a_new_session( - manager: SessionManager, config: SessionConfig, backend: MemoryBackend -) -> None: - """clear() before login() logs the loaded session out; login() starts afresh.""" - anonymous, old_token = await manager.create_anonymous_session(cart=["book"]) - client = TestClient(_login_app(manager, config)) - client.cookies.set(config.cookie_name, old_token) - - response = client.post("/clear-then-login") - - token = _token_sent(response, "cookie", config) - session, _ = await manager.get_session(token) - assert session.session_id != anonymous.session_id - assert session.user is not None - assert session.data == {} - with pytest.raises(SessionNotFoundError): - await manager.get_session(old_token) - # Only the new session is left in the backend. - assert len(await backend.get_all_keys()) == 1 - - -async def test_clear_then_login_for_a_new_visitor( - manager: SessionManager, config: SessionConfig -) -> None: - """clear() with nothing loaded has nothing to log out; login() still works.""" - client = TestClient(_login_app(manager, config)) - - response = client.post("/clear-then-login") - - token = _token_sent(response, "cookie", config) - session, _ = await manager.get_session(token) - assert session.user is not None - assert client.get("/me").status_code == 200 - - -async def test_login_twice_keeps_the_last_user( - manager: SessionManager, config: SessionConfig, backend: MemoryBackend -) -> None: - client = TestClient(_login_app(manager, config)) - - response = client.post("/login-twice") - - token = _token_sent(response, "cookie", config) - session, _ = await manager.get_session(token) - assert session.user is not None - assert session.user.user_id == "bob" - assert len(await backend.get_all_keys()) == 1 - - -def test_login_updates_the_request_session_for_dependencies( - manager: SessionManager, config: SessionConfig -) -> None: - """For the rest of the request, get_session returns the logged-in session.""" - client = TestClient(_login_app(manager, config)) - - assert client.post("/login-and-read").json() == {"same": True} - - -async def test_new_session_is_bound_like_the_middlewares( - backend: MemoryBackend, -) -> None: - """A session login() creates gets the IP and User-Agent bindings.""" - config = SessionConfig( - secret_key="a" * 32, - ip_binding=True, - user_agent_binding=True, - cookie_name="session", - cookie_https_only=False, - ) - manager = SessionManager(backend, config) - client = TestClient(_login_app(manager, config)) - - response = client.post("/login", headers={"User-Agent": "browser/1"}) - - token = _token_sent(response, "cookie", config) - session, _ = await manager.get_session( - token, ip_address="testclient", user_agent="browser/1" - ) - assert session.ip_address == "testclient" - assert session.user_agent == "browser/1" - - -async def test_login_outside_the_middleware_raises() -> None: - """Without FastAPICacheXSessionMiddleware nobody would send the token.""" - request = Request({"type": "http", "method": "POST", "headers": []}) - - with pytest.raises(RuntimeError, match="FastAPICacheXSessionMiddleware"): - await login(request, SessionUser(user_id="alice")) - - -async def test_login_as_another_user_starts_a_clean_session( - manager: SessionManager, config: SessionConfig, backend: MemoryBackend -) -> None: - """A's session and anything written before login() never reach B.""" - session_a, token_a = await manager.create_session( - SessionUser(user_id="alice"), cart=["book"], elevated=True - ) - client = TestClient(_login_app(manager, config)) - client.cookies.set(config.cookie_name, token_a) - - response = client.post("/write-then-login", params={"name": "bob"}) - - token_b = _token_sent(response, "cookie", config) - session_b, _ = await manager.get_session(token_b) - assert session_b.session_id != session_a.session_id - assert session_b.user is not None - assert session_b.user.user_id == "bob" - assert session_b.data == {"after": 2} - with pytest.raises(SessionNotFoundError): - await manager.get_session(token_a) - assert len(await backend.get_all_keys()) == 1 - assert _me(client, "cookie", config, token_b).json() == { - "user": "bob", - "data": {"after": 2}, - } - - -async def test_login_as_another_user_over_the_header( - manager: SessionManager, config: SessionConfig -) -> None: - _session_a, token_a = await manager.create_session( - SessionUser(user_id="alice"), cart=["book"] - ) - client = TestClient(_login_app(manager, config)) - - response = client.post( - "/write-then-login", - params={"name": "bob"}, - headers={config.header_name: token_a}, - ) - - token_b = _token_sent(response, "header", config) - session_b, _ = await manager.get_session(token_b) - assert session_b.data == {"after": 2} - with pytest.raises(SessionNotFoundError): - await manager.get_session(token_a) - - -async def test_relogin_keeps_the_data_and_updates_the_user( - manager: SessionManager, config: SessionConfig, backend: MemoryBackend -) -> None: - """The same user_id logging in again keeps the data; the new SessionUser wins.""" - session_a, token_a = await manager.create_session( - SessionUser(user_id="alice", roles=["stale"]), cart=["book"] - ) - client = TestClient(_login_app(manager, config)) - client.cookies.set(config.cookie_name, token_a) - - response = client.post("/write-then-login", params={"name": "alice"}) - - token = _token_sent(response, "cookie", config) - session, _ = await manager.get_session(token) - assert session.session_id != session_a.session_id - assert session.user is not None - assert session.user.roles == ["fresh"] - assert session.data == {"cart": ["book"], "before": 1, "after": 2} - with pytest.raises(SessionNotFoundError): - await manager.get_session(token_a) - assert len(await backend.get_all_keys()) == 1 - - -async def test_anonymous_login_keeps_writes_made_before_login( - manager: SessionManager, config: SessionConfig -) -> None: - """The cart case: an anonymous session's data and earlier writes are kept.""" - _anonymous, token = await manager.create_anonymous_session(cart=["book"]) - client = TestClient(_login_app(manager, config)) - client.cookies.set(config.cookie_name, token) - - response = client.post("/write-then-login", params={"name": "bob"}) - - session, _ = await manager.get_session(_token_sent(response, "cookie", config)) - assert session.data == {"cart": ["book"], "before": 1, "after": 2} diff --git a/tests/session/test_logout_and_keep.py b/tests/session/test_logout_and_keep.py deleted file mode 100644 index 417e12f..0000000 --- a/tests/session/test_logout_and_keep.py +++ /dev/null @@ -1,333 +0,0 @@ -"""``logout()``, ``login(keep=...)`` and the read-only ``Session.user`` (#256).""" - -from typing import Annotated -from typing import Any - -import pytest -from fastapi import FastAPI -from fastapi import Query -from fastapi import Request -from fastapi.testclient import TestClient - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session import Session -from fastapi_cachex.session import login -from fastapi_cachex.session import logout -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.dependencies import AuthenticatedSession -from fastapi_cachex.session.exceptions import SessionNotFoundError -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import SessionUser - -from .test_login import TRANSPORTS -from .test_login import _auth -from .test_login import _me -from .test_login import _token_sent - - -def _app(manager: SessionManager, config: SessionConfig) -> FastAPI: - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.post("/logout") - async def log_out(request: Request) -> dict[str, Any]: - was_loaded = await logout(request) - # The record is gone before the response, not when it is sent. - keys = await manager.backend.get_all_keys() - return { - "logged_out": was_loaded, - "keys": keys, - "state": getattr(request.state, "__fastapi_cachex_session", "unset"), - "dict": dict(request.session), - } - - @app.post("/logout-then-write") - async def logout_then_write(request: Request) -> dict[str, bool]: - await logout(request) - request.session["after"] = 1 - return {"ok": True} - - @app.post("/logout-then-login") - async def logout_then_login(request: Request) -> dict[str, bool]: - await logout(request) - await login(request, SessionUser(user_id="bob")) - return {"ok": True} - - @app.post("/login-keep") - async def login_keep( - request: Request, - keep: Annotated[list[str], Query()] = [], # noqa: B006 - name: str = "alice", - ) -> dict[str, Any]: - request.session["before"] = 1 - session = await login(request, SessionUser(user_id=name), keep=keep) - # The record stored under the new ID already lacks the dropped keys. - data_at_login = dict(session.data) - request.session["after"] = 2 - return {"data_at_login": data_at_login} - - @app.post("/login-then-logout") - async def login_then_logout(request: Request) -> dict[str, bool]: - await login(request, SessionUser(user_id="alice")) - return {"logged_out": await logout(request)} - - @app.get("/me") - async def me(session: AuthenticatedSession) -> dict[str, Any]: - assert session.user is not None - return {"user": session.user.user_id, "data": session.data} - - return app - - -def _post( - client: TestClient, - path: str, - transport: str, - config: SessionConfig, - token: str, - **kwargs: Any, -) -> Any: - client.cookies.clear() - if transport == "cookie": - client.cookies.set(config.cookie_name, token) - return client.post(path, **kwargs) - return client.post(path, headers=_auth(transport, config, token), **kwargs) - - -# --- logout() ------------------------------------------------------------------- - - -@pytest.mark.parametrize("transport", TRANSPORTS) -async def test_logout_deletes_the_session_at_once( - manager: SessionManager, config: SessionConfig, transport: str -) -> None: - """The record is deleted during the call; the token no longer resolves.""" - _session, token = await manager.create_session( - SessionUser(user_id="alice"), cart=["book"] - ) - client = TestClient(_app(manager, config)) - - response = _post(client, "/logout", transport, config, token) - - assert response.status_code == 200 - assert response.json() == { - "logged_out": True, - "keys": [], - "state": None, - "dict": {}, - } - with pytest.raises(SessionNotFoundError): - await manager.get_session(token) - assert config.header_name.lower() not in response.headers - if transport == "cookie": - assert "expires=Thu, 01 Jan 1970" in response.headers["set-cookie"] - assert response.headers["cache-control"] == "private, no-store" - else: - assert "set-cookie" not in response.headers - assert _me(client, transport, config, token).status_code == 401 - - -async def test_logout_without_a_session_sends_nothing( - manager: SessionManager, config: SessionConfig -) -> None: - response = TestClient(_app(manager, config)).post("/logout") - - assert response.json()["logged_out"] is False - assert "set-cookie" not in response.headers - - -async def test_writes_after_logout_start_a_new_anonymous_session( - manager: SessionManager, config: SessionConfig, backend: MemoryBackend -) -> None: - session, token = await manager.create_session( - SessionUser(user_id="alice"), cart=["book"] - ) - client = TestClient(_app(manager, config)) - - response = _post(client, "/logout-then-write", "cookie", config, token) - - new_session, _ = await manager.get_session(_token_sent(response, "cookie", config)) - assert new_session.session_id != session.session_id - assert new_session.user is None - assert new_session.data == {"after": 1} - assert len(await backend.get_all_keys()) == 1 - - -async def test_logout_then_login_starts_a_new_session( - manager: SessionManager, config: SessionConfig, backend: MemoryBackend -) -> None: - session, token = await manager.create_session( - SessionUser(user_id="alice"), cart=["book"] - ) - client = TestClient(_app(manager, config)) - - response = _post(client, "/logout-then-login", "cookie", config, token) - - new_session, _ = await manager.get_session(_token_sent(response, "cookie", config)) - assert new_session.session_id != session.session_id - assert new_session.user is not None - assert new_session.user.user_id == "bob" - assert new_session.data == {} - assert len(await backend.get_all_keys()) == 1 - - -async def test_login_then_logout_deletes_the_new_session( - manager: SessionManager, config: SessionConfig -) -> None: - """The session login() started is the one logout() ends.""" - client = TestClient(_app(manager, config)) - - response = client.post("/login-then-logout") - - assert response.json() == {"logged_out": True} - assert await manager.backend.get_all_keys() == [] - assert "01 jan 1970" in response.headers["set-cookie"].lower() - - -async def test_logout_outside_the_middleware_raises() -> None: - request = Request({"type": "http", "method": "POST", "headers": []}) - - with pytest.raises(RuntimeError, match=r"logout\(\) needs"): - await logout(request) - - -# --- login(keep=...) ------------------------------------------------------------ - - -@pytest.mark.parametrize( - ("keep", "kept"), - [ - (["cart"], {"cart": ["book"]}), - (["cart", "before", "missing"], {"cart": ["book"], "before": 1}), - ([], {}), - ], -) -async def test_login_keep_narrows_the_carried_data( - manager: SessionManager, - config: SessionConfig, - keep: list[str], - kept: dict[str, Any], -) -> None: - """Only the listed keys, stored or written before login(), are carried. - - The record stored under the new ID never holds a dropped key (``before`` - lives only in ``request.session`` until the response), and what the - handler writes after login() is saved as usual. - """ - _anonymous, token = await manager.create_anonymous_session( - cart=["book"], tracking="planted" - ) - client = TestClient(_app(manager, config)) - - response = _post( - client, "/login-keep", "cookie", config, token, params={"keep": keep} - ) - - stored_at_login = {k: v for k, v in kept.items() if k != "before"} - assert response.json() == {"data_at_login": stored_at_login} - session, _ = await manager.get_session(_token_sent(response, "cookie", config)) - assert session.data == {**kept, "after": 2} - with pytest.raises(SessionNotFoundError): - await manager.get_session(token) - - -async def test_login_keep_applies_to_a_relogin( - manager: SessionManager, config: SessionConfig -) -> None: - _session, token = await manager.create_session( - SessionUser(user_id="alice"), cart=["book"], elevated=True - ) - client = TestClient(_app(manager, config)) - - response = _post( - client, "/login-keep", "cookie", config, token, params={"keep": ["cart"]} - ) - - session, _ = await manager.get_session(_token_sent(response, "cookie", config)) - assert session.data == {"cart": ["book"], "after": 2} - - -@pytest.mark.parametrize(("keep", "kept"), [(["before"], {"before": 1}), ([], {})]) -async def test_login_keep_applies_without_a_loaded_session( - manager: SessionManager, - config: SessionConfig, - keep: list[str], - kept: dict[str, Any], -) -> None: - """A new visitor's writes before login() are narrowed the same way.""" - client = TestClient(_app(manager, config)) - - response = client.post("/login-keep", params={"keep": keep}) - - session, _ = await manager.get_session(_token_sent(response, "cookie", config)) - assert session.user is not None - assert session.data == {**kept, "after": 2} - - -async def test_login_keep_never_carries_another_users_data( - manager: SessionManager, config: SessionConfig -) -> None: - """``keep`` narrows what is carried; it cannot bring back what is dropped.""" - _session, token = await manager.create_session( - SessionUser(user_id="alice"), cart=["book"] - ) - client = TestClient(_app(manager, config)) - - response = _post( - client, - "/login-keep", - "cookie", - config, - token, - params={"keep": ["cart", "before"], "name": "bob"}, - ) - - assert response.json() == {"data_at_login": {}} - session, _ = await manager.get_session(_token_sent(response, "cookie", config)) - assert session.user is not None - assert session.user.user_id == "bob" - assert session.data == {"after": 2} - - -async def test_login_keep_rejects_a_string( - manager: SessionManager, config: SessionConfig -) -> None: - """``keep="cart"`` would keep the keys "c", "a", "r" and "t".""" - request = Request({"type": "http", "method": "POST", "headers": []}) - - with pytest.raises(TypeError, match=r"keep=\['cart'\]"): - await login(request, SessionUser(user_id="alice"), keep="cart") - - -# --- Read-only Session.user ----------------------------------------------------- - - -def test_session_user_cannot_be_assigned() -> None: - session = Session() - - with pytest.raises(AttributeError, match=r"login\(request, user\)"): - session.user = SessionUser(user_id="alice") - - assert session.user is None - - -def test_other_session_fields_stay_assignable() -> None: - session = Session() - - session.data = {"cart": ["book"]} - session.ip_address = "203.0.113.7" - - assert session.data == {"cart": ["book"]} - assert session.ip_address == "203.0.113.7" - - -def test_a_user_can_still_be_given_when_a_session_is_built() -> None: - """Construction and loading from the backend are not assignments.""" - session = Session(user=SessionUser(user_id="alice")) - loaded = Session.model_validate_json(session.model_dump_json()) - - assert loaded.user is not None - assert loaded.user.user_id == "alice" diff --git a/tests/session/test_lookup_writes.py b/tests/session/test_lookup_writes.py deleted file mode 100644 index b1d0082..0000000 --- a/tests/session/test_lookup_writes.py +++ /dev/null @@ -1,148 +0,0 @@ -"""A session lookup writes to the backend only when something needs storing (#115). - -Before 0.3.8 every ``get_session()`` re-saved the session to record -``last_accessed``, so each authenticated request cost a write, and one that -also modified the session cost two. -""" - -from datetime import datetime -from datetime import timedelta -from datetime import timezone - -import pytest -from fastapi import FastAPI -from fastapi import Request -from fastapi.testclient import TestClient - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import Session -from fastapi_cachex.session.models import SessionUser -from fastapi_cachex.types import CacheEntry - - -class SpyBackend(MemoryBackend): - """A memory backend that records every key passed to ``set`` or ``set_if_equals``.""" - - def __init__(self) -> None: - super().__init__() - self.writes: list[str] = [] - - async def set( - self, key: str, value: CacheEntry, ttl: int | timedelta | None = None - ) -> None: - self.writes.append(key) - await super().set(key, value, ttl=ttl) - - async def set_if_equals( - self, - key: str, - expected: CacheEntry, - value: CacheEntry, - ttl: int | timedelta | None = None, - ) -> bool: - self.writes.append(key) - return await super().set_if_equals(key, expected, value, ttl=ttl) - - -def _manager(**overrides: object) -> tuple[SessionManager, SpyBackend]: - backend = SpyBackend() - config = SessionConfig( - secret_key="a" * 32, cookie_name="session", cookie_https_only=False, **overrides - ) - return SessionManager(backend, config), backend - - -async def _stored(manager: SessionManager, session_id: str) -> Session: - stored = await manager._load_session(session_id) - assert stored is not None - return stored - - -@pytest.mark.parametrize("sliding_expiration", [True, False]) -async def test_plain_lookup_does_not_write(sliding_expiration: bool) -> None: - """A lookup that renews nothing leaves the backend untouched.""" - manager, backend = _manager(sliding_expiration=sliding_expiration) - session, token = await manager.create_session(SessionUser(user_id="u1")) - backend.writes.clear() - - loaded, renewed = await manager.get_session(token) - - assert renewed is None - assert backend.writes == [] - assert loaded.session_id == session.session_id - - -async def test_sliding_renewal_writes_once_and_extends_the_stored_expiry() -> None: - """A renewal must reach the backend, or its TTL would not move.""" - manager, backend = _manager(session_ttl=3600, sliding_threshold=0.5) - session, token = await manager.create_session(SessionUser(user_id="u1")) - session.expires_at = datetime.now(timezone.utc) + timedelta(seconds=100) - await manager.update_session(session) - backend.writes.clear() - - loaded, renewed = await manager.get_session(token) - - assert renewed is not None - assert len(backend.writes) == 1 - stored = await _stored(manager, session.session_id) - assert stored.expires_at == loaded.expires_at - assert stored.expires_at > datetime.now(timezone.utc) + timedelta(seconds=3000) # type: ignore[operator] - - -async def test_touch_stores_last_accessed() -> None: - """``touch=True`` keeps the stored ``last_accessed`` exact.""" - manager, backend = _manager() - session, token = await manager.create_session(SessionUser(user_id="u1")) - backend.writes.clear() - - untouched, _ = await manager.get_session(token) - assert backend.writes == [] - assert (await _stored(manager, session.session_id)).last_accessed < ( - untouched.last_accessed - ) - - touched, _ = await manager.get_session(token, touch=True) - assert len(backend.writes) == 1 - stored = await _stored(manager, session.session_id) - assert stored.last_accessed == touched.last_accessed - - -async def test_session_entries_use_a_constant_fingerprint() -> None: - """Nothing compares session fingerprints, so the payload is not hashed.""" - manager, backend = _manager() - session, _ = await manager.create_session(SessionUser(user_id="u1")) - - entry = await backend.get(manager._get_backend_key(session.session_id)) - - assert entry is not None - assert entry.fingerprint == "session" - - -def test_middleware_reads_without_writing_and_writes_changes_once() -> None: - """Reading ``request.session`` costs no write; changing it costs exactly one.""" - manager, backend = _manager() - app = FastAPI() - app.add_middleware(FastAPICacheXSessionMiddleware, session_manager=manager) - - @app.get("/read") - async def read(request: Request) -> dict[str, object]: - return {"count": request.session.get("count", 0)} - - @app.get("/bump") - async def bump(request: Request) -> dict[str, object]: - request.session["count"] = request.session.get("count", 0) + 1 - return {"count": request.session["count"]} - - client = TestClient(app) - assert client.get("/bump").json() == {"count": 1} # creates the session - backend.writes.clear() - - assert client.get("/read").json() == {"count": 1} - assert backend.writes == [] - - assert client.get("/bump").json() == {"count": 2} - assert len(backend.writes) == 1 - assert client.get("/read").json() == {"count": 2} diff --git a/tests/session/test_manager.py b/tests/session/test_manager.py deleted file mode 100644 index 119600d..0000000 --- a/tests/session/test_manager.py +++ /dev/null @@ -1,778 +0,0 @@ -"""Tests for session manager.""" - -import base64 -import json -from collections.abc import Iterable -from datetime import datetime -from datetime import timedelta -from datetime import timezone - -import pytest -from pydantic import SecretStr - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.exceptions import CacheXError -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.exceptions import SessionExpiredError -from fastapi_cachex.session.exceptions import SessionInvalidError -from fastapi_cachex.session.exceptions import SessionNotFoundError -from fastapi_cachex.session.exceptions import SessionSecurityError -from fastapi_cachex.session.exceptions import SessionTokenError -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.models import Session -from fastapi_cachex.session.models import SessionStatus -from fastapi_cachex.session.models import SessionToken -from fastapi_cachex.session.models import SessionUser -from fastapi_cachex.types import CacheEntry -from fastapi_cachex.types import CacheItem -from tests.conftest import Clock - - -class DummySerializer: - """Stub serializer used to verify DI override coverage.""" - - def __init__(self) -> None: - self.last_token: SessionToken | None = None - self.to_string_calls = 0 - self.from_string_calls = 0 - - def to_string(self, token: SessionToken) -> str: - self.last_token = token - self.to_string_calls += 1 - return f"dummy.{token.session_id}" - - def from_string(self, token_str: str) -> SessionToken: - self.from_string_calls += 1 - _prefix, session_id = token_str.split(".", 1) - return SessionToken( - session_id=session_id, - signature="", - issued_at=datetime.now(timezone.utc), - ) - - -def test_session_manager_accepts_secretstr(backend: MemoryBackend) -> None: - """Ensure SessionManager works with SecretStr secrets.""" - config = SessionConfig(secret_key=SecretStr("a" * 32)) - manager = SessionManager(backend, config) - - signature = manager.security.sign_session_id("test-session-id") - - assert len(signature) == 64 - - -async def test_create_session(manager: SessionManager) -> None: - """Test creating a session.""" - user = SessionUser(user_id="123", username="testuser") - session, token = await manager.create_session(user=user) - - assert session.session_id is not None - assert session.user is not None - assert session.user.user_id == "123" - assert token is not None - - -async def test_create_anonymous_session(manager: SessionManager) -> None: - """Test creating an anonymous session.""" - session, token = await manager.create_anonymous_session() - - assert session.session_id is not None - assert session.user is None - - retrieved, _ = await manager.get_session(token) - assert retrieved.user is None - - -async def test_get_session(manager: SessionManager) -> None: - """Test retrieving a session.""" - user = SessionUser(user_id="123", username="testuser") - created_session, token = await manager.create_session(user=user) - - retrieved_session, _ = await manager.get_session(token) - - assert retrieved_session.session_id == created_session.session_id - assert retrieved_session.user.user_id == "123" # type: ignore[union-attr] - - -async def test_get_invalid_token(manager: SessionManager) -> None: - """Test getting session with invalid token.""" - with pytest.raises(SessionTokenError): - await manager.get_session("invalid-token") - - -async def test_session_errors_are_caught_as_cachex_errors( - manager: SessionManager, -) -> None: - """Test a session error is caught by ``except CacheXError`` (#162).""" - with pytest.raises(CacheXError) as exc_info: - await manager.get_session("invalid-token") - - assert isinstance(exc_info.value, SessionTokenError) - - -async def test_get_nonexistent_session(manager: SessionManager) -> None: - """Test getting nonexistent session.""" - # Create a valid token for a session that doesn't exist - from fastapi_cachex.session.models import SessionToken - from fastapi_cachex.session.token_serializers import SimpleTokenSerializer - - token = SessionToken( - session_id="nonexistent", - signature=manager.security.sign_session_id("nonexistent"), - ) - - serializer = SimpleTokenSerializer() - with pytest.raises(SessionNotFoundError): - await manager.get_session(serializer.to_string(token)) - - -async def test_session_expiry(manager: SessionManager) -> None: - """Test session expiry.""" - # Create session with short TTL - manager.config.session_ttl = 1 - user = SessionUser(user_id="123", username="testuser") - session, token = await manager.create_session(user=user) - - # Manually expire the session - session.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) - await manager.update_session(session) - - with pytest.raises(SessionExpiredError): - await manager.get_session(token) - - -async def test_delete_session(manager: SessionManager) -> None: - """Test deleting a session.""" - user = SessionUser(user_id="123", username="testuser") - session, token = await manager.create_session(user=user) - - await manager.delete_session(session.session_id) - - with pytest.raises(SessionNotFoundError): - await manager.get_session(token) - - -async def test_regenerate_session_id(manager: SessionManager) -> None: - """Test regenerating session ID.""" - user = SessionUser(user_id="123", username="testuser") - session, old_token = await manager.create_session(user=user) - old_id = session.session_id - - updated_session, new_token = await manager.regenerate_session_id(session) - - assert updated_session.session_id != old_id - assert new_token != old_token - - # Old token should not work - with pytest.raises(SessionNotFoundError): - await manager.get_session(old_token) - - # New token should work - retrieved, _ = await manager.get_session(new_token) - assert retrieved.session_id == updated_session.session_id - - -async def test_ip_binding(backend: MemoryBackend) -> None: - """Test IP address binding.""" - config = SessionConfig(secret_key="a" * 32, ip_binding=True) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="123", username="testuser") - session, token = await manager.create_session( - user=user, - ip_address="192.168.1.1", - ) - - # Same IP should work - retrieved, _ = await manager.get_session(token, ip_address="192.168.1.1") - assert retrieved.session_id == session.session_id - - # Different IP should fail - with pytest.raises(SessionSecurityError, match="IP address mismatch"): - await manager.get_session(token, ip_address="192.168.1.2") - - -async def test_user_agent_binding(backend: MemoryBackend) -> None: - """Test User-Agent binding.""" - config = SessionConfig(secret_key="a" * 32, user_agent_binding=True) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="123", username="testuser") - session, token = await manager.create_session( - user=user, - user_agent="Mozilla/5.0", - ) - - # Same UA should work - retrieved, _ = await manager.get_session(token, user_agent="Mozilla/5.0") - assert retrieved.session_id == session.session_id - - # Different UA should fail - with pytest.raises(SessionSecurityError, match="User-Agent mismatch"): - await manager.get_session(token, user_agent="Chrome/91.0") - - -async def test_sliding_expiration(backend: MemoryBackend, clock: Clock) -> None: - """Test sliding expiration.""" - config = SessionConfig( - secret_key="a" * 32, - session_ttl=3600, - sliding_expiration=True, - sliding_threshold=0.5, - ) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="123", username="testuser") - session, token = await manager.create_session(user=user) - - original_expiry = session.expires_at - - clock.advance(1) - - # Set expiry to be past the threshold (only 1000 seconds left vs 3600 TTL) - session.expires_at = datetime.now(timezone.utc) + timedelta(seconds=1000) - await manager.update_session(session) - - # Access session - should trigger renewal since < 50% of TTL remains - retrieved, new_token = await manager.get_session(token) - - # Should be renewed to be further in the future than original - assert retrieved.expires_at > original_expiry # type: ignore[operator] - # A fresh token must be returned so the client's JWT exp stays in sync - assert new_token is not None - - -async def test_invalidate_session(manager: SessionManager) -> None: - """Test invalidating a session.""" - user = SessionUser(user_id="123", username="testuser") - session, token = await manager.create_session(user=user) - - await manager.invalidate_session(session) - - # Token should no longer work - with pytest.raises(SessionInvalidError): - await manager.get_session(token) - - -async def test_clear_expired_sessions(backend: MemoryBackend) -> None: - """Test clearing expired sessions.""" - config = SessionConfig(secret_key="a" * 32, session_ttl=1) - manager = SessionManager(backend, config) - - # Create a few sessions - user1 = SessionUser(user_id="1", username="user1") - user2 = SessionUser(user_id="2", username="user2") - - session1, _token1 = await manager.create_session(user=user1) - session2, _token2 = await manager.create_session(user=user2) - - # Manually expire one session - session1.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) - await manager.update_session(session1) - - # Keep the other session valid - session2.expires_at = datetime.now(timezone.utc) + timedelta(seconds=3600) - await manager.update_session(session2) - - # Clear expired sessions - count = await manager.clear_expired_sessions() - - assert count == 1 # Only one session should be cleared - - -async def test_save_session_with_expired_session( - backend: MemoryBackend, -) -> None: - """Test saving an expired session and its TTL calculation.""" - config = SessionConfig(secret_key="a" * 32, session_ttl=3600) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="123", username="testuser") - session, token = await manager.create_session(user=user) - - # Manually expire the session - session.expires_at = datetime.now(timezone.utc) - timedelta(seconds=10) - - # Save the expired session - await manager._save_session(session, conditional=False) - - # Try to retrieve - should fail with SessionExpiredError - with pytest.raises(SessionExpiredError): - await manager.get_session(token) - - -async def test_update_session(manager: SessionManager) -> None: - """Test updating a session.""" - user = SessionUser(user_id="123", username="testuser") - session, token = await manager.create_session(user=user) - - # Update session data - session.data["updated_field"] = "updated_value" - await manager.update_session(session) - - # Retrieve and verify - retrieved, _ = await manager.get_session(token) - assert retrieved.data.get("updated_field") == "updated_value" - - -async def test_delete_user_sessions(manager: SessionManager) -> None: - """Test deleting all sessions for a specific user.""" - user1 = SessionUser(user_id="user1", username="testuser1") - user2 = SessionUser(user_id="user2", username="testuser2") - - # Create multiple sessions for user1 - _session1, _token1 = await manager.create_session(user=user1) - _session2, _token2 = await manager.create_session(user=user1) - - # Create a session for user2 - session3, token3 = await manager.create_session(user=user2) - - # Delete all sessions for user1 - count = await manager.delete_user_sessions("user1") - - assert count == 2 - - # user2's session should still be accessible - retrieved, _ = await manager.get_session(token3) - assert retrieved.session_id == session3.session_id - - -async def test_invalid_session_signature( - backend: MemoryBackend, -) -> None: - """Test that invalid session signature raises SessionSecurityError.""" - config = SessionConfig(secret_key="a" * 32) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="123", username="testuser") - _session, token = await manager.create_session(user=user) - - # Tamper with the token signature - from fastapi_cachex.session.models import SessionToken - from fastapi_cachex.session.token_serializers import SimpleTokenSerializer - - serializer = SimpleTokenSerializer() - original_token = serializer.from_string(token) - tampered_token = SessionToken( - session_id=original_token.session_id, - signature="invalid_signature_' " + original_token.signature, - issued_at=original_token.issued_at, - ) - - # Try to get session with tampered token - with pytest.raises(SessionSecurityError, match="Invalid session signature"): - await manager.get_session(serializer.to_string(tampered_token)) - - -async def test_session_manager_respects_custom_serializer( - backend: MemoryBackend, -) -> None: - """Ensure DI serializer override is used for token operations.""" - config = SessionConfig(secret_key="a" * 32, token_format="jwt") - serializer = DummySerializer() - manager = SessionManager(backend, config, token_serializer=serializer) - - session, token = await manager.create_anonymous_session() - - assert token.startswith("dummy.") - retrieved, _ = await manager.get_session(token) - assert retrieved.session_id == session.session_id - assert serializer.to_string_calls == 1 - assert serializer.from_string_calls == 1 - assert serializer.last_token is not None - - -async def test_jwt_token_format_uses_serializer( - monkeypatch: pytest.MonkeyPatch, backend: MemoryBackend -) -> None: - """Cover JWT token path without requiring external jwt dependency.""" - - class StubJWTSerializer: - def __init__(self, config: SessionConfig) -> None: - self.config = config - self.to_string_tokens: list[SessionToken] = [] - self.from_string_payloads: list[str] = [] - - def to_string(self, token: SessionToken) -> str: - self.to_string_tokens.append(token) - return f"jwt.{token.session_id}" - - def from_string(self, token_str: str) -> SessionToken: - self.from_string_payloads.append(token_str) - _prefix, session_id = token_str.split(".", 1) - return SessionToken( - session_id=session_id, - signature="", - issued_at=datetime.now(timezone.utc), - ) - - monkeypatch.setattr( - "fastapi_cachex.session.manager.JWTTokenSerializer", StubJWTSerializer - ) - - config = SessionConfig(secret_key="a" * 32, token_format="jwt") - manager = SessionManager(backend, config) - - assert isinstance(manager._serializer, StubJWTSerializer) - - user = SessionUser(user_id="jwt-user", username=None) - session, token = await manager.create_session(user=user) - - stub = manager._serializer - assert stub.to_string_tokens[0].signature == "" - - retrieved, _ = await manager.get_session(token) - assert retrieved.session_id == session.session_id - assert stub.from_string_payloads == [token] - - -async def test_load_session_by_key_invalid_payload_returns_none( - manager: SessionManager, backend: MemoryBackend -) -> None: - """Ensure invalid cached payloads are ignored gracefully.""" - key = manager._get_backend_key("invalid-id") - backend.cache[key] = CacheItem( - value=CacheEntry(fingerprint="bad", content=b"{not-json}"), - expiry=None, - ) - - session = await manager._load_session_by_key(key) - - assert session is None - - -async def test_save_session_without_ttl_uses_none_expiry( - backend: MemoryBackend, -) -> None: - """Sessions without TTL should persist with no expiry set.""" - config = SessionConfig(secret_key="a" * 32, session_ttl=0) - manager = SessionManager(backend, config) - - session, token = await manager.create_anonymous_session() - - cache_data = await backend.get_cache_data() - key = manager._get_backend_key(session.session_id) - - assert session.expires_at is None - assert key in cache_data - _value, expiry = cache_data[key] - assert expiry is None - - retrieved, _ = await manager.get_session(token) - assert retrieved.session_id == session.session_id - - -# --------------------------------------------------------------------------- -# New tests for fixes applied in this session -# --------------------------------------------------------------------------- - - -async def test_absolute_timeout_raises_session_expired_error( - backend: MemoryBackend, -) -> None: - """absolute_timeout expires a record whose own expiry lies past the cap. - - New records are capped at creation (#164), so this covers records stored - before absolute_timeout was set or lowered. - """ - uncapped = SessionManager( - backend, SessionConfig(secret_key="a" * 32, session_ttl=3600) - ) - session, token = await uncapped.create_session(user=SessionUser(user_id="abs-user")) - session.created_at -= timedelta(seconds=61) - await uncapped.update_session(session) - - config = SessionConfig(secret_key="a" * 32, session_ttl=3600, absolute_timeout=60) - manager = SessionManager(backend, config) - - with pytest.raises(SessionExpiredError, match="absolute timeout"): - await manager.get_session(token) - - -async def test_absolute_timeout_zero_disables_enforcement( - backend: MemoryBackend, -) -> None: - """absolute_timeout=None (default/disabled) must not prematurely expire sessions.""" - config = SessionConfig(secret_key="a" * 32, session_ttl=3600, absolute_timeout=None) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="no-abs", username="testuser") - _session, token = await manager.create_session(user=user) - - # Should still be valid - retrieved, _ = await manager.get_session(token) - assert retrieved is not None - - -async def test_absolute_timeout_not_triggered_before_expiry( - backend: MemoryBackend, -) -> None: - """Session should remain valid before the absolute_timeout is reached.""" - config = SessionConfig(secret_key="a" * 32, session_ttl=3600, absolute_timeout=60) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="fresh-user", username="testuser") - _session, token = await manager.create_session(user=user) - - # Should be valid immediately (well within 60 s absolute timeout) - retrieved, _ = await manager.get_session(token) - assert retrieved is not None - - -async def _age(manager: SessionManager, session: Session, seconds: int) -> None: - """Move a stored session `seconds` into the past.""" - session.created_at -= timedelta(seconds=seconds) - assert session.expires_at is not None - session.expires_at -= timedelta(seconds=seconds) - await manager.update_session(session) - - -async def test_sliding_renewal_stops_at_absolute_timeout( - backend: MemoryBackend, -) -> None: - """Renewal must not extend expires_at, the TTL or the JWT exp past the cap (#164).""" - config = SessionConfig( - secret_key="a" * 32, - token_format="jwt", - session_ttl=100, - sliding_threshold=0.5, - absolute_timeout=120, - ) - manager = SessionManager(backend, config) - session, token = await manager.create_session(user=SessionUser(user_id="u")) - await _age(manager, session, 60) # 40 s left of 100: renew - - renewed, new_token = await manager.get_session(token) - - cap = renewed.created_at + timedelta(seconds=120) - assert renewed.expires_at == cap - assert new_token is not None - payload = new_token.split(".")[1] - claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) - assert claims["exp"] == int(cap.timestamp()) - item = backend.cache[manager._get_backend_key(renewed.session_id)] - assert item.expiry is not None - assert item.expiry <= cap.timestamp() - - -async def test_no_renewal_once_expiry_reaches_absolute_timeout( - backend: MemoryBackend, -) -> None: - """At the cap there is nothing to extend, so no token is re-issued.""" - config = SessionConfig( - secret_key="a" * 32, - session_ttl=100, - sliding_threshold=0.5, - absolute_timeout=120, - ) - manager = SessionManager(backend, config) - session, token = await manager.create_session(user=SessionUser(user_id="u")) - await _age(manager, session, 80) # the cap leaves 40 s, under the 50 s threshold - _renewed, first = await manager.get_session(token) - assert first is not None - - _session, second = await manager.get_session(first) - - assert second is None - - -async def test_new_session_expiry_is_capped_by_absolute_timeout( - backend: MemoryBackend, -) -> None: - """A session_ttl longer than absolute_timeout must not outlive the cap.""" - config = SessionConfig(secret_key="a" * 32, session_ttl=3600, absolute_timeout=60) - manager = SessionManager(backend, config) - - session, _token = await manager.create_session(user=SessionUser(user_id="u")) - - assert session.expires_at == session.created_at + timedelta(seconds=60) - - -async def test_delete_user_sessions_no_sessions(backend: MemoryBackend) -> None: - """delete_user_sessions returns 0 when the user has no sessions.""" - manager = SessionManager(backend, SessionConfig(secret_key="a" * 32)) - count = await manager.delete_user_sessions("nonexistent-user") - assert count == 0 - - -async def test_clear_expired_sessions_none_expired(backend: MemoryBackend) -> None: - """clear_expired_sessions returns 0 when no sessions are expired.""" - config = SessionConfig(secret_key="a" * 32, session_ttl=3600) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="active-user", username="active") - await manager.create_session(user=user) - - count = await manager.clear_expired_sessions() - assert count == 0 - - -async def test_load_session_by_key_invalid_json(backend: MemoryBackend) -> None: - """_load_session_by_key returns None for corrupt (non-JSON) session data.""" - config = SessionConfig(secret_key="a" * 32) - manager = SessionManager(backend, config) - - bad_key = "session:corrupt-id" - await backend.set(bad_key, CacheEntry(fingerprint="x", content=b"{not valid json}")) - - result = await manager._load_session_by_key(bad_key) - assert result is None - - -async def test_ip_binding_with_none_ip_emits_warning( - backend: MemoryBackend, caplog: pytest.LogCaptureFixture -) -> None: - """ip_binding=True with ip_address=None must log a warning and still create session.""" - import logging - - config = SessionConfig(secret_key="a" * 32, ip_binding=True) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="ip-test", username="testuser") - with caplog.at_level(logging.WARNING, logger="fastapi_cachex.session.manager"): - session, token = await manager.create_session(user=user, ip_address=None) - - assert session is not None - assert token - assert session.ip_address is None - assert any("ip_binding" in msg for msg in caplog.messages) - - -async def test_user_agent_binding_with_none_ua_emits_warning( - backend: MemoryBackend, caplog: pytest.LogCaptureFixture -) -> None: - """user_agent_binding=True with user_agent=None must log a warning and still create session.""" - import logging - - config = SessionConfig(secret_key="a" * 32, user_agent_binding=True) - manager = SessionManager(backend, config) - - user = SessionUser(user_id="ua-test", username="testuser") - with caplog.at_level(logging.WARNING, logger="fastapi_cachex.session.manager"): - session, token = await manager.create_session(user=user, user_agent=None) - - assert session is not None - assert token - assert session.user_agent is None - assert any("user_agent_binding" in msg for msg in caplog.messages) - - -async def test_session_scans_ignore_keys_outside_the_session_prefix( - backend: MemoryBackend, -) -> None: - """Route-cache or CacheManager keys sharing the backend are never read or deleted.""" - config = SessionConfig(secret_key="a" * 32, session_ttl=1) - manager = SessionManager(backend, config) - await backend.set("cache:unrelated", CacheEntry(fingerprint="x", content=b"1")) - session, _ = await manager.create_session( - user=SessionUser(user_id="1", username="user1") - ) - session.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) - await manager.update_session(session) - - assert await manager.clear_expired_sessions() == 1 - assert await manager.delete_user_sessions("1") == 0 - assert await backend.get("cache:unrelated") is not None - - -async def test_session_sweeps_are_no_ops_without_key_enumeration() -> None: - """Memcached cannot list keys, so the bulk operations yield nothing. - - `_iter_sessions` swallows `NotImplementedError` so a caller on such a - backend gets `0` rather than a crash. - """ - - class NoEnumerationBackend(MemoryBackend): - async def get_all_keys(self) -> list[str]: - raise NotImplementedError - - config = SessionConfig(secret_key="a" * 32) - manager = SessionManager(NoEnumerationBackend(), config) - await manager.create_session(user=SessionUser(user_id="u1")) - - assert await manager.delete_user_sessions("u1") == 0 - assert await manager.clear_expired_sessions() == 0 - - -async def test_delete_user_sessions_covers_every_session_of_that_user() -> None: - """The sweep has to keep going after the first match, and skip other users.""" - config = SessionConfig(secret_key="a" * 32) - manager = SessionManager(MemoryBackend(), config) - - await manager.create_session(user=SessionUser(user_id="u1")) - await manager.create_session(user=SessionUser(user_id="u1")) - _, other_token = await manager.create_session(user=SessionUser(user_id="u2")) - - assert await manager.delete_user_sessions("u1") == 2 - assert await manager.delete_user_sessions("u1") == 0 - assert await manager.get_session(other_token) is not None - - -async def test_session_sweeps_skip_entries_they_cannot_read() -> None: - """A key under the prefix that does not decode must be stepped over. - - Anything may end up under the prefix -- a half-written entry, a record - from an older schema -- and a bulk operation that raised on one of them - would take every later session down with it. - """ - config = SessionConfig(secret_key="a" * 32) - backend = MemoryBackend() - manager = SessionManager(backend, config) - - await manager.create_session(user=SessionUser(user_id="u1")) - await backend.set( - f"{config.backend_key_prefix}not-a-session", - CacheEntry(fingerprint="e", content=b"not json"), - ) - - assert await manager.delete_user_sessions("u1") == 1 - - -@pytest.mark.parametrize("status", [SessionStatus.INVALIDATED, SessionStatus.EXPIRED]) -async def test_clear_expired_sessions_removes_sessions_no_longer_active( - status: SessionStatus, -) -> None: - """A record marked invalidated or expired is unusable before its TTL (#165).""" - backend = MemoryBackend() - manager = SessionManager(backend, SessionConfig(secret_key="a" * 32)) - marked, _ = await manager.create_session(user=SessionUser(user_id="u1")) - _, active_token = await manager.create_session(user=SessionUser(user_id="u2")) - marked.status = status - await manager.update_session(marked) - - assert await manager.clear_expired_sessions() == 1 - - assert await backend.get(manager._get_backend_key(marked.session_id)) is None - assert await manager.get_session(active_token) is not None - - -async def test_session_sweeps_delete_in_one_batch() -> None: - """Both sweeps hand every matching key to a single delete_many (#165).""" - - class CountingBackend(MemoryBackend): - def __init__(self) -> None: - super().__init__() - self.deletes = 0 - self.batches: list[int] = [] - - async def delete(self, key: str) -> bool: - self.deletes += 1 - return await super().delete(key) - - async def delete_many(self, keys: Iterable[str]) -> int: - keys = list(keys) - self.batches.append(len(keys)) - return await super().delete_many(keys) - - backend = CountingBackend() - manager = SessionManager(backend, SessionConfig(secret_key="a" * 32)) - for _ in range(3): - session, _ = await manager.create_session(user=SessionUser(user_id="u1")) - for _ in range(2): - session, _ = await manager.create_session(user=SessionUser(user_id="u2")) - await manager.invalidate_session(session) - - assert await manager.clear_expired_sessions() == 2 - assert await manager.delete_user_sessions("u1") == 3 - assert backend.batches == [2, 3] - assert backend.deletes == 0 diff --git a/tests/session/test_middleware.py b/tests/session/test_middleware.py deleted file mode 100644 index c5bb2f1..0000000 --- a/tests/session/test_middleware.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Tests for client address resolution and header token extraction.""" - -import pytest -from fastapi import Request - -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.middleware import _read_header_token -from fastapi_cachex.session.middleware import get_client_ip - -# `get_client_ip` is the address resolution the middleware binds sessions to. - - -def test_get_client_ip_ignores_forwarded_headers_by_default( - config: SessionConfig, -) -> None: - """Forwarded headers are spoofable, so an untrusted peer's are ignored.""" - connection = _connection( - {"X-Forwarded-For": "1.2.3.4", "X-Real-IP": "5.6.7.8"}, peer="10.0.0.9" - ) - - assert get_client_ip(connection, config) == "10.0.0.9" - - -def test_get_client_ip_from_x_forwarded_for_behind_trusted_proxy() -> None: - """A proxy the app vouches for may report the real client address.""" - config = SessionConfig( - secret_key="a" * 32, trusted_proxies=["10.0.0.9", "10.0.0.1"] - ) - connection = _connection( - {"X-Forwarded-For": "192.168.1.1, 10.0.0.1"}, peer="10.0.0.9" - ) - - assert get_client_ip(connection, config) == "192.168.1.1" - - -def test_get_client_ip_ignores_a_prepended_forwarded_entry() -> None: - """Proxies append, so the leftmost entry is whatever the caller sent.""" - config = SessionConfig(secret_key="a" * 32, trusted_proxies=["10.0.0.9"]) - # The attacker sent the first entry themselves; nginx appended the second. - connection = _connection( - {"X-Forwarded-For": "198.51.100.5, 203.0.113.99"}, peer="10.0.0.9" - ) - - assert get_client_ip(connection, config) == "203.0.113.99" - - -def test_get_client_ip_falls_back_when_every_hop_is_trusted() -> None: - """With no untrusted entry left there is no client address to recover.""" - config = SessionConfig( - secret_key="a" * 32, trusted_proxies=["10.0.0.9", "10.0.0.1"] - ) - connection = _connection({"X-Forwarded-For": "10.0.0.1"}, peer="10.0.0.9") - - assert get_client_ip(connection, config) == "10.0.0.9" - - -def test_get_client_ip_from_real_ip_behind_trusted_proxy() -> None: - """X-Real-IP is the fallback once the peer is trusted.""" - config = SessionConfig(secret_key="a" * 32, trusted_proxies=["10.0.0.9"]) - connection = _connection({"X-Real-IP": "192.168.1.1"}, peer="10.0.0.9") - - assert get_client_ip(connection, config) == "192.168.1.1" - - -def test_get_client_ip_from_client(config: SessionConfig) -> None: - connection = _connection({}, peer="192.168.1.1") - - assert get_client_ip(connection, config) == "192.168.1.1" - - -def test_get_client_ip_none(config: SessionConfig) -> None: - """No peer and no trusted forwarding: there is no address.""" - assert get_client_ip(_connection({}), config) is None - - -def _connection(headers: dict[str, str], peer: str | None = None) -> Request: - """A bare `Request` carrying only the headers (and peer) under test.""" - return Request( - { - "type": "http", - "method": "GET", - "path": "/", - "client": (peer, 1234) if peer is not None else None, - "headers": [ - (key.lower().encode(), value.encode()) for key, value in headers.items() - ], - } - ) - - -def test_bearer_is_used_when_the_header_source_finds_nothing( - config: SessionConfig, -) -> None: - """The priority list is a fallback chain, not a first-entry-only lookup. - - Every other extraction test supplies the header it asks for first, so the - loop always returned on its first pass and an implementation that only - ever checked `token_source_priority[0]` would have passed them all. - """ - token = _read_header_token( - _connection({"Authorization": "Bearer from-bearer"}), config - )[0] - - assert token == "from-bearer" - - -@pytest.mark.parametrize( - "authorization", - [ - "Bearer from-bearer", - "bearer from-bearer", - "BEARER from-bearer", - "Bearer from-bearer", - ], -) -def test_bearer_scheme_is_matched_case_insensitively( - config: SessionConfig, authorization: str -) -> None: - """Auth schemes are case-insensitive (RFC 9110 §11.1), and RFC 6750 allows - more than one space before the token (#166). - """ - token = _read_header_token(_connection({"Authorization": authorization}), config)[0] - - assert token == "from-bearer" - - -@pytest.mark.parametrize( - "authorization", - ["Bearer", "Bearer ", "Bearer ", "Basic from-bearer", "Bearerfrom-bearer"], -) -def test_an_empty_or_non_bearer_authorization_header_yields_no_token( - config: SessionConfig, authorization: str -) -> None: - token = _read_header_token(_connection({"Authorization": authorization}), config)[0] - - assert token is None - - -def test_header_wins_over_bearer_when_both_are_present( - config: SessionConfig, -) -> None: - """Order in the list is the order that is honoured.""" - token = _read_header_token( - _connection( - { - config.header_name: "from-header", - "Authorization": "Bearer from-bearer", - } - ), - config, - )[0] - - assert token == "from-header" - - -def test_bearer_source_is_skipped_when_bearer_tokens_are_disabled() -> None: - """`use_bearer_token=False` must win over the priority list.""" - with pytest.warns(DeprecationWarning, match="use_bearer_token"): - config = SessionConfig(secret_key="a" * 32, use_bearer_token=False) - - token = _read_header_token( - _connection({"Authorization": "Bearer from-bearer"}), config - )[0] - - assert token is None - - -def test_an_unknown_source_is_skipped_rather_than_read_as_a_bearer_token() -> None: - """The chain ends in an `elif`, not an `else`, and this is why. - - `token_source_priority` is a list of Literals, so pydantic refuses an - unknown source at construction and no caller can reach this through the - public API. Nothing revalidates the list afterwards, though, and the - branch exists for the maintainer who adds a third source to the Literal - and forgets this function: it must fall through, not inherit whatever the - last branch happens to do. Mutating the list in place is the only way to - stand where that maintainer will stand. - """ - config = SessionConfig(secret_key="a" * 32) - config.token_source_priority[:] = ["query"] # type: ignore[list-item] - - token = _read_header_token( - _connection({"Authorization": "Bearer from-bearer"}), config - )[0] - - assert token is None - - -def test_a_known_source_after_an_unknown_one_is_still_honoured( - config: SessionConfig, -) -> None: - """Falling through must continue the chain, not abandon it.""" - config.token_source_priority[:] = ["query", "header"] # type: ignore[list-item] - - token = _read_header_token( - _connection({config.header_name: "from-header"}), config - )[0] - - assert token == "from-header" diff --git a/tests/session/test_models.py b/tests/session/test_models.py deleted file mode 100644 index f043d2c..0000000 --- a/tests/session/test_models.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Tests for session models.""" - -from datetime import datetime -from datetime import timedelta -from datetime import timezone - -import pytest - -from fastapi_cachex.session.models import Session -from fastapi_cachex.session.models import SessionStatus -from fastapi_cachex.session.models import SessionToken -from fastapi_cachex.session.models import SessionUser -from fastapi_cachex.session.token_serializers import SimpleTokenSerializer -from tests.conftest import Clock - - -def test_session_user_creation() -> None: - """Test creating a session user.""" - user = SessionUser(user_id="123", username="testuser", email="test@example.com") - assert user.user_id == "123" - assert user.username == "testuser" - assert user.email == "test@example.com" - assert user.roles == [] - assert user.permissions == [] - - -def test_session_user_with_roles() -> None: - """Test session user with roles and permissions.""" - user = SessionUser( - user_id="123", - username="testuser", - roles=["admin", "user"], - permissions=["read", "write"], - ) - assert "admin" in user.roles - assert "write" in user.permissions - - -def test_session_creation() -> None: - """Test creating a session.""" - session = Session() - assert session.session_id is not None - assert session.status == SessionStatus.ACTIVE - assert session.user is None - assert isinstance(session.created_at, datetime) - assert isinstance(session.last_accessed, datetime) - - -def test_session_with_user() -> None: - """Test session with user data.""" - user = SessionUser(user_id="123", username="testuser") - session = Session(user=user) - assert session.user is not None - assert session.user.user_id == "123" - - -def test_session_is_valid() -> None: - """Test session validity check.""" - session = Session() - assert session.is_valid() - - # Expired session - session.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) - assert not session.is_valid() - - # Invalidated session - session.expires_at = None - session.status = SessionStatus.INVALIDATED - assert not session.is_valid() - - -def test_session_is_expired() -> None: - """Test session expiry check.""" - session = Session() - assert not session.is_expired() - - session.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) - assert session.is_expired() - - session.expires_at = datetime.now(timezone.utc) + timedelta(hours=1) - assert not session.is_expired() - - -def test_session_renew(clock: Clock) -> None: - """Test session renewal.""" - session = Session() - session.expires_at = datetime.now(timezone.utc) + timedelta(hours=1) - old_expiry = session.expires_at - - clock.advance(1) - - session.renew(3600) # Renew for 1 hour - - assert session.expires_at > old_expiry - - -def test_session_regenerate_id() -> None: - """Test session ID regeneration.""" - session = Session() - old_id = session.session_id - - new_id = session.regenerate_id() - - assert new_id != old_id - assert session.session_id == new_id - - -def test_session_flash_messages() -> None: - """Test flash message functionality.""" - session = Session() - - session.add_flash_message("Hello", "info") - session.add_flash_message("Error occurred", "error") - - messages = session.get_flash_messages(clear=False) - assert len(messages) == 2 - assert messages[0]["message"] == "Hello" - assert messages[1]["category"] == "error" - - # Still has messages - assert len(session.flash_messages) == 2 - - # Clear messages - messages = session.get_flash_messages(clear=True) - assert len(messages) == 2 - assert len(session.flash_messages) == 0 - - -def test_session_token_to_string() -> None: - """Test session token string conversion.""" - serializer = SimpleTokenSerializer() - token = SessionToken(session_id="test123", signature="abc123") - token_str = serializer.to_string(token) - - assert "test123" in token_str - assert "abc123" in token_str - assert token_str.count(".") == 2 - - -def test_session_token_from_string() -> None: - """Test session token parsing.""" - serializer = SimpleTokenSerializer() - token = SessionToken(session_id="test123", signature="abc123") - token_str = serializer.to_string(token) - - parsed = serializer.from_string(token_str) - - assert parsed.session_id == "test123" - assert parsed.signature == "abc123" - - -def test_session_token_invalid_format() -> None: - """Test session token with invalid format.""" - serializer = SimpleTokenSerializer() - with pytest.raises(ValueError, match="Invalid token format"): - serializer.from_string("invalid") - - with pytest.raises(ValueError, match="Invalid token format"): - serializer.from_string("only.two") - - -def test_session_token_invalid_timestamp() -> None: - """Test session token with invalid timestamp.""" - serializer = SimpleTokenSerializer() - with pytest.raises(ValueError, match="Invalid timestamp"): - serializer.from_string("test123.abc123.invalid") diff --git a/tests/session/test_security.py b/tests/session/test_security.py deleted file mode 100644 index bb980c5..0000000 --- a/tests/session/test_security.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Tests for security utilities.""" - -import pytest - -from fastapi_cachex.session.models import Session -from fastapi_cachex.session.security import SecurityManager - - -def test_security_manager_initialization() -> None: - """Test security manager initialization: signing works with a valid key.""" - manager = SecurityManager("a" * 32) - assert manager.sign_session_id("test") is not None - - -def test_security_manager_short_key() -> None: - """Test security manager with short key.""" - with pytest.raises(ValueError, match="at least 32 characters"): - SecurityManager("short") - - -def test_sign_session_id() -> None: - """Test session ID signing.""" - manager = SecurityManager("a" * 32) - signature = manager.sign_session_id("test-session-id") - - assert signature is not None - assert len(signature) == 64 # SHA256 hex digest - - -def test_verify_signature() -> None: - """Test signature verification.""" - manager = SecurityManager("a" * 32) - session_id = "test-session-id" - signature = manager.sign_session_id(session_id) - - # Valid signature - assert manager.verify_signature(session_id, signature) - - # Invalid signature - assert not manager.verify_signature(session_id, "invalid") - - # Different session ID - assert not manager.verify_signature("different-id", signature) - - -def test_check_ip_match() -> None: - """Test IP address matching.""" - manager = SecurityManager("a" * 32) - - # Session without IP binding - session = Session() - assert manager.check_ip_match(session, "192.168.1.1") - assert manager.check_ip_match(session, None) - - # Session with IP binding - session.ip_address = "192.168.1.1" - assert manager.check_ip_match(session, "192.168.1.1") - assert not manager.check_ip_match(session, "192.168.1.2") - assert not manager.check_ip_match(session, None) - - -def test_check_user_agent_match() -> None: - """Test User-Agent matching.""" - manager = SecurityManager("a" * 32) - - # Session without UA binding - session = Session() - assert manager.check_user_agent_match(session, "Mozilla/5.0") - assert manager.check_user_agent_match(session, None) - - # Session with UA binding - session.user_agent = "Mozilla/5.0" - assert manager.check_user_agent_match(session, "Mozilla/5.0") - assert not manager.check_user_agent_match(session, "Chrome/91.0") - assert not manager.check_user_agent_match(session, None) - - -def test_hash_data() -> None: - """Test data hashing.""" - manager = SecurityManager("a" * 32) - hash1 = manager.hash_data("test data") - hash2 = manager.hash_data("test data") - hash3 = manager.hash_data("different data") - - assert hash1 == hash2 - assert hash1 != hash3 - assert len(hash1) == 64 # SHA256 hex digest diff --git a/tests/session/test_starlette_middleware.py b/tests/session/test_starlette_middleware.py deleted file mode 100644 index 8c39992..0000000 --- a/tests/session/test_starlette_middleware.py +++ /dev/null @@ -1,1173 +0,0 @@ -"""Tests for the Starlette-aligned, backend-backed session middleware. - -FastAPICacheXSessionMiddleware reuses starlette.middleware.sessions.Session for -its dict-like scope["session"] interface. That module imports itsdangerous at -module level, which is why itsdangerous is a base dependency rather than an -extra: the middleware cannot be constructed without it. -""" - -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from typing import Any - -import pytest -from fastapi import Depends -from fastapi import FastAPI -from fastapi import Request -from fastapi.testclient import TestClient - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.dependencies import AuthenticatedSession -from fastapi_cachex.session.dependencies import RequiredSession -from fastapi_cachex.session.dependencies import UserSessionDep -from fastapi_cachex.session.dependencies import get_session -from fastapi_cachex.session.dependencies import rotate_session_id -from fastapi_cachex.session.exceptions import SessionNotFoundError -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import SessionUser -from fastapi_cachex.session.proxy import SessionManagerProxy - - -def _extract_cookie_token(set_cookie_header: str, cookie_name: str) -> str: - """Pull the cookie value out of a raw Set-Cookie header string.""" - first_pair = set_cookie_header.split(";", maxsplit=1)[0] - name, _, value = first_pair.partition("=") - assert name == cookie_name - return value - - -def test_middleware_initialization( - manager: SessionManager, config: SessionConfig -) -> None: - """Test middleware initialization.""" - - async def app(scope, receive, send): - pass - - middleware = FastAPICacheXSessionMiddleware(app, manager, config) - - assert middleware.session_manager is manager - assert middleware.config is config - - -def test_middleware_initialization_uses_manager_config( - manager: SessionManager, -) -> None: - """Ensure middleware defaults to manager config when none provided.""" - - async def app(scope, receive, send): - pass - - middleware = FastAPICacheXSessionMiddleware(app, manager) - - assert middleware.config is manager.config - - -def test_default_cookie_config_values() -> None: - """The cookie defaults to a ``__Host-`` name with Secure (#256).""" - config = SessionConfig(secret_key="a" * 32) - - assert config.cookie_name == "__Host-session" - assert config.cookie_max_age == 14 * 24 * 60 * 60 - assert config.cookie_path == "/" - assert config.cookie_same_site == "lax" - assert config.cookie_https_only is True - assert config.cookie_domain is None - - -def test_default_cookie_is_host_prefixed_and_round_trips_over_https() -> None: - """The default config sends a cookie browsers accept only from this host. - - Neither the config nor the middleware warns about it any more (#256). - """ - config = SessionConfig(secret_key="a" * 32) - manager = SessionManager(MemoryBackend(), config) - app = FastAPI() - app.add_middleware(FastAPICacheXSessionMiddleware, session_manager=manager) - - @app.get("/set") - async def set_route(request: Request) -> dict[str, bool]: - request.session["foo"] = "bar" - return {"ok": True} - - @app.get("/get") - async def get_route(request: Request) -> dict[str, Any]: - return {"foo": request.session.get("foo")} - - client = TestClient(app, base_url="https://testserver") - set_cookie = client.get("/set").headers["set-cookie"] - - assert set_cookie.startswith("__Host-session=") - assert "; path=/;" in set_cookie - assert "secure" in set_cookie - assert "domain" not in set_cookie - assert client.get("/get").json() == {"foo": "bar"} - - -def test_set_cookie_header_includes_secure_and_domain_flags( - manager: SessionManager, -) -> None: - """cookie_https_only and cookie_domain must be reflected in Set-Cookie.""" - config = SessionConfig( - secret_key="a" * 32, - cookie_name="session", - cookie_https_only=True, - cookie_domain="example.com", - ) - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/set") - async def set_route(request: Request) -> dict[str, bool]: - request.session["foo"] = "bar" - return {"ok": True} - - client = TestClient(app) - response = client.get("/set") - - set_cookie = response.headers["set-cookie"] - assert "secure" in set_cookie - assert "domain=example.com" in set_cookie - - -async def test_call_passes_through_non_http_scope() -> None: - """Non-http/websocket scopes (e.g. lifespan) must bypass session handling entirely.""" - config = SessionConfig( - secret_key="a" * 32, cookie_name="session", cookie_https_only=False - ) - manager = SessionManager(MemoryBackend(), config) - - calls: list[str] = [] - - async def app(scope, receive, send) -> None: - calls.append(scope["type"]) - - middleware = FastAPICacheXSessionMiddleware(app, manager, config) - - await middleware({"type": "lifespan"}, None, None) # type: ignore[arg-type] - - assert calls == ["lifespan"] - - -def test_get_session_manager_di_stashed_once_across_requests( - manager: SessionManager, config: SessionConfig -) -> None: - """Session manager is only stashed on app.state on the first request, not re-stashed.""" - from fastapi_cachex.session.dependencies import SessionManagerDep - - SessionManagerProxy.set(manager) - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/manager") - async def manager_route(mgr: SessionManagerDep) -> dict[str, bool]: - return {"is_same": mgr is manager} - - client = TestClient(app) - first = client.get("/manager") - second = client.get("/manager") - - assert first.status_code == 200 - assert second.status_code == 200 - assert first.json() == {"is_same": True} - assert second.json() == {"is_same": True} - - -def test_no_cookie_dict_untouched_no_set_cookie( - manager: SessionManager, config: SessionConfig -) -> None: - """No cookie + handler never touches request.session -> no Set-Cookie.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/test") - async def test_route() -> dict[str, bool]: - return {"ok": True} - - client = TestClient(app) - response = client.get("/test") - - assert response.status_code == 200 - assert "set-cookie" not in response.headers - - -async def test_no_cookie_dict_mutated_creates_session( - manager: SessionManager, config: SessionConfig -) -> None: - """No cookie + handler writes request.session -> new session persisted.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/set") - async def set_route(request: Request) -> dict[str, bool]: - request.session["foo"] = "bar" - return {"ok": True} - - client = TestClient(app) - response = client.get("/set") - - assert response.status_code == 200 - set_cookie = response.headers["set-cookie"] - token = _extract_cookie_token(set_cookie, config.cookie_name) - - session, _ = await manager.get_session(token) - assert session.data == {"foo": "bar"} - - -def test_no_cookie_dict_mutated_then_cleared_no_set_cookie( - manager: SessionManager, config: SessionConfig -) -> None: - """Mutate-then-clear within one request, starting empty -> no Set-Cookie at all. - - Matches Starlette's own parity edge case: the "clear" branch is gated on - "not initial_session_was_empty", which is False here since there was no - session to begin with. - """ - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/roundtrip") - async def roundtrip_route(request: Request) -> dict[str, bool]: - request.session["foo"] = "bar" - del request.session["foo"] - return {"ok": True} - - client = TestClient(app) - response = client.get("/roundtrip") - - assert response.status_code == 200 - assert "set-cookie" not in response.headers - - -async def test_valid_cookie_dict_mutated_merges_data( - manager: SessionManager, config: SessionConfig -) -> None: - """Existing session + mutation -> merged data persisted and re-sent.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/add") - async def add_route(request: Request) -> dict[str, bool]: - request.session["b"] = 2 - return {"ok": True} - - user = SessionUser(user_id="test-user") - _session, token = await manager.create_session(user=user, a=1) - - client = TestClient(app) - client.cookies.set(config.cookie_name, token) - response = client.get("/add") - - assert response.status_code == 200 - set_cookie = response.headers["set-cookie"] - new_token = _extract_cookie_token(set_cookie, config.cookie_name) - - reloaded, _ = await manager.get_session(new_token) - assert reloaded.data == {"a": 1, "b": 2} - - -async def test_valid_cookie_sliding_expiration_refreshes_cookie_without_mutation( - manager: SessionManager, config: SessionConfig -) -> None: - """Sliding expiration renewal must refresh Set-Cookie even without a data change.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/noop") - async def noop_route() -> dict[str, bool]: - return {"ok": True} - - user = SessionUser(user_id="slide-user") - session, token = await manager.create_session(user=user) - - # Shorten expiry so time_remaining < sliding threshold (< 50% of session_ttl) - shortened_expiry = datetime.now(timezone.utc) + timedelta(seconds=100) - session.expires_at = shortened_expiry - await manager._save_session(session, conditional=False) - - client = TestClient(app) - client.cookies.set(config.cookie_name, token) - response = client.get("/noop") - - assert response.status_code == 200 - set_cookie = response.headers.get("set-cookie") - # A Set-Cookie must be emitted purely because of the sliding renewal, even - # though the route never touched request.session. Note: for the "simple" - # token format the renewed token can be textually identical to the - # original if generated within the same wall-clock second (session_id and - # signature are unchanged, only the embedded issued_at second may differ), - # so this doesn't assert the token string actually changed. - assert set_cookie is not None - new_token = _extract_cookie_token(set_cookie, config.cookie_name) - - renewed, _ = await manager.get_session(new_token) - assert renewed.expires_at is not None - assert renewed.expires_at > shortened_expiry - - -async def test_valid_cookie_cleared_deletes_backend_session( - manager: SessionManager, config: SessionConfig -) -> None: - """Clearing a non-empty session must delete the backend record and expire the cookie.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/clear") - async def clear_route(request: Request) -> dict[str, bool]: - request.session.clear() - return {"ok": True} - - user = SessionUser(user_id="test-user") - _session, token = await manager.create_session(user=user, x=1) - - client = TestClient(app) - client.cookies.set(config.cookie_name, token) - response = client.get("/clear") - - assert response.status_code == 200 - set_cookie = response.headers["set-cookie"] - assert f"{config.cookie_name}=;" in set_cookie - assert "1970" in set_cookie - - with pytest.raises(SessionNotFoundError): - await manager.get_session(token) - - -def test_invalid_cookie_starts_fresh_session( - manager: SessionManager, config: SessionConfig -) -> None: - """A garbage/invalid token must not leak errors and behaves like no cookie at all.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/set") - async def set_route(request: Request) -> dict[str, bool]: - request.session["k"] = "v" - return {"ok": True} - - client = TestClient(app) - client.cookies.set(config.cookie_name, "not-a-real-token") - response = client.get("/set") - - assert response.status_code == 200 - # A brand-new session must be created; the bad token is discarded entirely. - assert "set-cookie" in response.headers - - -async def test_expired_cookie_starts_fresh_session( - manager: SessionManager, config: SessionConfig -) -> None: - """An expired session token must be discarded, not surfaced as an error.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/test") - async def test_route(request: Request) -> dict[str, bool]: - return {"has_data": bool(dict(request.session))} - - user = SessionUser(user_id="test-user") - session, token = await manager.create_session(user=user) - session.expires_at = datetime.now(timezone.utc) - timedelta(seconds=10) - await manager._save_session(session, conditional=False) - - client = TestClient(app) - client.cookies.set(config.cookie_name, token) - response = client.get("/test") - - assert response.status_code == 200 - assert response.json() == {"has_data": False} - - -async def test_ip_binding_mismatch_starts_fresh_session( - config: SessionConfig, -) -> None: - """IP binding mismatch behaves like an invalid token: fresh empty session, not an HTTP error.""" - backend = MemoryBackend() - manager = SessionManager(backend, config) - config.ip_binding = True - - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/test") - async def test_route(request: Request) -> dict[str, bool]: - return {"has_data": bool(dict(request.session))} - - user = SessionUser(user_id="test-user") - _session, token = await manager.create_session(user=user, ip_address="10.0.0.1") - - client = TestClient(app) - client.cookies.set(config.cookie_name, token) - # TestClient's default client IP won't match "10.0.0.1". - response = client.get("/test") - - assert response.status_code == 200 - assert response.json() == {"has_data": False} - - -@pytest.mark.parametrize( - ("user_agent", "loaded"), [("App/1.0", True), ("Other/2.0", False)] -) -async def test_user_agent_binding_checks_the_request_user_agent( - config: SessionConfig, user_agent: str, loaded: bool -) -> None: - """A session bound to a User-Agent loads only for that User-Agent.""" - config.user_agent_binding = True - manager = SessionManager(MemoryBackend(), config) - - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/test") - async def test_route(request: Request) -> dict[str, bool]: - return {"has_data": bool(dict(request.session))} - - _session, token = await manager.create_session( - user=SessionUser(user_id="u1"), user_agent="App/1.0", k="v" - ) - - client = TestClient(app) - client.cookies.set(config.cookie_name, token) - response = client.get("/test", headers={"User-Agent": user_agent}) - - assert response.json() == {"has_data": loaded} - - -def test_get_client_ip_from_x_forwarded_for(config: SessionConfig) -> None: - """Shared _get_client_ip reads X-Forwarded-For behind a trusted proxy.""" - from starlette.requests import HTTPConnection - - from fastapi_cachex.session.middleware import get_client_ip - - trusting = config.model_copy(update={"trusted_proxies": ["10.0.0.9", "10.0.0.1"]}) - scope: dict[str, Any] = { - "type": "http", - "headers": [(b"x-forwarded-for", b"192.168.1.1, 10.0.0.1")], - "client": ("10.0.0.9", 12345), - } - connection = HTTPConnection(scope) - - assert get_client_ip(connection, trusting) == "192.168.1.1" - - -def test_get_client_ip_takes_the_rightmost_untrusted_entry( - config: SessionConfig, -) -> None: - """A caller-supplied entry sits to the left of the address the proxy added.""" - from starlette.requests import HTTPConnection - - from fastapi_cachex.session.middleware import get_client_ip - - trusting = config.model_copy(update={"trusted_proxies": ["10.0.0.9"]}) - scope: dict[str, Any] = { - "type": "http", - "headers": [(b"x-forwarded-for", b"198.51.100.5, 203.0.113.99")], - "client": ("10.0.0.9", 12345), - } - connection = HTTPConnection(scope) - - assert get_client_ip(connection, trusting) == "203.0.113.99" - - -def test_get_client_ip_from_real_ip(config: SessionConfig) -> None: - """Shared _get_client_ip falls back to X-Real-IP behind a trusted proxy.""" - from starlette.requests import HTTPConnection - - from fastapi_cachex.session.middleware import get_client_ip - - trusting = config.model_copy(update={"trusted_proxies": ["10.0.0.9"]}) - scope: dict[str, Any] = { - "type": "http", - "headers": [(b"x-real-ip", b"192.168.1.1")], - "client": ("10.0.0.9", 12345), - } - connection = HTTPConnection(scope) - - assert get_client_ip(connection, trusting) == "192.168.1.1" - - -def test_get_client_ip_ignores_forwarded_headers_from_untrusted_peer( - config: SessionConfig, -) -> None: - """With no trusted proxies the headers are ignored entirely.""" - from starlette.requests import HTTPConnection - - from fastapi_cachex.session.middleware import get_client_ip - - scope: dict[str, Any] = { - "type": "http", - "headers": [ - (b"x-forwarded-for", b"1.2.3.4"), - (b"x-real-ip", b"5.6.7.8"), - ], - "client": ("10.0.0.9", 12345), - } - connection = HTTPConnection(scope) - - assert get_client_ip(connection, config) == "10.0.0.9" - - -def test_get_client_ip_from_client(config: SessionConfig) -> None: - """Shared _get_client_ip free function falls back to the raw client address.""" - from starlette.requests import HTTPConnection - - from fastapi_cachex.session.middleware import get_client_ip - - scope: dict[str, Any] = { - "type": "http", - "headers": [], - "client": ("192.168.1.1", 12345), - } - connection = HTTPConnection(scope) - - assert get_client_ip(connection, config) == "192.168.1.1" - - -def test_get_client_ip_none(config: SessionConfig) -> None: - """Shared _get_client_ip free function returns None when nothing is available.""" - from starlette.requests import HTTPConnection - - from fastapi_cachex.session.middleware import get_client_ip - - scope: dict[str, Any] = {"type": "http", "headers": [], "client": None} - connection = HTTPConnection(scope) - - assert get_client_ip(connection, config) is None - - -async def test_get_session_dependency_works_under_starlette_middleware( - manager: SessionManager, config: SessionConfig -) -> None: - """get_session must resolve the loaded Session under FastAPICacheXSessionMiddleware. - - Guards the request.state.__fastapi_cachex_session write added to - FastAPICacheXSessionMiddleware.__call__: without it, get_session (which reads - that state key) would always 401, even with a valid session cookie. - """ - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/me") - async def me_route(session=Depends(get_session)): - return {"user_id": session.user.user_id} - - user = SessionUser(user_id="cookie-user") - _session, token = await manager.create_session(user=user) - - client = TestClient(app) - - # Without the cookie, the dependency has nothing to resolve -> 401. - unauthenticated = client.get("/me") - assert unauthenticated.status_code == 401 - - # With a valid session cookie, the dependency must resolve the Session. - client.cookies.set(config.cookie_name, token) - authenticated = client.get("/me") - assert authenticated.status_code == 200 - assert authenticated.json() == {"user_id": "cookie-user"} - - -async def test_header_token_takes_priority_under_starlette_middleware( - manager: SessionManager, config: SessionConfig -) -> None: - """FastAPICacheXSessionMiddleware must accept the X-Session-Token header first. - - Guards the header-first-then-cookie token resolution: a token supplied via - the configured header (config.header_name) must authenticate even with no - cookie, and must win over an invalid session cookie. - """ - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/me") - async def me_route(session=Depends(get_session)): - return {"user_id": session.user.user_id} - - user = SessionUser(user_id="header-user") - _session, token = await manager.create_session(user=user) - - client = TestClient(app) - - # Token via header, no cookie -> resolves the Session. - header_only = client.get("/me", headers={config.header_name: token}) - assert header_only.status_code == 200 - assert header_only.json() == {"user_id": "header-user"} - - # Header must take priority over a bogus cookie. - client.cookies.set(config.cookie_name, "not-a-valid-token") - header_wins = client.get("/me", headers={config.header_name: token}) - assert header_wins.status_code == 200 - assert header_wins.json() == {"user_id": "header-user"} - - -async def test_header_source_renewal_uses_response_header_not_cookie( - manager: SessionManager, config: SessionConfig -) -> None: - """A header-sourced token that gets sliding-renewed must be echoed via the - response header, never as a Set-Cookie.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/noop") - async def noop_route() -> dict[str, bool]: - return {"ok": True} - - user = SessionUser(user_id="slide-header-user") - session, token = await manager.create_session(user=user) - - # Shorten expiry so time_remaining < sliding threshold (< 50% of session_ttl). - shortened_expiry = datetime.now(timezone.utc) + timedelta(seconds=100) - session.expires_at = shortened_expiry - await manager._save_session(session, conditional=False) - - client = TestClient(app) - response = client.get("/noop", headers={config.header_name: token}) - - assert response.status_code == 200 - # Renewal must be delivered via the response header, and no cookie is set. - assert "set-cookie" not in response.headers - renewed_token = response.headers.get(config.header_name) - assert renewed_token is not None - - renewed, _ = await manager.get_session(renewed_token) - assert renewed.expires_at is not None - assert renewed.expires_at > shortened_expiry - - -async def test_header_source_modify_persists_without_cookie( - manager: SessionManager, config: SessionConfig -) -> None: - """Modifying a header-sourced session persists the data but emits neither a - Set-Cookie nor a redundant token header (the token is unchanged).""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/add") - async def add_route(request: Request) -> dict[str, bool]: - request.session["b"] = 2 - return {"ok": True} - - user = SessionUser(user_id="mod-header-user") - _session, token = await manager.create_session(user=user, a=1) - - client = TestClient(app) - response = client.get("/add", headers={config.header_name: token}) - - assert response.status_code == 200 - assert "set-cookie" not in response.headers - # Token is unchanged, so nothing needs to be handed back via the header. - assert config.header_name not in response.headers - - # The data change is still persisted under the same token. - reloaded, _ = await manager.get_session(token) - assert reloaded.data == {"a": 1, "b": 2} - - -async def test_header_source_invalid_token_new_session_via_header( - manager: SessionManager, config: SessionConfig -) -> None: - """An invalid header token that leads to a new session must return the new - token via the response header, not a Set-Cookie.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/set") - async def set_route(request: Request) -> dict[str, bool]: - request.session["k"] = "v" - return {"ok": True} - - client = TestClient(app) - response = client.get("/set", headers={config.header_name: "not-a-real-token"}) - - assert response.status_code == 200 - assert "set-cookie" not in response.headers - new_token = response.headers.get(config.header_name) - assert new_token is not None - - created, _ = await manager.get_session(new_token) - assert created.data == {"k": "v"} - - -async def test_header_source_cleared_session_is_deleted_without_a_cookie( - manager: SessionManager, config: SessionConfig -) -> None: - """Clearing a header-sourced session deletes the record and sets no cookie. - - The cookie transport answers a clear with an expiring `Set-Cookie`; a - header client has no cookie to expire and simply keeps a token that no - longer resolves. Only the cookie half of that branch was covered. - """ - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/logout") - async def logout_route(request: Request) -> dict[str, bool]: - request.session.clear() - return {"ok": True} - - _session, token = await manager.create_session( - user=SessionUser(user_id="header-logout-user"), data={"seen": True} - ) - - client = TestClient(app) - response = client.get("/logout", headers={config.header_name: token}) - - assert response.status_code == 200 - assert "set-cookie" not in response.headers - with pytest.raises(SessionNotFoundError): - await manager.get_session(token) - - -def _regenerating_app( - manager: SessionManager, - config: SessionConfig, - *, - write_data: bool, -) -> FastAPI: - """An app whose /login regenerates the request's session ID, as docs advise.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.post("/login") - async def login(request: Request, session=Depends(get_session)): - await manager.regenerate_session_id(session) - if write_data: - request.session["logged_in"] = True - return {"ok": True} - - return app - - -async def _shorten_expiry(manager: SessionManager, token: str) -> None: - """Push the session under the sliding threshold so the load renews it.""" - session, _ = await manager.get_session(token) - session.expires_at = datetime.now(timezone.utc) + timedelta(seconds=100) - await manager._save_session(session, conditional=False) - - -@pytest.mark.parametrize("write_data", [True, False]) -@pytest.mark.parametrize("sliding", [True, False]) -async def test_regenerated_session_id_is_sent_as_cookie( - manager: SessionManager, config: SessionConfig, write_data: bool, sliding: bool -) -> None: - """After regenerate_session_id() the cookie must carry a token for the new ID. - - The middleware used to re-send the loaded token, which named the deleted - record, so logging in with the documented fixation defence logged the user - straight back out. - """ - _session, old_token = await manager.create_session( - user=SessionUser(user_id="u"), a=1 - ) - if sliding: - await _shorten_expiry(manager, old_token) - client = TestClient(_regenerating_app(manager, config, write_data=write_data)) - client.cookies.set(config.cookie_name, old_token) - - response = client.post("/login") - - assert response.status_code == 200 - new_token = _extract_cookie_token( - response.headers["set-cookie"], config.cookie_name - ) - assert new_token != old_token - session, _ = await manager.get_session(new_token) - expected = {"a": 1, "logged_in": True} if write_data else {"a": 1} - assert session.data == expected - with pytest.raises(SessionNotFoundError): - await manager.get_session(old_token) - - -@pytest.mark.parametrize("write_data", [True, False]) -@pytest.mark.parametrize("sliding", [True, False]) -async def test_regenerated_session_id_is_sent_in_the_header( - manager: SessionManager, config: SessionConfig, write_data: bool, sliding: bool -) -> None: - """A header client gets the new ID's token back in the response header.""" - _session, old_token = await manager.create_session(user=SessionUser(user_id="u")) - if sliding: - await _shorten_expiry(manager, old_token) - client = TestClient(_regenerating_app(manager, config, write_data=write_data)) - - response = client.post("/login", headers={config.header_name: old_token}) - - assert response.status_code == 200 - assert "set-cookie" not in response.headers - new_token = response.headers[config.header_name] - assert new_token != old_token - await manager.get_session(new_token) - with pytest.raises(SessionNotFoundError): - await manager.get_session(old_token) - - -def _rotating_login_app(manager: SessionManager, config: SessionConfig) -> FastAPI: - """An app with a Starlette-style login that rotates the ID first (#225).""" - SessionManagerProxy.set(manager) - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.post("/touch") - async def touch(request: Request): - request.session["cart"] = [1] - return {"ok": True} - - @app.post("/login") - async def login(request: Request): - rotated = await rotate_session_id(request) - request.session["user_id"] = "alice" - return {"rotated": rotated} - - return app - - -async def test_rotate_session_id_defeats_a_planted_cookie( - manager: SessionManager, config: SessionConfig -) -> None: - """A victim logging in with a planted cookie must not log the attacker in (#225). - - Without rotation the login writes into the planted session and sends the - same token back, so the attacker's copy of the cookie now carries the - victim's user_id. - """ - app = _rotating_login_app(manager, config) - attacker = TestClient(app) - attacker.post("/touch") - planted = attacker.cookies[config.cookie_name] - - victim = TestClient(app) - victim.cookies.set(config.cookie_name, planted) - response = victim.post("/login") - - assert response.json() == {"rotated": True} - victim_token = _extract_cookie_token( - response.headers["set-cookie"], config.cookie_name - ) - assert victim_token != planted - session, _ = await manager.get_session(victim_token) - assert session.data == {"cart": [1], "user_id": "alice"} - with pytest.raises(SessionNotFoundError): - await manager.get_session(planted) - - -def test_rotate_session_id_without_a_session( - manager: SessionManager, config: SessionConfig -) -> None: - """A new visitor can log in: rotation is a no-op and the write starts a session.""" - client = TestClient(_rotating_login_app(manager, config)) - - response = client.post("/login") - - assert response.status_code == 200 - assert response.json() == {"rotated": False} - assert config.cookie_name in client.cookies - - -async def test_rotate_session_id_over_the_header( - manager: SessionManager, config: SessionConfig -) -> None: - """A header client gets the rotated token back in the response header.""" - _session, old_token = await manager.create_session(user=SessionUser(user_id="u")) - client = TestClient(_rotating_login_app(manager, config)) - - response = client.post("/login", headers={config.header_name: old_token}) - - assert response.json() == {"rotated": True} - assert "set-cookie" not in response.headers - new_token = response.headers[config.header_name] - session, _ = await manager.get_session(new_token) - assert session.data == {"user_id": "alice"} - with pytest.raises(SessionNotFoundError): - await manager.get_session(old_token) - - -def _vary(response: Any) -> set[str]: - """The response's Vary header as a set of lowercased header names.""" - return { - name.strip().lower() - for name in response.headers.get("vary", "").split(",") - if name.strip() - } - - -def _session_reading_app(manager: SessionManager, config: SessionConfig) -> FastAPI: - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/read") - async def read_route(request: Request) -> dict[str, Any]: - return dict(request.session) - - @app.get("/untouched") - async def untouched_route() -> dict[str, bool]: - return {"ok": True} - - return app - - -async def test_header_token_varies_on_the_token_header_not_cookie( - manager: SessionManager, config: SessionConfig -) -> None: - """The cookie is never read when the header carries the token (#168).""" - _session, token = await manager.create_session(user=SessionUser(user_id="u")) - client = TestClient(_session_reading_app(manager, config)) - - response = client.get("/read", headers={config.header_name: token}) - - assert _vary(response) == {config.header_name.lower()} - - -async def test_bearer_token_varies_on_every_header_consulted( - manager: SessionManager, config: SessionConfig -) -> None: - """The custom header is checked first, so the response depends on it too.""" - _session, token = await manager.create_session(user=SessionUser(user_id="u")) - client = TestClient(_session_reading_app(manager, config)) - - response = client.get("/read", headers={"Authorization": f"Bearer {token}"}) - - assert _vary(response) == {config.header_name.lower(), "authorization"} - - -async def test_cookie_token_varies_on_cookie_and_the_headers_checked_first( - manager: SessionManager, config: SessionConfig -) -> None: - """A token header, had one been sent, would have won over the cookie.""" - _session, token = await manager.create_session(user=SessionUser(user_id="u")) - client = TestClient(_session_reading_app(manager, config)) - client.cookies.set(config.cookie_name, token) - - response = client.get("/read") - - assert _vary(response) == {config.header_name.lower(), "authorization", "cookie"} - - -def test_disabled_bearer_transport_is_left_out_of_vary(manager: SessionManager) -> None: - with pytest.warns(DeprecationWarning, match="use_bearer_token"): - config = SessionConfig( - secret_key="a" * 32, - use_bearer_token=False, - cookie_name="session", - cookie_https_only=False, - ) - client = TestClient(_session_reading_app(manager, config)) - - response = client.get("/read") - - assert _vary(response) == {config.header_name.lower(), "cookie"} - - -def test_no_vary_when_the_session_is_not_accessed( - manager: SessionManager, config: SessionConfig -) -> None: - client = TestClient(_session_reading_app(manager, config)) - - response = client.get("/untouched") - - assert "vary" not in response.headers - - -def _clearing_app(manager: SessionManager, config: SessionConfig) -> FastAPI: - """An app that logs out with clear(), pops single keys and writes after clearing.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/logout") - async def logout_route(request: Request) -> dict[str, bool]: - request.session.clear() - return {"ok": True} - - @app.get("/pop-flash") - async def pop_flash_route(request: Request) -> dict[str, Any]: - return {"flash": request.session.pop("flash", None)} - - @app.get("/clear-then-write") - async def clear_then_write_route(request: Request) -> dict[str, bool]: - request.session.clear() - request.session["flash"] = "signed out" - return {"ok": True} - - return app - - -@pytest.mark.parametrize("transport", ["cookie", "header"]) -async def test_clear_logs_out_a_session_with_empty_data( - manager: SessionManager, config: SessionConfig, transport: str -) -> None: - """clear() ends a logged-in session even when its data was already empty. - - The emptiness of the initial data used to decide whether clear() counted, - so a user session created without data survived its own logout. - """ - _session, token = await manager.create_session( - user=SessionUser(user_id="empty-data-user") - ) - - client = TestClient(_clearing_app(manager, config)) - if transport == "cookie": - client.cookies.set(config.cookie_name, token) - response = client.get("/logout") - set_cookie = response.headers["set-cookie"] - assert f"{config.cookie_name}=;" in set_cookie - assert "1970" in set_cookie - else: - response = client.get("/logout", headers={config.header_name: token}) - assert "set-cookie" not in response.headers - assert config.header_name.lower() not in response.headers - - assert response.status_code == 200 - with pytest.raises(SessionNotFoundError): - await manager.get_session(token) - - -async def test_popping_the_last_key_keeps_a_user_logged_in( - manager: SessionManager, config: SessionConfig -) -> None: - """Removing the last key is not a logout: the user session stays, with empty data.""" - _session, token = await manager.create_session( - user=SessionUser(user_id="flash-user"), flash="hi" - ) - - client = TestClient(_clearing_app(manager, config)) - response = client.get("/pop-flash", headers={config.header_name: token}) - - assert response.json() == {"flash": "hi"} - kept, _ = await manager.get_session(token) - assert kept.user is not None - assert kept.user.user_id == "flash-user" - assert kept.data == {} - - -async def test_popping_the_last_key_deletes_an_anonymous_session( - manager: SessionManager, config: SessionConfig -) -> None: - """Without a user an emptied session holds nothing, so it goes, as in Starlette.""" - _session, token = await manager.create_anonymous_session(flash="hi") - - client = TestClient(_clearing_app(manager, config)) - client.cookies.set(config.cookie_name, token) - response = client.get("/pop-flash") - - assert response.json() == {"flash": "hi"} - assert "1970" in response.headers["set-cookie"] - with pytest.raises(SessionNotFoundError): - await manager.get_session(token) - - -@pytest.mark.parametrize("transport", ["cookie", "header"]) -async def test_writing_after_clear_starts_a_new_anonymous_session( - manager: SessionManager, config: SessionConfig, transport: str -) -> None: - """Keys written after clear() go to a new session, never the logged-out one.""" - _session, token = await manager.create_session( - user=SessionUser(user_id="logout-flash-user"), seen=True - ) - - client = TestClient(_clearing_app(manager, config)) - if transport == "cookie": - client.cookies.set(config.cookie_name, token) - response = client.get("/clear-then-write") - new_token = _extract_cookie_token( - response.headers["set-cookie"], config.cookie_name - ) - else: - response = client.get("/clear-then-write", headers={config.header_name: token}) - new_token = response.headers[config.header_name] - - assert response.status_code == 200 - with pytest.raises(SessionNotFoundError): - await manager.get_session(token) - fresh, _ = await manager.get_session(new_token) - assert fresh.session_id != _session.session_id - assert fresh.user is None - assert fresh.data == {"flash": "signed out"} - - -@pytest.mark.parametrize( - "user_session", - [AuthenticatedSession, UserSessionDep], - ids=["AuthenticatedSession", "UserSessionDep"], -) -async def test_require_user_session_rejects_anonymous_sessions( - manager: SessionManager, config: SessionConfig, user_session: Any -) -> None: - """An anonymous cart session passes get_session but not require_user_session. - - ``AuthenticatedSession`` (#114) and, since 0.4.0, ``UserSessionDep`` (#127) - both require a session with a user. - """ - app = FastAPI() - app.add_middleware(FastAPICacheXSessionMiddleware, session_manager=manager) - - @app.post("/cart") - async def add_to_cart(request: Request) -> dict[str, bool]: - request.session["cart"] = [1] - return {"ok": True} - - @app.get("/any") - async def any_session(session: RequiredSession) -> dict[str, bool]: - return {"user": session.user is not None} - - @app.get("/account") - async def account(session: user_session) -> dict[str, str]: - assert session.user is not None - return {"user_id": session.user.user_id} - - visitor = TestClient(app) - assert visitor.get("/account").status_code == 401 - visitor.post("/cart") - assert visitor.get("/any").json() == {"user": False} - response = visitor.get("/account") - assert response.status_code == 401 - assert response.headers["www-authenticate"] == "Bearer" - - _session, token = await manager.create_session(user=SessionUser(user_id="alice")) - member = TestClient(app) - response = member.get("/account", headers={config.header_name: token}) - assert response.json() == {"user_id": "alice"} diff --git a/tests/session/test_token_response_caching.py b/tests/session/test_token_response_caching.py deleted file mode 100644 index 7f787cc..0000000 --- a/tests/session/test_token_response_caching.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Responses that carry a session token must never be stored by a cache (#297). - -A token is a credential: a CDN or reverse proxy that stored a response with -``Set-Cookie`` or the token header would hand it to the next visitor. Every -response the session middleware writes a token or a clearing cookie to gets -``Cache-Control: private, no-store`` and ``Vary`` on the transport headers, -whether or not the handler touched ``request.session``. -""" - -from typing import Any - -import pytest -from fastapi import Depends -from fastapi import FastAPI -from fastapi import Request -from fastapi import Response -from fastapi.testclient import TestClient - -from fastapi_cachex import cache -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.dependencies import get_session -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import SessionUser - -_NO_STORE = "private, no-store" -_PUBLIC = "public, max-age=60" - - -@pytest.fixture -def sliding_config() -> SessionConfig: - """A config that renews the token on every load.""" - return SessionConfig( - secret_key="a" * 32, - sliding_threshold=1.0, - cookie_name="session", - cookie_https_only=False, - ) - - -@pytest.fixture -def sliding_manager( - backend: MemoryBackend, sliding_config: SessionConfig -) -> SessionManager: - return SessionManager(backend, sliding_config) - - -def _vary(response: Any) -> list[str]: - """The response's Vary header as lowercased names, duplicates kept.""" - return [ - name.strip().lower() - for name in response.headers.get("vary", "").split(",") - if name.strip() - ] - - -def _app( - manager: SessionManager, - config: SessionConfig, -) -> FastAPI: - """An app with a publicly cacheable route and routes that touch the session.""" - app = FastAPI() - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - - @app.get("/public") - @cache(ttl=60, public=True) - async def public_route() -> dict[str, bool]: - return {"ok": True} - - @app.get("/vary-accept") - async def vary_accept_route(response: Response) -> dict[str, bool]: - response.headers["Vary"] = "Accept-Encoding, Cookie" - response.headers["Cache-Control"] = _PUBLIC - return {"ok": True} - - @app.get("/vary-star") - async def vary_star_route(response: Response) -> dict[str, bool]: - response.headers["Vary"] = "*" - return {"ok": True} - - @app.get("/write") - async def write_route(request: Request) -> dict[str, bool]: - request.session["cart"] = [1] - return {"ok": True} - - @app.get("/logout") - async def logout_route(request: Request) -> dict[str, bool]: - request.session.clear() - return {"ok": True} - - @app.get("/pop") - async def pop_route(request: Request) -> dict[str, Any]: - return {"flash": request.session.pop("flash", None)} - - @app.post("/login") - async def login_route(session=Depends(get_session)) -> dict[str, bool]: - await manager.regenerate_session_id(session) - return {"ok": True} - - return app - - -def _assert_not_storable(response: Any, vary: set[str]) -> None: - assert response.headers["cache-control"] == _NO_STORE - assert set(_vary(response)) == vary - - -def test_a_new_session_cookie_is_not_storable( - manager: SessionManager, config: SessionConfig -) -> None: - client = TestClient(_app(manager, config)) - - response = client.get("/write") - - assert config.cookie_name in response.headers["set-cookie"] - _assert_not_storable( - response, {config.header_name.lower(), "authorization", "cookie"} - ) - - -async def test_a_sliding_renewal_cookie_overrides_a_public_route( - sliding_manager: SessionManager, sliding_config: SessionConfig -) -> None: - """The repro from #297: the route never touches the session.""" - _session, token = await sliding_manager.create_session( - user=SessionUser(user_id="u") - ) - client = TestClient(_app(sliding_manager, sliding_config)) - client.cookies.set(sliding_config.cookie_name, token) - - response = client.get("/public") - - assert sliding_config.cookie_name in response.headers["set-cookie"] - _assert_not_storable( - response, {sliding_config.header_name.lower(), "authorization", "cookie"} - ) - - -async def test_a_sliding_renewal_header_overrides_a_public_route( - sliding_manager: SessionManager, sliding_config: SessionConfig -) -> None: - _session, token = await sliding_manager.create_session( - user=SessionUser(user_id="u") - ) - client = TestClient(_app(sliding_manager, sliding_config)) - - response = client.get("/public", headers={sliding_config.header_name: token}) - - assert sliding_config.header_name in response.headers - assert "set-cookie" not in response.headers - _assert_not_storable(response, {sliding_config.header_name.lower()}) - - -async def test_a_sliding_renewal_over_bearer_varies_on_authorization( - sliding_manager: SessionManager, sliding_config: SessionConfig -) -> None: - _session, token = await sliding_manager.create_session( - user=SessionUser(user_id="u") - ) - client = TestClient(_app(sliding_manager, sliding_config)) - - response = client.get("/public", headers={"Authorization": f"Bearer {token}"}) - - assert sliding_config.header_name in response.headers - _assert_not_storable( - response, {sliding_config.header_name.lower(), "authorization"} - ) - - -async def test_existing_vary_values_are_kept_and_not_duplicated( - sliding_manager: SessionManager, sliding_config: SessionConfig -) -> None: - _session, token = await sliding_manager.create_session( - user=SessionUser(user_id="u") - ) - client = TestClient(_app(sliding_manager, sliding_config)) - client.cookies.set(sliding_config.cookie_name, token) - - response = client.get("/vary-accept") - - assert response.headers["cache-control"] == _NO_STORE - vary = _vary(response) - assert sorted(vary) == sorted( - { - "accept-encoding", - "cookie", - sliding_config.header_name.lower(), - "authorization", - } - ) - - -async def test_vary_star_is_left_as_it_is( - sliding_manager: SessionManager, sliding_config: SessionConfig -) -> None: - """``Vary: *`` already covers every header; adding names to it is noise.""" - _session, token = await sliding_manager.create_session( - user=SessionUser(user_id="u") - ) - client = TestClient(_app(sliding_manager, sliding_config)) - client.cookies.set(sliding_config.cookie_name, token) - - response = client.get("/vary-star") - - assert response.headers["cache-control"] == _NO_STORE - assert response.headers["vary"] == "*" - - -@pytest.mark.parametrize("transport", ["cookie", "header"]) -async def test_a_regenerated_session_id_is_not_storable( - manager: SessionManager, config: SessionConfig, transport: str -) -> None: - _session, token = await manager.create_session(user=SessionUser(user_id="u")) - client = TestClient(_app(manager, config)) - - if transport == "cookie": - client.cookies.set(config.cookie_name, token) - response = client.post("/login") - assert config.cookie_name in response.headers["set-cookie"] - vary = {config.header_name.lower(), "authorization", "cookie"} - else: - response = client.post("/login", headers={config.header_name: token}) - assert response.headers[config.header_name] != token - vary = {config.header_name.lower()} - - _assert_not_storable(response, vary) - - -async def test_a_logout_clearing_cookie_is_not_storable( - manager: SessionManager, config: SessionConfig -) -> None: - _session, token = await manager.create_session(user=SessionUser(user_id="u")) - client = TestClient(_app(manager, config)) - client.cookies.set(config.cookie_name, token) - - response = client.get("/logout") - - assert "1970" in response.headers["set-cookie"] - _assert_not_storable( - response, {config.header_name.lower(), "authorization", "cookie"} - ) - - -async def test_an_emptied_anonymous_session_clearing_cookie_is_not_storable( - manager: SessionManager, config: SessionConfig -) -> None: - _session, token = await manager.create_anonymous_session(flash="hi") - client = TestClient(_app(manager, config)) - client.cookies.set(config.cookie_name, token) - - response = client.get("/pop") - - assert "1970" in response.headers["set-cookie"] - _assert_not_storable( - response, {config.header_name.lower(), "authorization", "cookie"} - ) - - -async def test_an_emptied_anonymous_header_session_sends_nothing_to_forbid( - manager: SessionManager, config: SessionConfig -) -> None: - """A header client just drops its dangling token, so no header is sent.""" - _session, token = await manager.create_anonymous_session(flash="hi") - client = TestClient(_app(manager, config)) - - response = client.get("/pop", headers={config.header_name: token}) - - assert response.json() == {"flash": "hi"} - assert config.header_name not in response.headers - assert "set-cookie" not in response.headers - assert "cache-control" not in response.headers - - -async def test_a_response_without_a_token_keeps_its_cache_control( - manager: SessionManager, config: SessionConfig -) -> None: - """A loaded session outside the renewal window sends no token.""" - _session, token = await manager.create_session(user=SessionUser(user_id="u")) - client = TestClient(_app(manager, config)) - client.cookies.set(config.cookie_name, token) - - response = client.get("/public") - - assert "set-cookie" not in response.headers - assert response.headers["cache-control"] == _PUBLIC - assert "vary" not in response.headers - - -def test_a_response_without_a_session_keeps_its_cache_control( - manager: SessionManager, config: SessionConfig -) -> None: - client = TestClient(_app(manager, config)) - - response = client.get("/public") - - assert "set-cookie" not in response.headers - assert response.headers["cache-control"] == _PUBLIC - assert "vary" not in response.headers diff --git a/tests/session/test_token_serializers.py b/tests/session/test_token_serializers.py deleted file mode 100644 index d1aee03..0000000 --- a/tests/session/test_token_serializers.py +++ /dev/null @@ -1,381 +0,0 @@ -from datetime import datetime -from datetime import timezone -from typing import cast - -import pytest -from pydantic import SecretStr - -from fastapi_cachex.backends import MemoryBackend -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.models import SessionToken -from fastapi_cachex.session.token_serializers import JWTTokenSerializer -from fastapi_cachex.session.token_serializers import SimpleTokenSerializer - - -class StubJWTModule: - def __init__(self) -> None: - self.encode_calls = 0 - self.decode_calls = 0 - self.last_encode_payload: dict[str, object] | None = None - self.last_encode_kwargs: dict[str, object] | None = None - self.last_decode_kwargs: dict[str, object] | None = None - - def encode(self, payload: dict[str, object], key: str, algorithm: str) -> str: - self.encode_calls += 1 - self.last_encode_payload = payload - self.last_encode_kwargs = {"key": key, "algorithm": algorithm} - return f"encoded-{payload['sid']}" - - def decode(self, token_str: str, **kwargs: object) -> dict[str, object]: - self.decode_calls += 1 - self.last_decode_kwargs = kwargs - return self.last_encode_payload or {} - - -def test_simple_token_serializer_roundtrip() -> None: - serializer = SimpleTokenSerializer() - token = SessionToken( - session_id="sid", - signature="sig", - issued_at=datetime.now(timezone.utc), - ) - - token_str = serializer.to_string(token) - parsed = serializer.from_string(token_str) - - assert parsed.session_id == token.session_id - assert parsed.signature == token.signature - - -def test_jwt_serializer_to_and_from_string_success() -> None: - stub = StubJWTModule() - config = SessionConfig( - secret_key=SecretStr("b" * 32), - token_format="jwt", - session_ttl=120, - jwt_issuer="issuer", - jwt_audience="aud", - ) - serializer = JWTTokenSerializer(config, jwt_module=stub) - - token = SessionToken( - session_id="jwt-id", - signature="", - issued_at=datetime.now(timezone.utc), - ) - - token_str = serializer.to_string(token) - - assert stub.encode_calls == 1 - assert stub.last_encode_payload is not None - payload = stub.last_encode_payload - assert payload["sid"] == "jwt-id" - assert payload["iss"] == "issuer" - assert payload["aud"] == "aud" - iat = cast("int", payload["iat"]) - exp = cast("int", payload["exp"]) - assert exp == iat + 120 - - parsed = serializer.from_string(token_str) - - assert parsed.session_id == "jwt-id" - assert parsed.signature == "" - assert stub.decode_calls == 1 - assert stub.last_decode_kwargs is not None - assert stub.last_decode_kwargs.get("issuer") == "issuer" - assert stub.last_decode_kwargs.get("audience") == "aud" - - -def test_jwt_serializer_decode_failure() -> None: - class FailingJWTModule: - def encode(self, payload: dict[str, object], key: str, algorithm: str) -> str: - return "token" - - def decode(self, token_str: str, **kwargs: object) -> dict[str, object]: - msg = "boom" - raise RuntimeError(msg) - - config = SessionConfig(secret_key=SecretStr("c" * 32), token_format="jwt") - serializer = JWTTokenSerializer(config, jwt_module=FailingJWTModule()) - - with pytest.raises(ValueError, match="Invalid JWT token"): - serializer.from_string("token") - - -def test_jwt_serializer_invalid_payload() -> None: - class InvalidPayloadJWTModule: - def encode(self, payload: dict[str, object], key: str, algorithm: str) -> str: - return "token" - - def decode(self, token_str: str, **kwargs: object) -> dict[str, object]: - return {"iat": "bad", "exp": 0} - - config = SessionConfig(secret_key=SecretStr("d" * 32), token_format="jwt") - serializer = JWTTokenSerializer(config, jwt_module=InvalidPayloadJWTModule()) - - with pytest.raises(ValueError, match="Invalid JWT payload"): - serializer.from_string("token") - - -def test_simple_token_overflow_timestamp() -> None: - """A token with an astronomically large timestamp must raise ValueError (not OverflowError).""" - serializer = SimpleTokenSerializer() - # Construct a raw token string with an overflow-inducing timestamp - huge_timestamp = "9" * 20 - token_str = f"some-session-id.some-signature.{huge_timestamp}" - - with pytest.raises(ValueError, match="Invalid timestamp in token"): - serializer.from_string(token_str) - - -def test_jwt_serializer_without_issuer_and_audience() -> None: - """JWTTokenSerializer must work without jwt_issuer/jwt_audience configured.""" - stub = StubJWTModule() - config = SessionConfig( - secret_key=SecretStr("e" * 32), - token_format="jwt", - session_ttl=60, - jwt_issuer=None, - jwt_audience=None, - ) - serializer = JWTTokenSerializer(config, jwt_module=stub) - - token = SessionToken( - session_id="no-iss-aud", - signature="", - issued_at=datetime.now(timezone.utc), - ) - token_str = serializer.to_string(token) - - # Payload must not include iss/aud when they are None - payload = stub.last_encode_payload or {} - assert "iss" not in payload - assert "aud" not in payload - - parsed = serializer.from_string(token_str) - assert parsed.session_id == "no-iss-aud" - - # decode kwargs must not include issuer/audience - decode_kwargs = stub.last_decode_kwargs or {} - assert "issuer" not in decode_kwargs - assert "audience" not in decode_kwargs - - -def test_jwt_serializer_non_string_sid() -> None: - """If JWT decode returns a non-string sid, it must be coerced to str.""" - - class IntSidJWTModule: - def encode(self, payload: dict[str, object], key: str, algorithm: str) -> str: - return "token" - - def decode(self, token_str: str, **kwargs: object) -> dict[str, object]: - return {"sid": 42, "iat": 0, "exp": 9999999999} - - config = SessionConfig(secret_key=SecretStr("f" * 32), token_format="jwt") - serializer = JWTTokenSerializer(config, jwt_module=IntSidJWTModule()) - - parsed = serializer.from_string("token") - assert parsed.session_id == "42" - - -def test_jwt_serializer_uses_an_injected_module() -> None: - """`jwt_module` exists so PyJWT is not the only possible implementation.""" - stub = StubJWTModule() - config = SessionConfig(secret_key=SecretStr("a" * 32), token_format="jwt") - - serializer = JWTTokenSerializer(config, jwt_module=stub) - - assert serializer.jwt_encoder is stub - - -def test_jwt_serializer_without_pyjwt_explains_the_extra( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Without the extra installed the error must name the extra.""" - import importlib - - real_import_module = importlib.import_module - - def fake_import_module(name: str, package: str | None = None) -> object: - if name == "jwt": - msg = "No module named 'jwt'" - raise ImportError(msg) - return real_import_module(name, package) - - monkeypatch.setattr(importlib, "import_module", fake_import_module) - config = SessionConfig(secret_key=SecretStr("a" * 32), token_format="jwt") - - with pytest.raises(ImportError, match=r"fastapi-cachex\[jwt\]"): - JWTTokenSerializer(config) - - -def test_jwt_serializer_rejects_a_non_numeric_iat() -> None: - """`iat` arrives from the token, so a non-numeric value is external input.""" - - class BadIatJWTModule: - def encode(self, payload: dict[str, object], key: str, algorithm: str) -> str: - return "token" - - def decode(self, token_str: str, **kwargs: object) -> dict[str, object]: - return {"sid": "session-id", "iat": "not-a-number"} - - config = SessionConfig(secret_key=SecretStr("a" * 32), token_format="jwt") - serializer = JWTTokenSerializer(config, jwt_module=BadIatJWTModule()) - - with pytest.raises(ValueError, match="Invalid JWT payload"): - serializer.from_string("token") - - -def test_jwt_serializer_accepts_a_numeric_string_iat() -> None: - """A string that is a number is coerced rather than rejected.""" - - class StringIatJWTModule: - def encode(self, payload: dict[str, object], key: str, algorithm: str) -> str: - return "token" - - def decode(self, token_str: str, **kwargs: object) -> dict[str, object]: - return {"sid": "session-id", "iat": "1700000000"} - - config = SessionConfig(secret_key=SecretStr("a" * 32), token_format="jwt") - serializer = JWTTokenSerializer(config, jwt_module=StringIatJWTModule()) - - token = serializer.from_string("token") - - assert token.session_id == "session-id" - assert int(token.issued_at.timestamp()) == 1700000000 - - -@pytest.mark.parametrize( - "algorithm", - ["RS256", "RS512", "ES256", "ES384", "PS256", "EdDSA"], -) -def test_jwt_serializer_rejects_asymmetric_algorithms(algorithm: str) -> None: - """The built-in serializer only has `secret_key`, so it cannot sign RS/ES/PS/EdDSA. - - The config accepted these, and the first `create_session()` then failed - inside PyJWT. They must fail when the manager is built instead. - """ - config = SessionConfig( - secret_key=SecretStr("a" * 32), - token_format="jwt", - jwt_algorithm=algorithm, - ) - - with pytest.raises(ValueError, match="custom token_serializer"): - JWTTokenSerializer(config, jwt_module=StubJWTModule()) - - -def test_session_manager_fails_at_startup_for_asymmetric_jwt() -> None: - """The error surfaces when `SessionManager` is built, not on first use.""" - config = SessionConfig( - secret_key=SecretStr("a" * 32), - token_format="jwt", - jwt_algorithm="RS256", - ) - - with pytest.raises(ValueError, match="RS256"): - SessionManager(MemoryBackend(), config) - - -def test_session_manager_accepts_asymmetric_jwt_with_custom_serializer() -> None: - """A custom serializer that holds the key pair is still allowed.""" - config = SessionConfig( - secret_key=SecretStr("a" * 32), - token_format="jwt", - jwt_algorithm="RS256", - ) - serializer = SimpleTokenSerializer() - - manager = SessionManager(MemoryBackend(), config, token_serializer=serializer) - - assert manager._serializer is serializer - - -@pytest.mark.parametrize("algorithm", ["HS256", "HS384", "HS512"]) -def test_jwt_serializer_round_trips_hmac_algorithms(algorithm: str) -> None: - """The HMAC algorithms work end to end with real PyJWT.""" - pytest.importorskip("jwt") - config = SessionConfig( - # Long enough for HS512, so no algorithm warns about a short key. - secret_key=SecretStr("a" * 64), - token_format="jwt", - jwt_algorithm=algorithm, - ) - serializer = JWTTokenSerializer(config) - token = SessionToken( - session_id="sid-1", signature="", issued_at=datetime.now(timezone.utc) - ) - - assert serializer.from_string(serializer.to_string(token)).session_id == "sid-1" - - -@pytest.mark.parametrize( - ("algorithm", "secret", "rejected"), - [ - ("HS256", "a" * 32, False), - ("HS384", "a" * 47, True), - ("HS384", "a" * 48, False), - ("HS512", "a" * 63, True), - ("HS512", "a" * 64, False), - # 32 characters but 64 UTF-8 bytes: the key length is counted in bytes. - ("HS512", "é" * 32, False), - # 32 characters but 47 UTF-8 bytes: one byte short of HS384. - ("HS384", "é" * 15 + "a" * 17, True), - ], -) -def test_jwt_serializer_rejects_a_short_hmac_key( - algorithm: str, secret: str, rejected: bool -) -> None: - """A secret shorter than the hash output is refused when the serializer is built (#129).""" - config = SessionConfig( - secret_key=SecretStr(secret), token_format="jwt", jwt_algorithm=algorithm - ) - - if rejected: - with pytest.raises(ValueError, match=f"requires for {algorithm}"): - JWTTokenSerializer(config, jwt_module=StubJWTModule()) - else: - JWTTokenSerializer(config, jwt_module=StubJWTModule()) - - -def test_a_short_hmac_key_is_rejected_when_the_manager_is_built() -> None: - """The check runs at startup, before any token is signed.""" - config = SessionConfig( - secret_key=SecretStr("a" * 32), token_format="jwt", jwt_algorithm="HS512" - ) - - with pytest.raises(ValueError, match=r"secrets\.token_urlsafe\(64\)"): - SessionManager(MemoryBackend(), config) - - -def test_a_short_hmac_key_is_rejected_before_pyjwt_is_needed( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The configuration error comes first, not the missing extra.""" - import importlib - - def no_jwt(name: str, package: str | None = None) -> object: - msg = "No module named 'jwt'" - raise ImportError(msg) - - monkeypatch.setattr(importlib, "import_module", no_jwt) - config = SessionConfig( - secret_key=SecretStr("a" * 32), token_format="jwt", jwt_algorithm="HS512" - ) - - with pytest.raises(ValueError, match="requires for HS512"): - JWTTokenSerializer(config) - - -def test_a_custom_serializer_holds_its_own_key() -> None: - """The length check guards the built-in serializer's use of secret_key only.""" - config = SessionConfig( - secret_key=SecretStr("a" * 32), token_format="jwt", jwt_algorithm="HS512" - ) - - manager = SessionManager( - MemoryBackend(), config, token_serializer=SimpleTokenSerializer() - ) - - assert isinstance(manager._serializer, SimpleTokenSerializer) diff --git a/tests/state/__init__.py b/tests/state/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/state/test_login_csrf.py b/tests/state/test_login_csrf.py deleted file mode 100644 index f5252f0..0000000 --- a/tests/state/test_login_csrf.py +++ /dev/null @@ -1,71 +0,0 @@ -"""The STATE.md quick start: a state bound to a nonce cookie (#226).""" - -import secrets -from urllib.parse import parse_qs -from urllib.parse import urlparse - -from fastapi import FastAPI -from fastapi import HTTPException -from fastapi import Request -from fastapi.responses import RedirectResponse -from fastapi.testclient import TestClient - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.state import StateError -from fastapi_cachex.state import StateManager - -COOKIE = "oauth_binding" - - -def _app() -> FastAPI: - app = FastAPI() - states = StateManager(backend=MemoryBackend()) - - @app.get("/login") - async def login(): - nonce = secrets.token_urlsafe(32) - state = await states.create_state(binding=nonce) - response = RedirectResponse( - f"https://provider.example.com/authorize?state={state}" - ) - response.set_cookie(COOKIE, nonce, max_age=600, httponly=True, samesite="lax") - return response - - @app.get("/callback") - async def callback(request: Request, state: str, code: str): - try: - await states.consume_state(state, binding=request.cookies.get(COOKIE)) - except StateError as e: - raise HTTPException(status_code=400, detail="Invalid state") from e - return {"logged_in_with_code": code} - - return app - - -def _start(client: TestClient) -> str: - response = client.get("/login", follow_redirects=False) - location: str = response.headers["location"] - return parse_qs(urlparse(location).query)["state"][0] - - -def test_the_browser_that_started_the_flow_completes_it() -> None: - client = TestClient(_app()) - state = _start(client) - - response = client.get("/callback", params={"state": state, "code": "c"}) - - assert response.status_code == 200 - - -def test_a_state_started_by_an_attacker_is_rejected_in_the_victims_browser() -> None: - """The login CSRF from #226: before binding, the victim was logged in as the attacker.""" - app = _app() - attacker, victim = TestClient(app), TestClient(app) - state = _start(attacker) - _start(victim) # the victim has a nonce cookie of their own - - response = victim.get( - "/callback", params={"state": state, "code": "ATTACKERS_CODE"} - ) - - assert response.status_code == 400 diff --git a/tests/state/test_manager.py b/tests/state/test_manager.py deleted file mode 100644 index c77283e..0000000 --- a/tests/state/test_manager.py +++ /dev/null @@ -1,962 +0,0 @@ -"""Tests for OAuth state manager functionality.""" - -import hashlib -import json -import logging -import time -from collections.abc import AsyncGenerator -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from typing import TYPE_CHECKING -from typing import Any - -import pytest -import pytest_asyncio - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.proxy import BackendProxy -from fastapi_cachex.state.exceptions import InvalidStateError -from fastapi_cachex.state.exceptions import StateDataError -from fastapi_cachex.state.exceptions import StateExpiredError -from fastapi_cachex.state.manager import StateManager -from fastapi_cachex.state.models import StateData -from fastapi_cachex.types import CacheEntry -from tests.conftest import Clock -from tests.live_servers import REDIS_HOST -from tests.live_servers import REDIS_PORT -from tests.live_servers import requires_redis -from tests.live_servers import requires_redis_package - -if TYPE_CHECKING: - from fastapi_cachex.backends.base import BaseCacheBackend - - -@pytest_asyncio.fixture( - params=[ - pytest.param("memory", id="MemoryBackend"), - pytest.param( - "redis", - id="RedisBackend", - marks=[requires_redis, requires_redis_package], - ), - ] -) -async def state_manager( - request: Any, -) -> AsyncGenerator[StateManager, Any]: - """Create a StateManager instance with different backends. - - This fixture is parametrized to test with: - - MemoryBackend (always runs) - - RedisBackend (runs only if Redis is available) - """ - backend_type = request.param - - if backend_type == "memory": - mem_backend = MemoryBackend() - mem_backend.start_cleanup() - backend: BaseCacheBackend = mem_backend - else: # redis - from fastapi_cachex.backends import AsyncRedisCacheBackend - - backend = AsyncRedisCacheBackend( - host=REDIS_HOST, - port=REDIS_PORT, - socket_timeout=1.0, - socket_connect_timeout=1.0, - key_prefix="test_state:", - ) - - BackendProxy.set(backend) - manager = StateManager() - - yield manager - - # Cleanup - await backend.clear() - if backend_type == "memory" and isinstance(backend, MemoryBackend): - backend.stop_cleanup() - - -async def test_create_state_basic(state_manager: StateManager) -> None: - """Test creating a basic OAuth state.""" - state = await state_manager.create_state() - - assert state is not None - assert isinstance(state, str) - assert len(state) > 0 - - -async def test_create_state_with_metadata(state_manager: StateManager) -> None: - """Test creating OAuth state with metadata.""" - metadata = { - "callback_url": "http://localhost:8000/callback", - "user_id": "user123", - "provider": "google", - } - - state = await state_manager.create_state(metadata=metadata) - - # Verify state was created - assert state is not None - - # Retrieve and verify metadata - retrieved_metadata = await state_manager.get_state_metadata(state) - assert retrieved_metadata == metadata - - -async def test_create_state_with_custom_ttl(state_manager: StateManager) -> None: - """Test creating OAuth state with custom TTL.""" - custom_ttl = 1800 # 30 minutes - - state = await state_manager.create_state(ttl=custom_ttl) - - # Verify state was created and stored - is_valid = await state_manager.validate_state(state) - assert is_valid is True - - -async def test_consume_state(state_manager: StateManager) -> None: - """Test consuming a valid OAuth state.""" - state = await state_manager.create_state() - - # Consume the state - state_data = await state_manager.consume_state(state) - - assert isinstance(state_data, StateData) - assert state_data.state == state - assert state_data.created_at is not None - assert state_data.expires_at is not None - assert state_data.metadata is not None - - # State should no longer be valid after consumption - is_valid = await state_manager.validate_state(state) - assert is_valid is False - - -async def test_consume_state_with_different_manager( - memory_backend: MemoryBackend, -) -> None: - """Test consuming state with a different StateManager instance.""" - BackendProxy.set(memory_backend) - - manager1 = StateManager() - state = await manager1.create_state() - - # Create a new StateManager instance - new_manager = StateManager() - state_data = await new_manager.consume_state(state) - - assert isinstance(state_data, StateData) - assert state_data.state == state - - # Original manager should also see the state as consumed - is_valid = await manager1.validate_state(state) - assert is_valid is False - - -async def test_consume_state_with_metadata(state_manager: StateManager) -> None: - """Test consuming state and retrieving its metadata.""" - metadata = { - "callback_url": "http://localhost:8000/callback", - "nonce": "random_nonce", - } - - state = await state_manager.create_state(metadata=metadata) - - # Consume the state - state_data = await state_manager.consume_state(state) - - assert state_data.metadata == metadata - - -async def test_consume_invalid_state(state_manager: StateManager) -> None: - """Test consuming an invalid state raises InvalidStateError.""" - with pytest.raises(InvalidStateError, match="Invalid or expired state"): - await state_manager.consume_state("invalid_state_string") - - -async def test_consume_state_after_backend_ttl_is_invalid( - memory_backend: MemoryBackend, monkeypatch: pytest.MonkeyPatch -) -> None: - """Once the backend TTL has run out the entry is gone: InvalidStateError. - - A state whose backend entry still exists but whose expires_at has passed - raises StateExpiredError instead; see - test_consume_state_past_wall_clock_expiry_removes_the_entry. - """ - manager = StateManager(backend=memory_backend) - state = await manager.create_state(ttl=60) - - later = time.time() + 61 - monkeypatch.setattr("fastapi_cachex.backends.memory.time.time", lambda: later) - - with pytest.raises(InvalidStateError, match="Invalid or expired state"): - await manager.consume_state(state) - - -async def test_validate_state(state_manager: StateManager) -> None: - """Test validating a state without consuming it.""" - state = await state_manager.create_state() - - # Validate the state - is_valid = await state_manager.validate_state(state) - assert is_valid is True - - # State should still be valid after validation - is_valid_again = await state_manager.validate_state(state) - assert is_valid_again is True - - -async def test_validate_invalid_state(state_manager: StateManager) -> None: - """Test validating an invalid state.""" - is_valid = await state_manager.validate_state("invalid_state") - assert is_valid is False - - -async def test_get_state_metadata_valid(state_manager: StateManager) -> None: - """Test retrieving metadata from a valid state.""" - metadata = {"key": "value", "nested": {"data": 123}} - - state = await state_manager.create_state(metadata=metadata) - - # Retrieve metadata without consuming - retrieved = await state_manager.get_state_metadata(state) - assert retrieved == metadata - - -async def test_get_state_metadata_invalid(state_manager: StateManager) -> None: - """Test retrieving metadata from an invalid state.""" - retrieved = await state_manager.get_state_metadata("invalid_state") - assert retrieved is None - - -async def test_delete_state(state_manager: StateManager) -> None: - """Test manually deleting a state.""" - state = await state_manager.create_state() - - # Verify state exists - is_valid = await state_manager.validate_state(state) - assert is_valid is True - - # Delete the state - deleted = await state_manager.delete_state(state) - assert deleted is True - - # Verify state no longer exists - is_valid_after = await state_manager.validate_state(state) - assert is_valid_after is False - - -async def test_multiple_states_independent(state_manager: StateManager) -> None: - """Test that multiple states are independent.""" - metadata1 = {"user_id": "user1"} - metadata2 = {"user_id": "user2"} - - state1 = await state_manager.create_state(metadata=metadata1) - state2 = await state_manager.create_state(metadata=metadata2) - - # States should be different - assert state1 != state2 - - # Each state should have its own metadata - retrieved1 = await state_manager.get_state_metadata(state1) - retrieved2 = await state_manager.get_state_metadata(state2) - - assert retrieved1 == metadata1 - assert retrieved2 == metadata2 - - # Consuming one shouldn't affect the other - await state_manager.consume_state(state1) - - is_valid1 = await state_manager.validate_state(state1) - is_valid2 = await state_manager.validate_state(state2) - - assert is_valid1 is False - assert is_valid2 is True - - -async def test_state_expiry_information(state_manager: StateManager) -> None: - """Test that state data contains correct expiry information.""" - ttl = 3600 - - state = await state_manager.create_state(ttl=ttl) - state_data = await state_manager.consume_state(state) - - # Verify timestamps are present and correct - assert isinstance(state_data.created_at, datetime) - assert isinstance(state_data.expires_at, datetime) - - # Expiry should be approximately TTL seconds after creation - time_diff = (state_data.expires_at - state_data.created_at).total_seconds() - assert abs(time_diff - ttl) < 5 # Allow 5 seconds tolerance - - -async def test_state_manager_custom_prefix(memory_backend: MemoryBackend) -> None: - """Test StateManager with custom key prefix.""" - BackendProxy.set(memory_backend) - manager = StateManager(key_prefix="custom_prefix:") - - state = await manager.create_state(metadata={"test": "data"}) - - # Verify state works with custom prefix - is_valid = await manager.validate_state(state) - assert is_valid is True - - # Different manager with different prefix shouldn't find the state - other_manager = StateManager(key_prefix="other_prefix:") - is_valid_other = await other_manager.validate_state(state) - assert is_valid_other is False - - -async def test_state_reuse_prevention(state_manager: StateManager) -> None: - """Test that consumed states cannot be reused.""" - state = await state_manager.create_state() - - # Consume the state once - await state_manager.consume_state(state) - - # Try to consume again - should fail - with pytest.raises(InvalidStateError): - await state_manager.consume_state(state) - - -async def test_get_state_metadata_after_expire( - state_manager: StateManager, clock: Clock -) -> None: - """Test retrieving metadata from an expired state.""" - # Create state with very short TTL - state = await state_manager.create_state(ttl=1, metadata={"test": "data"}) - - await clock.wait(1.1, state_manager.backend) - - # Try to retrieve metadata - should return None since it's expired - retrieved = await state_manager.get_state_metadata(state) - assert retrieved is None - - -async def test_consume_state_with_invalid_json(state_manager: StateManager) -> None: - """Test consuming state when backend returns invalid JSON.""" - # Directly set invalid JSON in backend - cache_key = f"{state_manager.key_prefix}bad_state" - etag = hashlib.sha256(b"not valid json").hexdigest() - entry = CacheEntry(fingerprint=etag, content=b"not valid json") - await state_manager.backend.set(cache_key, entry, ttl=600) - - # Try to consume - should raise StateDataError - with pytest.raises(StateDataError, match="Failed to parse state data"): - await state_manager.consume_state("bad_state") - - -async def test_get_metadata_with_invalid_json(state_manager: StateManager) -> None: - """Test retrieving metadata when backend returns invalid JSON.""" - # Directly set invalid JSON in backend - cache_key = f"{state_manager.key_prefix}bad_state" - etag = hashlib.sha256(b"not valid json").hexdigest() - entry = CacheEntry(fingerprint=etag, content=b"not valid json") - await state_manager.backend.set(cache_key, entry, ttl=600) - - # Try to retrieve metadata - should return None - retrieved = await state_manager.get_state_metadata("bad_state") - assert retrieved is None - - -async def test_validate_state_with_invalid_json(state_manager: StateManager) -> None: - """Test validating state when backend returns invalid JSON.""" - # Directly set invalid JSON in backend - cache_key = f"{state_manager.key_prefix}bad_state" - etag = hashlib.sha256(b"not valid json").hexdigest() - entry = CacheEntry(fingerprint=etag, content=b"not valid json") - await state_manager.backend.set(cache_key, entry, ttl=600) - - # Try to validate - should return False - is_valid = await state_manager.validate_state("bad_state") - assert is_valid is False - - -async def test_create_state_empty_metadata(state_manager: StateManager) -> None: - """Test creating state with empty metadata.""" - state = await state_manager.create_state(metadata={}) - - # Verify metadata is empty dict - metadata = await state_manager.get_state_metadata(state) - assert metadata == {} - - -async def test_state_with_complex_nested_metadata(state_manager: StateManager) -> None: - """Test state with complex nested metadata structures.""" - complex_metadata = { - "level1": { - "level2": { - "level3": ["item1", "item2", {"key": "value"}], - "numbers": [1, 2, 3], - }, - "boolean": True, - "null_value": None, - }, - "list_of_dicts": [{"a": 1}, {"b": 2}], - } - - state = await state_manager.create_state(metadata=complex_metadata) - - retrieved = await state_manager.get_state_metadata(state) - assert retrieved == complex_metadata - - -async def test_get_metadata_with_missing_expiry(state_manager: StateManager) -> None: - """A stored state without expires_at yields no metadata.""" - state = "test_state" - cache_key = f"{state_manager.key_prefix}{state}" - - # expires_at is required by StateData, so this entry cannot be decoded. - state_data = { - "state": state, - "created_at": datetime.now(timezone.utc).isoformat(), - "metadata": {"test": "data"}, - } - json_content = json.dumps(state_data) - etag = hashlib.sha256(json_content.encode()).hexdigest() - entry = CacheEntry(fingerprint=etag, content=json_content.encode("utf-8")) - await state_manager.backend.set(cache_key, entry, ttl=600) - - assert await state_manager.get_state_metadata(state) is None - # Peeking does not consume the entry. - assert await state_manager.backend.get(cache_key) is not None - - -async def test_validate_state_with_missing_expiry(state_manager: StateManager) -> None: - """A stored state without expires_at does not validate.""" - state = "test_state" - cache_key = f"{state_manager.key_prefix}{state}" - - # expires_at is required by StateData, so this entry cannot be decoded. - state_data = { - "state": state, - "created_at": datetime.now(timezone.utc).isoformat(), - "metadata": {"test": "data"}, - } - json_content = json.dumps(state_data) - etag = hashlib.sha256(json_content.encode()).hexdigest() - entry = CacheEntry(fingerprint=etag, content=json_content.encode("utf-8")) - await state_manager.backend.set(cache_key, entry, ttl=600) - - assert await state_manager.validate_state(state) is False - # Peeking does not consume the entry. - assert await state_manager.backend.get(cache_key) is not None - - -async def test_validate_state_with_invalid_expiry_format( - state_manager: StateManager, -) -> None: - """Test validating state when expires_at has invalid format.""" - state = "test_state" - cache_key = f"{state_manager.key_prefix}{state}" - - # Manually construct invalid state data - state_data = { - "state": state, - "created_at": datetime.now(timezone.utc).isoformat(), - "expires_at": "invalid-date-format", - "metadata": {"test": "data"}, - } - json_content = json.dumps(state_data) - etag = hashlib.sha256(json_content.encode()).hexdigest() - entry = CacheEntry(fingerprint=etag, content=json_content.encode("utf-8")) - await state_manager.backend.set(cache_key, entry, ttl=600) - - # Should return False due to invalid expiry - is_valid = await state_manager.validate_state(state) - assert is_valid is False - - -async def test_get_metadata_with_invalid_expiry_format( - state_manager: StateManager, -) -> None: - """Test retrieving metadata when expires_at has invalid format.""" - state = "test_state" - cache_key = f"{state_manager.key_prefix}{state}" - - # Manually construct invalid state data - state_data = { - "state": state, - "created_at": datetime.now(timezone.utc).isoformat(), - "expires_at": "invalid-date-format", - "metadata": {"test": "data"}, - } - json_content = json.dumps(state_data) - etag = hashlib.sha256(json_content.encode()).hexdigest() - entry = CacheEntry(fingerprint=etag, content=json_content.encode("utf-8")) - await state_manager.backend.set(cache_key, entry, ttl=600) - - # Should return None due to invalid expiry - retrieved = await state_manager.get_state_metadata(state) - assert retrieved is None - - -async def test_consume_state_with_missing_expiry(state_manager: StateManager) -> None: - """Test consuming state when state data is missing expiry.""" - state = "test_state" - cache_key = f"{state_manager.key_prefix}{state}" - - # Manually construct state data without expires_at - state_data = { - "state": state, - "created_at": datetime.now(timezone.utc).isoformat(), - "metadata": {"test": "data"}, - } - json_content = json.dumps(state_data) - etag = hashlib.sha256(json_content.encode()).hexdigest() - entry = CacheEntry(fingerprint=etag, content=json_content.encode("utf-8")) - await state_manager.backend.set(cache_key, entry, ttl=600) - - with pytest.raises(StateDataError): - await state_manager.consume_state(state) - - -async def test_consume_state_with_non_string_content( - state_manager: StateManager, -) -> None: - """Test consuming state when backend content is not a string.""" - # Directly set non-string content in backend - cache_key = f"{state_manager.key_prefix}bad_state" - # CacheEntry with non-string content (testing edge case) - entry = CacheEntry(fingerprint="test", content=b"\xff\xfe non-utf8") - await state_manager.backend.set(cache_key, entry, ttl=600) - - # Try to consume - should raise StateDataError - with pytest.raises(StateDataError, match="Unexpected state data format"): - await state_manager.consume_state("bad_state") - - -async def test_validate_state_with_non_string_content( - state_manager: StateManager, -) -> None: - """Test validating state when backend content is not a string.""" - cache_key = f"{state_manager.key_prefix}bad_state" - entry = CacheEntry(fingerprint="test", content=b"\xff\xfe non-utf8") - await state_manager.backend.set(cache_key, entry, ttl=600) - - # Try to validate - should return False - is_valid = await state_manager.validate_state("bad_state") - assert is_valid is False - - -async def test_get_metadata_with_non_string_content( - state_manager: StateManager, -) -> None: - """Test retrieving metadata when backend content is not a string.""" - cache_key = f"{state_manager.key_prefix}bad_state" - entry = CacheEntry(fingerprint="test", content=b"\xff\xfe non-utf8") - await state_manager.backend.set(cache_key, entry, ttl=600) - - # Try to retrieve metadata - should return None - retrieved = await state_manager.get_state_metadata("bad_state") - assert retrieved is None - - -NON_OBJECT_JSON = [ - pytest.param(b"[1, 2]", id="array"), - pytest.param(b'"x"', id="string"), - pytest.param(b"1", id="number"), - pytest.param(b"null", id="null"), -] - - -async def _store_raw_state( - state_manager: StateManager, state: str, content: bytes -) -> None: - entry = CacheEntry(fingerprint=hashlib.sha256(content).hexdigest(), content=content) - await state_manager.backend.set( - f"{state_manager.key_prefix}{state}", entry, ttl=600 - ) - - -@pytest.mark.parametrize( - "content", - [ - pytest.param(b"1" * 5000, id="top-level"), - pytest.param( - b'{"state": "s", "metadata": {"n": ' + b"1" * 5000 + b"}}", id="nested" - ), - ], -) -async def test_oversized_json_integer_is_malformed( - state_manager: StateManager, content: bytes -) -> None: - """An integer past the interpreter's digit limit is malformed data, not a ValueError.""" - await _store_raw_state(state_manager, "bad_state", content) - - assert await state_manager.validate_state("bad_state") is False - assert await state_manager.get_state_metadata("bad_state") is None - with pytest.raises(StateDataError, match="Failed to parse state data"): - await state_manager.consume_state("bad_state") - - -@pytest.mark.parametrize("content", NON_OBJECT_JSON) -async def test_consume_state_with_non_object_json( - state_manager: StateManager, content: bytes -) -> None: - """consume_state() raises StateDataError (not TypeError) for JSON that is not an object.""" - await _store_raw_state(state_manager, "bad_state", content) - - with pytest.raises(StateDataError, match="expected a JSON object"): - await state_manager.consume_state("bad_state") - assert ( - await state_manager.backend.get(f"{state_manager.key_prefix}bad_state") is None - ) - - -@pytest.mark.parametrize("content", NON_OBJECT_JSON) -async def test_validate_state_with_non_object_json( - state_manager: StateManager, content: bytes -) -> None: - """validate_state() returns False for JSON that is not an object.""" - await _store_raw_state(state_manager, "bad_state", content) - - assert await state_manager.validate_state("bad_state") is False - - -@pytest.mark.parametrize("content", NON_OBJECT_JSON) -async def test_get_metadata_with_non_object_json( - state_manager: StateManager, content: bytes -) -> None: - """get_state_metadata() returns None for JSON that is not an object.""" - await _store_raw_state(state_manager, "bad_state", content) - - assert await state_manager.get_state_metadata("bad_state") is None - - -async def test_get_metadata_with_non_dict_metadata(state_manager: StateManager) -> None: - """Test retrieving metadata when metadata is not a dict.""" - state = "test_state" - cache_key = f"{state_manager.key_prefix}{state}" - - # Manually construct state data with invalid metadata - state_data = { - "state": state, - "created_at": datetime.now(timezone.utc).isoformat(), - "expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), - "metadata": "not a dict", # Invalid metadata type - } - json_content = json.dumps(state_data) - etag = hashlib.sha256(json_content.encode()).hexdigest() - entry = CacheEntry(fingerprint=etag, content=json_content.encode("utf-8")) - await state_manager.backend.set(cache_key, entry, ttl=600) - - # Should return None since metadata validation fails - retrieved = await state_manager.get_state_metadata(state) - assert retrieved is None - - -async def test_consume_state_with_bad_expiry_date(state_manager: StateManager) -> None: - """Test consuming state when expiry date has invalid format.""" - state = "test_state" - cache_key = f"{state_manager.key_prefix}{state}" - - # Manually construct state data with invalid expiry - state_data = { - "state": state, - "created_at": datetime.now(timezone.utc).isoformat(), - "expires_at": "bad-date", - "metadata": {}, - } - json_content = json.dumps(state_data) - etag = hashlib.sha256(json_content.encode()).hexdigest() - entry = CacheEntry(fingerprint=etag, content=json_content.encode("utf-8")) - await state_manager.backend.set(cache_key, entry, ttl=600) - - # Should raise StateDataError due to bad expiry format - with pytest.raises(StateDataError, match="Invalid state data structure"): - await state_manager.consume_state(state) - - -# --------------------------------------------------------------------------- -# New tests for fixes applied in this session -# --------------------------------------------------------------------------- - - -async def test_delete_state_nonexistent_returns_false( - state_manager: StateManager, -) -> None: - """delete_state() must return False for a state that was never created.""" - result = await state_manager.delete_state("this_state_does_not_exist") - assert result is False - - -async def test_delete_state_existing_returns_true( - state_manager: StateManager, -) -> None: - """delete_state() must return True when the state exists and is deleted.""" - state = await state_manager.create_state() - result = await state_manager.delete_state(state) - assert result is True - # And it's gone now - is_valid = await state_manager.validate_state(state) - assert is_valid is False - - -async def test_delete_state_idempotent_returns_false_on_second_call( - state_manager: StateManager, -) -> None: - """Deleting a state twice: second call must return False.""" - state = await state_manager.create_state() - assert await state_manager.delete_state(state) is True - assert await state_manager.delete_state(state) is False - - -async def test_state_manager_accepts_explicit_backend( - memory_backend: MemoryBackend, -) -> None: - """StateManager(backend=...) must use the provided backend without touching BackendProxy.""" - # Unset BackendProxy: the manager must never call BackendProxy.get() when - # a backend is passed directly. setup_default_backend (autouse) sets a - # fresh backend for the next test. - BackendProxy.set(None) - - manager = StateManager(backend=memory_backend) - assert manager.backend is memory_backend - - state = await manager.create_state(metadata={"direct": True}) - assert await manager.validate_state(state) is True - - -async def test_state_manager_falls_back_to_backend_proxy( - memory_backend: MemoryBackend, -) -> None: - """StateManager() with no backend argument must use BackendProxy.get().""" - # setup_default_backend (autouse) already set a backend on BackendProxy. - # StateManager() must pick that up via BackendProxy.get(). - proxy_backend = BackendProxy.get() - manager = StateManager() - # The manager should use whatever BackendProxy currently holds - assert manager.backend is proxy_backend - - state = await manager.create_state() - assert await manager.validate_state(state) is True - - -def test_state_manager_raises_when_no_backend_configured() -> None: - """StateManager() raises BackendNotFoundError if no backend is configured in the proxy.""" - from fastapi_cachex.exceptions import BackendNotFoundError - - # setup_default_backend (autouse) sets a fresh backend for the next test. - BackendProxy.set(None) - with pytest.raises(BackendNotFoundError): - StateManager() - - -async def test_consume_state_has_exactly_one_winner_under_concurrency( - state_manager: StateManager, -) -> None: - """A replayed callback racing the real one must not be accepted twice.""" - import asyncio - - state = await state_manager.create_state(metadata={"n": 1}) - - results = await asyncio.gather( - *(state_manager.consume_state(state) for _ in range(10)), - return_exceptions=True, - ) - - winners = [r for r in results if isinstance(r, StateData)] - losers = [r for r in results if isinstance(r, InvalidStateError)] - assert len(winners) == 1 - assert len(losers) == 9 - assert winners[0].metadata == {"n": 1} - - -async def test_consume_state_past_wall_clock_expiry_removes_the_entry( - state_manager: StateManager, -) -> None: - """An entry whose expires_at passed before the backend TTL is gone after consume.""" - from datetime import datetime - from datetime import timedelta - from datetime import timezone - - from fastapi_cachex.state.exceptions import StateExpiredError - - state = await state_manager.create_state(ttl=60) - cache_key = f"{state_manager.key_prefix}{state}" - cached = await state_manager.backend.get(cache_key) - assert cached is not None - stale = json.loads(cached.content.decode()) - stale["expires_at"] = ( - datetime.now(timezone.utc) - timedelta(seconds=1) - ).isoformat() - await state_manager.backend.set( - cache_key, - CacheEntry(fingerprint="stale", content=json.dumps(stale).encode()), - ttl=60, - ) - - with pytest.raises(StateExpiredError): - await state_manager.consume_state(state) - - assert await state_manager.backend.get(cache_key) is None - assert await state_manager.validate_state(state) is False - - -async def test_peeking_a_state_past_its_wall_clock_expiry_treats_it_as_gone( - state_manager: StateManager, -) -> None: - """A stored entry whose expires_at already passed neither validates nor yields metadata.""" - state = "stale_state" - state_data_obj = StateData( - state=state, - metadata={"test": "data"}, - expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), - ) - content = state_data_obj.model_dump_json().encode("utf-8") - entry = CacheEntry(fingerprint=hashlib.sha256(content).hexdigest(), content=content) - await state_manager.backend.set( - f"{state_manager.key_prefix}{state}", entry, ttl=600 - ) - - assert await state_manager.validate_state(state) is False - assert await state_manager.get_state_metadata(state) is None - - -def _state_records(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]: - return [r for r in caplog.records if r.name == "fastapi_cachex.state.manager"] - - -async def test_logs_never_contain_the_raw_state( - memory_backend: MemoryBackend, caplog: pytest.LogCaptureFixture -) -> None: - """Every log line identifies a state by digest only, so CR/LF cannot forge lines.""" - caplog.set_level(logging.DEBUG, logger="fastapi_cachex.state.manager") - manager = StateManager(backend=memory_backend) - - state = await manager.create_state() - await manager.validate_state(state) - await manager.consume_state(state) - await manager.delete_state(state) - forged = "abc\r\nERROR forged entry" - with pytest.raises(InvalidStateError): - await manager.consume_state(forged) - await manager.validate_state(forged) - - records = _state_records(caplog) - assert records - assert state not in caplog.text - assert "forged entry" not in caplog.text - assert all("\n" not in r.getMessage() for r in records) - assert hashlib.sha256(state.encode()).hexdigest()[:12] in caplog.text - - -async def test_unknown_state_is_not_logged_as_warning( - memory_backend: MemoryBackend, caplog: pytest.LogCaptureFixture -) -> None: - """A missing or expired state is routine client input, not an operator warning.""" - caplog.set_level(logging.DEBUG, logger="fastapi_cachex.state.manager") - manager = StateManager(backend=memory_backend) - - with pytest.raises(InvalidStateError): - await manager.consume_state("unknown") - - state = await manager.create_state(ttl=60) - stale = StateData( - state=state, expires_at=datetime.now(timezone.utc) - timedelta(seconds=1) - ) - content = stale.model_dump_json().encode() - await memory_backend.set( - f"{manager.key_prefix}{state}", - CacheEntry(fingerprint=hashlib.sha256(content).hexdigest(), content=content), - ttl=60, - ) - with pytest.raises(StateExpiredError): - await manager.consume_state(state) - - assert all(r.levelno < logging.WARNING for r in _state_records(caplog)) - - -@pytest.mark.parametrize("operation", ["consume", "validate", "metadata"]) -async def test_malformed_state_data_is_logged_once_without_the_state( - memory_backend: MemoryBackend, - caplog: pytest.LogCaptureFixture, - operation: str, -) -> None: - """A decode failure produces one warning, with no traceback echoing the stored state.""" - caplog.set_level(logging.DEBUG, logger="fastapi_cachex.state.manager") - manager = StateManager(backend=memory_backend) - state = "secret-state-token" - content = json.dumps({"state": state, "expires_at": "not a date"}).encode() - await memory_backend.set( - f"{manager.key_prefix}{state}", - CacheEntry(fingerprint=hashlib.sha256(content).hexdigest(), content=content), - ttl=60, - ) - - if operation == "consume": - with pytest.raises(StateDataError): - await manager.consume_state(state) - elif operation == "validate": - assert await manager.validate_state(state) is False - else: - assert await manager.get_state_metadata(state) is None - - records = [r for r in _state_records(caplog) if r.levelno >= logging.WARNING] - assert len(records) == 1 - assert records[0].levelno == logging.WARNING - assert records[0].exc_info is None - assert state not in caplog.text - - -async def test_bound_state_is_accepted_with_its_binding( - state_manager: StateManager, -) -> None: - """The client that started the flow completes it (#226).""" - state = await state_manager.create_state(binding="nonce-a") - - data = await state_manager.consume_state(state, binding="nonce-a") - - assert data.state == state - assert data.binding_hash == hashlib.sha256(b"nonce-a").hexdigest() - - -@pytest.mark.parametrize("binding", ["nonce-b", None]) -async def test_bound_state_is_rejected_for_another_client( - state_manager: StateManager, binding: str | None -) -> None: - """Login CSRF: a state issued to the attacker must not complete in the victim's browser (#226). - - The victim's callback carries the victim's binding (or none), not the - attacker's, and the state is consumed so it cannot be retried. - """ - state = await state_manager.create_state(binding="nonce-a") - - with pytest.raises(InvalidStateError, match="different client"): - await state_manager.consume_state(state, binding=binding) - - with pytest.raises(InvalidStateError): - await state_manager.consume_state(state, binding="nonce-a") - - -async def test_unbound_state_is_rejected_with_a_binding( - state_manager: StateManager, -) -> None: - """A caller that checks bindings must not accept a state issued without one.""" - state = await state_manager.create_state() - - with pytest.raises(InvalidStateError, match="different client"): - await state_manager.consume_state(state, binding="nonce-a") - - -async def test_empty_binding_is_rejected(state_manager: StateManager) -> None: - """An empty binding (a missing cookie read as "") would bind everyone alike.""" - with pytest.raises(ValueError, match="binding must not be empty"): - await state_manager.create_state(binding="") - - -async def test_binding_is_not_stored_in_plain_text( - memory_backend: MemoryBackend, -) -> None: - """Only the SHA-256 of the binding reaches the backend.""" - manager = StateManager(backend=memory_backend) - state = await manager.create_state(binding="nonce-secret") - - entry = await memory_backend.get(f"{manager.key_prefix}{state}") - - assert entry is not None - assert b"nonce-secret" not in entry.content diff --git a/tests/state/test_proxy.py b/tests/state/test_proxy.py deleted file mode 100644 index 6c68351..0000000 --- a/tests/state/test_proxy.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Tests for StateManagerProxy and get_state_manager dependency.""" - -import threading -import time -from concurrent.futures import ThreadPoolExecutor - -import pytest - -from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.exceptions import BackendNotFoundError -from fastapi_cachex.proxy import BackendProxy -from fastapi_cachex.state import dependencies as state_dependencies -from fastapi_cachex.state.dependencies import get_state_manager -from fastapi_cachex.state.manager import StateManager -from fastapi_cachex.state.proxy import StateManagerProxy - - -def test_state_manager_proxy_get_set(memory_backend: MemoryBackend) -> None: - """StateManagerProxy.set()/.get() round-trip a StateManager instance.""" - manager = StateManager(backend=memory_backend) - StateManagerProxy.set(manager) - try: - assert StateManagerProxy.get() is manager - finally: - StateManagerProxy.set(None) - - -def test_state_manager_proxy_raises_when_unset() -> None: - """StateManagerProxy.get() raises BackendNotFoundError when unset.""" - StateManagerProxy.set(None) - with pytest.raises(BackendNotFoundError): - StateManagerProxy.get() - - -def test_state_manager_proxy_cannot_be_instantiated() -> None: - """StateManagerProxy cannot be instantiated due to ProxyMeta.""" - with pytest.raises(TypeError, match="Proxy class cannot be instantiated"): - StateManagerProxy() - - -def test_get_state_manager_lazily_creates_default( - memory_backend: MemoryBackend, -) -> None: - """get_state_manager() lazily creates and registers a default StateManager.""" - BackendProxy.set(memory_backend) - StateManagerProxy.set(None) - try: - manager = get_state_manager() - assert isinstance(manager, StateManager) - assert StateManagerProxy.get() is manager - finally: - StateManagerProxy.set(None) - - -def test_get_state_manager_reuses_existing_proxy_instance( - memory_backend: MemoryBackend, -) -> None: - """get_state_manager() reuses an already-set StateManagerProxy instance.""" - existing = StateManager(backend=memory_backend) - StateManagerProxy.set(existing) - try: - assert get_state_manager() is existing - finally: - StateManagerProxy.set(None) - - -def test_get_state_manager_concurrent_first_calls_share_one_instance( - memory_backend: MemoryBackend, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """FastAPI runs the sync dependency in worker threads; racers must agree. - - Without a lock each first request built and registered its own - `StateManager`, and the later `set()` replaced the earlier one. - """ - - class SlowStateManager(StateManager): - def __init__(self) -> None: - time.sleep(0.05) # widen the window between the check and the set - super().__init__() - - monkeypatch.setattr(state_dependencies, "StateManager", SlowStateManager) - BackendProxy.set(memory_backend) - StateManagerProxy.set(None) - workers = 8 - barrier = threading.Barrier(workers) - - def first_call(_: int) -> StateManager: - barrier.wait() - return get_state_manager() - - try: - with ThreadPoolExecutor(max_workers=workers) as pool: - managers = list(pool.map(first_call, range(workers))) - assert len({id(manager) for manager in managers}) == 1 - assert StateManagerProxy.get() is managers[0] - finally: - StateManagerProxy.set(None) - - -def test_get_state_manager_without_a_backend_raises_and_registers_nothing() -> None: - """OAuth states need a shared backend, so there is no memory fallback.""" - BackendProxy.set(None) - StateManagerProxy.set(None) - - with pytest.raises(BackendNotFoundError): - get_state_manager() - - with pytest.raises(BackendNotFoundError): - StateManagerProxy.get() - with pytest.raises(BackendNotFoundError): - BackendProxy.get() diff --git a/tests/test_cache_authorized_private.py b/tests/test_cache_authorized_private.py new file mode 100644 index 0000000..4032ca6 --- /dev/null +++ b/tests/test_cache_authorized_private.py @@ -0,0 +1,85 @@ +"""`cache_authorized` answers are private (#372). + +`cache_authorized=True` lets a request with credentials use the backend under +a per-caller key, but its response kept the decorator's header: no `private`, +so a CDN in front of the app, which keys on the URL alone, could serve one +user's response to the next under `max-age=60, must-revalidate`. +""" + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi.testclient import TestClient + +from fastapi_cachex import build_cache_key +from fastapi_cachex import cache + +_AUTH = {"Authorization": "Bearer alice"} + + +def _per_caller(request: Request) -> str: + return build_cache_key(request, request.headers.get("authorization", "")) + + +@pytest.mark.parametrize( + ("cache_kwargs", "expected"), + [ + ({}, "private, max-age=60"), + ({"must_revalidate": True}, "private, max-age=60, must-revalidate"), + ( + {"stale": "revalidate", "stale_ttl": 30}, + "private, max-age=60, stale-while-revalidate=30", + ), + ], +) +def test_authorization_answers_are_private_on_miss_hit_and_304( + cache_kwargs: dict[str, object], expected: str +) -> None: + app = FastAPI() + calls: list[str] = [] + + @app.get("/me") + @cache(ttl=60, key_builder=_per_caller, cache_authorized=True, **cache_kwargs) # type: ignore[arg-type] + async def me(request: Request) -> dict[str, str]: + calls.append(request.headers["authorization"]) + return {"me": request.headers["authorization"]} + + client = TestClient(app) + miss = client.get("/me", headers=_AUTH) + hit = client.get("/me", headers=_AUTH) + revalidated = client.get( + "/me", headers={**_AUTH, "If-None-Match": miss.headers["etag"]} + ) + + assert calls == ["Bearer alice"] # the backend is still used + assert "age" in hit.headers + assert revalidated.status_code == 304 + for response in (miss, hit, revalidated): + assert response.headers["cache-control"] == expected + + +def test_requests_without_credentials_keep_the_decorator_header() -> None: + app = FastAPI() + + @app.get("/me") + @cache(ttl=60, key_builder=_per_caller, cache_authorized=True) + async def me() -> dict[str, bool]: + return {"ok": True} + + client = TestClient(app) + client.get("/me") + + assert client.get("/me").headers["cache-control"] == "max-age=60" + + +def test_public_routes_keep_public() -> None: + app = FastAPI() + + @app.get("/catalog") + @cache(ttl=60, public=True) + async def catalog() -> dict[str, bool]: + return {"ok": True} + + response = TestClient(app).get("/catalog", headers=_AUTH) + + assert response.headers["cache-control"] == "public, max-age=60" diff --git a/tests/session/test_cache_bypass_warning.py b/tests/test_cache_bypass_warning.py similarity index 73% rename from tests/session/test_cache_bypass_warning.py rename to tests/test_cache_bypass_warning.py index 0b29d82..8ced09b 100644 --- a/tests/session/test_cache_bypass_warning.py +++ b/tests/test_cache_bypass_warning.py @@ -20,10 +20,6 @@ from fastapi_cachex import cache from fastapi_cachex.cache import _BypassWarner -from fastapi_cachex.session.config import SessionConfig -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.models import SessionUser _LOGGER = "fastapi_cachex.cache" _BEARER = "Bearer secret-bearer-value-326" @@ -37,19 +33,10 @@ def _warnings(caplog: pytest.LogCaptureFixture) -> list[str]: ] -def _app( - manager: SessionManager | None = None, - config: SessionConfig | None = None, - **cache_kwargs: object, -) -> FastAPI: +def _app(**cache_kwargs: object) -> FastAPI: """Two cached routes and a route that writes the session.""" app = FastAPI() - if manager is not None: - app.add_middleware( - FastAPICacheXSessionMiddleware, session_manager=manager, config=config - ) - else: - app.add_middleware(StarletteSessionMiddleware, secret_key="s" * 32) + app.add_middleware(StarletteSessionMiddleware, secret_key="s" * 32) @app.get("/products") @cache(ttl=60, **cache_kwargs) # type: ignore[arg-type] @@ -69,48 +56,28 @@ async def add(request: Request) -> dict[str, bool]: return app -async def _token(manager: SessionManager) -> str: - _session, token = await manager.create_session(user=SessionUser(user_id="alice")) - return token - - -async def _client( - kind: str, manager: SessionManager, config: SessionConfig, **cache_kwargs: object -) -> tuple[TestClient, str]: +def _client(kind: str, **cache_kwargs: object) -> tuple[TestClient, str]: """A client whose every request carries credential ``kind``, and its secret.""" if kind == "authorization": - app = _app(manager, config, **cache_kwargs) + app = _app(**cache_kwargs) return TestClient(app, headers={"Authorization": _BEARER}), _BEARER - if kind == "session header": - token = await _token(manager) - app = _app(manager, config, **cache_kwargs) - return TestClient(app, headers={config.header_name: token}), token - if kind == "session cookie": - token = await _token(manager) - app = _app(manager, config, **cache_kwargs) - return TestClient(app, cookies={config.cookie_name: token}), token # Starlette's cookie session with data in it. - client = TestClient(_app(None, None, **cache_kwargs)) + client = TestClient(_app(**cache_kwargs)) client.get("/add") return client, client.cookies["session"] _KINDS = { "authorization": "an Authorization header", - "session header": "a session token (header, bearer token or cookie)", - "session cookie": "a session token (header, bearer token or cookie)", "session data": "non-empty session data (request.session)", } @pytest.mark.parametrize("kind", list(_KINDS)) async def test_first_bypass_warns_once( - kind: str, - manager: SessionManager, - config: SessionConfig, - caplog: pytest.LogCaptureFixture, + kind: str, caplog: pytest.LogCaptureFixture ) -> None: - client, secret = await _client(kind, manager, config) + client, secret = _client(kind) with caplog.at_level(logging.DEBUG, logger=_LOGGER): for _ in range(3): @@ -126,15 +93,13 @@ async def test_first_bypass_warns_once( # Every request still gets its DEBUG line. debug = [r for r in caplog.records if "bypassing the backend" in r.getMessage()] assert len(debug) == 3 - # Neither the token nor the header value reaches any log line. + # Neither the session cookie nor the header value reaches any log line. assert secret not in caplog.text assert "secret-bearer-value" not in caplog.text -async def test_each_route_warns( - manager: SessionManager, config: SessionConfig, caplog: pytest.LogCaptureFixture -) -> None: - client, _ = await _client("authorization", manager, config) +async def test_each_route_warns(caplog: pytest.LogCaptureFixture) -> None: + client, _ = _client("authorization") with caplog.at_level(logging.WARNING, logger=_LOGGER): for _ in range(2): @@ -148,10 +113,10 @@ async def test_each_route_warns( async def test_route_is_named_by_its_template( - manager: SessionManager, config: SessionConfig, caplog: pytest.LogCaptureFixture + caplog: pytest.LogCaptureFixture, ) -> None: """Distinct paths of one route warn once, and no requested path is logged.""" - client, _ = await _client("authorization", manager, config) + client, _ = _client("authorization") with caplog.at_level(logging.WARNING, logger=_LOGGER): for item_id in range(5): @@ -162,33 +127,28 @@ async def test_route_is_named_by_its_template( assert "/items/3" not in warning -async def test_each_credential_kind_warns( - manager: SessionManager, config: SessionConfig, caplog: pytest.LogCaptureFixture -) -> None: - token = await _token(manager) - client = TestClient(_app(manager, config)) +async def test_each_credential_kind_warns(caplog: pytest.LogCaptureFixture) -> None: + client = TestClient(_app()) with caplog.at_level(logging.WARNING, logger=_LOGGER): for _ in range(2): client.get("/products", headers={"Authorization": _BEARER}) - client.get("/products", headers={config.header_name: token}) + client.get("/add") + for _ in range(2): + client.get("/products") warnings = _warnings(caplog) assert len(warnings) == 2 assert _KINDS["authorization"] in warnings[0] - assert _KINDS["session header"] in warnings[1] + assert _KINDS["session data"] in warnings[1] @pytest.mark.parametrize("opt_in", ["public", "cache_authorized"]) @pytest.mark.parametrize("kind", list(_KINDS)) async def test_opted_in_routes_do_not_warn( - opt_in: str, - kind: str, - manager: SessionManager, - config: SessionConfig, - caplog: pytest.LogCaptureFixture, + opt_in: str, kind: str, caplog: pytest.LogCaptureFixture ) -> None: - client, _ = await _client(kind, manager, config, **{opt_in: True}) + client, _ = _client(kind, **{opt_in: True}) with caplog.at_level(logging.WARNING, logger=_LOGGER): for _ in range(2): @@ -198,9 +158,9 @@ async def test_opted_in_routes_do_not_warn( async def test_requests_without_credentials_do_not_warn( - manager: SessionManager, config: SessionConfig, caplog: pytest.LogCaptureFixture + caplog: pytest.LogCaptureFixture, ) -> None: - client = TestClient(_app(manager, config)) + client = TestClient(_app(), cookies={"theme": "dark"}) with caplog.at_level(logging.WARNING, logger=_LOGGER): for _ in range(3): diff --git a/tests/test_cache_key_type.py b/tests/test_cache_key_type.py index 74c0a85..5627851 100644 --- a/tests/test_cache_key_type.py +++ b/tests/test_cache_key_type.py @@ -100,7 +100,7 @@ def test_parse_reverses_to_str(key: CacheKey) -> None: "key", [ pytest.param("cache:user:1", id="cache-manager"), - pytest.param("oauth_state:abc", id="state-manager"), + pytest.param("lock:abc", id="cache-lock"), pytest.param("GET|||example.com|||/items|||", id="0.3.x"), pytest.param("http:v1|GET|example.com|/items|", id="other-tag"), pytest.param("GET|example.com|/items|", id="no-tag"), diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index ea0cb4f..de0346d 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -550,13 +550,13 @@ async def test_clear_removes_all_manager_keys(memory_backend: MemoryBackend) -> await manager.set("b", 2) other_entry = CacheEntry(fingerprint="x", content=b'"other"') - await memory_backend.set("oauth_state:untouched", other_entry, ttl=None) + await memory_backend.set("other_namespace:untouched", other_entry, ttl=None) removed = await manager.clear() assert removed == 2 assert await manager.get("a") is None - assert await memory_backend.get("oauth_state:untouched") is not None + assert await memory_backend.get("other_namespace:untouched") is not None async def test_clear_pattern_delegates_to_backend_within_namespace( diff --git a/tests/test_cache_session_bypass.py b/tests/test_cache_session_bypass.py new file mode 100644 index 0000000..e7a0dd5 --- /dev/null +++ b/tests/test_cache_session_bypass.py @@ -0,0 +1,148 @@ +"""`@cache` must not share a response to a request that arrived with a session (#319). + +A non-empty ``request.session``, from any session middleware, is per visitor, +so such a request bypasses the shared backend like one with ``Authorization``. +An empty session and a ``Cookie`` header on its own do not. +""" + +import logging + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi.testclient import TestClient +from starlette.middleware.sessions import ( + SessionMiddleware as StarletteSessionMiddleware, +) + +from fastapi_cachex import cache +from fastapi_cachex.proxy import BackendProxy +from fastapi_cachex.types import CACHE_KEY_SEPARATOR + +_PRIVATE = "private, max-age=60" + + +def _key(path: str) -> str: + """The key `default_key_builder` produces for a TestClient GET.""" + return f"http:v2|GET|testserver|{path}|" + + +def _app(**cache_kwargs: object) -> tuple[FastAPI, dict[str, int]]: + """A cart kept in Starlette's cookie session, and a route that fills it.""" + app = FastAPI() + app.add_middleware(StarletteSessionMiddleware, secret_key="s" * 32) + calls = {"n": 0} + + @app.get("/cart") + @cache(ttl=60, **cache_kwargs) # type: ignore[arg-type] + async def cart(request: Request) -> dict[str, object]: + calls["n"] += 1 + return {"cart": request.session.get("cart", []), "n": calls["n"]} + + @app.get("/add") + async def add(request: Request, item: str) -> dict[str, bool]: + request.session["cart"] = [item] + return {"ok": True} + + return app, calls + + +async def test_session_with_data_bypasses_the_backend() -> None: + """Any session middleware: a non-empty `request.session` is per visitor.""" + app, _ = _app() + alice = TestClient(app) + alice.get("/add", params={"item": "apple"}) + + response = alice.get("/cart") + + assert response.json()["cart"] == ["apple"] + assert response.headers["Cache-Control"] == _PRIVATE + assert await BackendProxy.get().get(_key("/cart")) is None + + +def test_one_visitors_session_is_not_served_to_the_next() -> None: + app, _ = _app() + alice, bob = TestClient(app), TestClient(app) + alice.get("/add", params={"item": "apple"}) + bob.get("/add", params={"item": "pear"}) + + assert alice.get("/cart").json()["cart"] == ["apple"] + assert bob.get("/cart").json()["cart"] == ["pear"] + + +def test_empty_session_is_still_cached() -> None: + app, calls = _app() + client = TestClient(app) + + client.get("/cart") + response = client.get("/cart") + + assert calls["n"] == 1 + assert response.headers["Cache-Control"] == "max-age=60" + + +def test_a_cookie_header_alone_does_not_bypass() -> None: + """Only a session with data counts; an unrelated cookie keeps the cache.""" + app, calls = _app() + + TestClient(app).get("/cart") + response = TestClient(app, cookies={"theme": "dark"}).get("/cart") + + assert response.json()["n"] == 1 + assert calls["n"] == 1 + assert response.headers["Cache-Control"] == "max-age=60" + + +def test_an_x_session_token_header_does_not_bypass() -> None: + """The removed session middleware's token header is an ordinary header now.""" + app, calls = _app() + + TestClient(app).get("/cart") + response = TestClient(app).get("/cart", headers={"X-Session-Token": "abc"}) + + assert calls["n"] == 1 + assert response.headers["Cache-Control"] == "max-age=60" + + +def test_public_route_is_shared_across_sessions() -> None: + """As for `Authorization`: `public` says the response suits every caller.""" + app, calls = _app(public=True) + alice, bob = TestClient(app), TestClient(app) + alice.get("/add", params={"item": "apple"}) + bob.get("/add", params={"item": "pear"}) + + alice.get("/cart") + response = bob.get("/cart") + + assert calls["n"] == 1 + assert response.headers["Cache-Control"] == "public, max-age=60" + + +async def test_cache_authorized_caches_per_session_entries() -> None: + def per_visitor_key(request: Request) -> str: + visitor = request.session.get("cart", ["-"])[0] + return f"{request.url.path}{CACHE_KEY_SEPARATOR}{visitor}" + + app, _ = _app(key_builder=per_visitor_key, cache_authorized=True) + alice, bob = TestClient(app), TestClient(app) + alice.get("/add", params={"item": "apple"}) + bob.get("/add", params={"item": "pear"}) + + alice.get("/cart") + response = bob.get("/cart") + + assert response.json()["cart"] == ["pear"] + assert response.headers["Cache-Control"] == _PRIVATE + assert await BackendProxy.get().get("/cart|apple") is not None + assert await BackendProxy.get().get("/cart|pear") is not None + + +def test_bypass_is_logged(caplog: pytest.LogCaptureFixture) -> None: + app, _ = _app() + client = TestClient(app) + client.get("/add", params={"item": "apple"}) + + with caplog.at_level(logging.DEBUG, logger="fastapi_cachex.cache"): + client.get("/cart") + + assert "Session data present; bypassing the backend for path=/cart" in caplog.text diff --git a/tests/test_cache_vary.py b/tests/test_cache_vary.py index c63445c..28e9d5a 100644 --- a/tests/test_cache_vary.py +++ b/tests/test_cache_vary.py @@ -10,11 +10,9 @@ from fastapi import Response from fastapi.testclient import TestClient -from fastapi_cachex import SessionConfig from fastapi_cachex import add_routes from fastapi_cachex import build_cache_key from fastapi_cachex import invalidate -from fastapi_cachex._vary import _HASHED_VARY_HEADERS from fastapi_cachex.cache import cache from fastapi_cachex.exceptions import CacheXError from fastapi_cachex.proxy import BackendProxy @@ -357,12 +355,6 @@ async def test_credential_headers_are_hashed_in_any_case( ] -def test_session_header_default_is_hashed() -> None: - default = SessionConfig.model_fields["header_name"].default - - assert default.lower() in _HASHED_VARY_HEADERS - - async def test_cookie_is_hashed_with_repeated_lines_joined() -> None: with pytest.warns(UserWarning, match="cache vary on Cookie"): client, _ = _credential_app(["Cookie"]) @@ -449,7 +441,10 @@ def test_documented_filter_silences_the_cookie_warning() -> None: "vary", [ pytest.param(["Accept-Language"], id="accept-language"), - pytest.param(["Authorization", "X-Session-Token"], id="credentials"), + pytest.param( + ["Authorization", "Proxy-Authorization", "X-Session-Token"], + id="credentials", + ), pytest.param(["X-Cookie-Consent"], id="cookie-lookalike"), ], ) diff --git a/tests/test_examples.py b/tests/test_examples.py index 9b76b85..267a3c6 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -5,10 +5,9 @@ `TestClient`. A `DeprecationWarning` fails the test, so an example cannot keep showing an API we are phasing out. -`session_jwt`, `session_jwt_claims`, `redis_backend` and `session_redis` need -optional packages and are skipped without them (checked with `find_spec`, never -imported here). `redis_backend` and `session_redis` also talk to a real server, -so they follow the opt-in rules in `tests/live_servers.py`. +`redis_backend` needs the redis extra and is skipped without it (checked with +`find_spec`, never imported here). It also talks to a real server, so it +follows the opt-in rules in `tests/live_servers.py`. """ import importlib.util @@ -17,8 +16,6 @@ from concurrent.futures import ThreadPoolExecutor from pathlib import Path from types import ModuleType -from urllib.parse import parse_qs -from urllib.parse import urlsplit import pytest from fastapi.testclient import TestClient @@ -26,9 +23,6 @@ from fastapi_cachex.exceptions import BackendNotFoundError from fastapi_cachex.manager_proxy import CacheManagerProxy from fastapi_cachex.proxy import BackendProxy -from fastapi_cachex.session.exceptions import SessionSecurityError -from fastapi_cachex.session.proxy import SessionManagerProxy -from fastapi_cachex.state.proxy import StateManagerProxy from tests.live_servers import REDIS_HOST from tests.live_servers import REDIS_PORT from tests.live_servers import redis_skip_reason @@ -36,7 +30,7 @@ pytestmark = pytest.mark.filterwarnings("error::DeprecationWarning") EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "examples" -_PROXIES = (BackendProxy, CacheManagerProxy, SessionManagerProxy, StateManagerProxy) +_PROXIES = (BackendProxy, CacheManagerProxy) @pytest.fixture(autouse=True) @@ -49,12 +43,6 @@ def _reset_proxies() -> Iterator[None]: proxy.set(None) -@pytest.fixture(autouse=True) -def _session_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: - """Without SESSION_SECRET_KEY the session examples warn (an error here).""" - monkeypatch.setenv("SESSION_SECRET_KEY", "test-" + "k" * 43) - - def load_example(name: str) -> ModuleType: """Import `examples/.py` as a new module, so no state is shared.""" module_name = f"_cachex_example_{name}" @@ -78,14 +66,8 @@ def test_every_example_has_a_test() -> None: "app_cache", "cache_lock", "http_cache", - "oauth_state", "rate_limit", "redis_backend", - "session_api", - "session_jwt", - "session_jwt_claims", - "session_login", - "session_redis", } assert {p.stem for p in EXAMPLES_DIR.glob("*.py")} == tested readme = (EXAMPLES_DIR / "README.md").read_text(encoding="utf-8") @@ -187,285 +169,6 @@ def test_app_cache() -> None: assert example.upstream_calls["rates"] == calls + 2 -def _session_cookie(client: TestClient) -> str: - token = client.cookies.get("session") - assert token - return str(token) - - -def test_session_login() -> None: - example = load_example("session_login") - credentials = {"username": "alice", "password": "alice-demo-password"} - with TestClient(example.app) as client: - # An anonymous visitor fills a cart: that starts a session. - assert client.post("/cart/book").json() == {"cart": ["book"]} - anonymous_token = _session_cookie(client) - # An anonymous session is not a login. - assert client.get("/me").status_code == 401 - - wrong = {"username": "alice", "password": "nope"} - assert client.post("/login", json=wrong).status_code == 401 - - assert client.post("/login", json=credentials).status_code == 200 - user_token = _session_cookie(client) - # Login rotated the session ID: a new token, and the old one is dead. - assert user_token != anonymous_token - me = client.get("/me") - assert me.status_code == 200 - assert me.json() == {"user": "alice", "cart": ["book"]} - - with TestClient(example.app) as stranger: - assert stranger.get("/me").status_code == 401 - stranger.cookies.set("session", anonymous_token) - assert stranger.get("/me").status_code == 401 - stranger.cookies.clear() - # The header transport reaches the same session. - assert ( - stranger.get("/me", headers={"X-Session-Token": user_token}).status_code - == 200 - ) - - assert client.post("/logout").json() == {"logged_out": True} - assert client.post("/logout").json() == {"logged_out": False} - with TestClient(example.app) as replay: - replay.cookies.set("session", user_token) - assert replay.get("/me").status_code == 401 - - -def test_session_login_without_a_prior_session() -> None: - example = load_example("session_login") - credentials = {"username": "alice", "password": "alice-demo-password"} - with TestClient(example.app) as client: - assert client.post("/login", json=credentials).status_code == 200 - assert client.get("/me").json() == {"user": "alice", "cart": []} - - -def _cookie_attributes(set_cookie: str) -> dict[str, str]: - """The attributes of one `Set-Cookie` value, keys lowercased, value dropped.""" - _, *parts = (part.strip() for part in set_cookie.split(";")) - attributes = {} - for part in parts: - key, _, value = part.partition("=") - attributes[key.lower()] = value - return attributes - - -def _session_set_cookie(set_cookies: list[str]) -> str: - """The one `Set-Cookie` value for the session cookie.""" - [value] = [v for v in set_cookies if v.startswith("session=")] - return value - - -@pytest.mark.parametrize("returning_visitor", [False, True]) -def test_session_login_response_is_private(returning_visitor: bool) -> None: - """The login response carries a credential: never cacheable, same cookie flags.""" - example = load_example("session_login") - example.config.cookie_domain = "example.test" - credentials = {"username": "alice", "password": "alice-demo-password"} - with TestClient(example.app, base_url="http://app.example.test") as client: - # A cookie set by the middleware itself: the reference attributes. - started = client.post("/cart/book") - expected = _cookie_attributes( - _session_set_cookie(started.headers.get_list("set-cookie")) - ) - assert expected == { - "path": "/", - "max-age": str(example.config.cookie_max_age), - "httponly": "", - "samesite": "lax", - "domain": "example.test", - } - if not returning_visitor: - client.cookies.clear() - - login = client.post("/login", json=credentials) - assert login.status_code == 200 - assert login.headers["cache-control"] == "private, no-store" - set_cookie = _session_set_cookie(login.headers.get_list("set-cookie")) - assert _cookie_attributes(set_cookie) == expected - # Only the HttpOnly cookie: a copy in a header would be readable by scripts. - assert "x-session-token" not in login.headers - assert client.get("/me").json()["user"] == "alice" - - logout = client.post("/logout") - cleared = _cookie_attributes( - _session_set_cookie(logout.headers.get_list("set-cookie")) - ) - assert logout.headers["cache-control"] == "private, no-store" - assert cleared["domain"] == expected["domain"] - assert cleared["path"] == expected["path"] - assert "expires" in cleared - assert not client.cookies.get("session") - assert client.get("/me").status_code == 401 - - -@pytest.mark.parametrize( - "name", - [ - "session_api", - "session_login", - *( - pytest.param( - name, - marks=pytest.mark.skipif( - importlib.util.find_spec("jwt") is None, - reason=f"{name} needs the jwt extra (PyJWT)", - ), - ) - for name in ("session_jwt", "session_jwt_claims") - ), - ], -) -def test_session_example_warns_without_a_secret_key( - monkeypatch: pytest.MonkeyPatch, name: str -) -> None: - """No placeholder key: an unset SESSION_SECRET_KEY warns and a random one is used.""" - monkeypatch.delenv("SESSION_SECRET_KEY") - with pytest.warns(UserWarning, match="SESSION_SECRET_KEY is not set") as record: - first = load_example(name) - assert record[0].filename == str(EXAMPLES_DIR / f"{name}.py") - with pytest.warns(UserWarning, match="SESSION_SECRET_KEY is not set"): - second = load_example(name) - assert len(first.config.secret_key.get_secret_value()) >= 32 - assert ( - first.config.secret_key.get_secret_value() - != second.config.secret_key.get_secret_value() - ) - - -@pytest.mark.skipif( - importlib.util.find_spec("jwt") is None, - reason="session_jwt needs the jwt extra (PyJWT)", -) -def test_session_jwt() -> None: - example = load_example("session_jwt") - credentials = {"username": "alice", "password": "alice-demo-password"} - with TestClient(example.app) as client: - assert client.get("/me").status_code == 401 - issued = client.post("/token", json=credentials) - assert issued.status_code == 200 - token = issued.json()["access_token"] - assert token.count(".") == 2 # header.payload.signature - auth = {"Authorization": f"Bearer {token}"} - - assert client.get("/me", headers=auth).json() == {"user": "alice"} - assert client.post("/logout", headers=auth).status_code == 200 - # The session is gone, so the unexpired JWT no longer works. - assert client.get("/me", headers=auth).status_code == 401 - - -def test_session_api() -> None: - example = load_example("session_api") - credentials = {"username": "alice", "password": "alice-demo-password"} - with TestClient(example.app) as client: - assert client.get("/public").json() == {"message": "Hello, guest!"} - assert client.get("/profile").status_code == 401 - wrong = {"username": "alice", "password": "nope"} - assert client.post("/login", json=wrong).status_code == 401 - - token = client.post("/login", json=credentials).json()["token"] - # The token is only in the body: no cookie for an API client. - assert not client.cookies.get("session") - bearer = {"Authorization": f"Bearer {token}"} - header = {"X-Session-Token": token} - assert client.get("/profile", headers=bearer).json() == { - "user_id": "alice", - "username": "alice", - "roles": ["user"], - } - assert client.get("/public", headers=header).json() == { - "message": "Hello, alice!" - } - - assert client.post("/logout", headers=bearer).status_code == 200 - assert client.get("/profile", headers=bearer).status_code == 401 - assert client.post("/logout", headers=bearer).status_code == 401 - - -@pytest.mark.skipif( - importlib.util.find_spec("jwt") is None, - reason="session_jwt_claims needs the jwt extra (PyJWT)", -) -def test_session_jwt_claims() -> None: - example = load_example("session_jwt_claims") - credentials = {"username": "alice", "password": "alice-demo-password"} - with TestClient(example.app) as client: - wrong = {"username": "alice", "password": "nope"} - assert client.post("/auth/login", json=wrong).status_code == 401 - issued = client.post("/auth/login", json=credentials) - assert issued.status_code == 200 - token = issued.json()["token"] - claims = example.jwt.decode(token, options={"verify_signature": False}) - assert claims["tenant_id"] == "acme-corp" - assert claims["api_version"] == "v2" - assert claims["iss"] == "acme-corp" - auth = {"Authorization": f"Bearer {token}"} - assert client.get("/api/profile", headers=auth).json() == { - "user_id": "alice", - "username": "alice", - } - - # The same session, signed with the same key, for another tenant. - other = example.MultiTenantJWTSerializer(example.config, tenant_id="other") - foreign = other.to_string(example.serializer.from_string(token)) - with pytest.raises(ValueError, match="Invalid tenant_id"): - example.serializer.from_string(foreign) - foreign_auth = {"Authorization": f"Bearer {foreign}"} - assert client.get("/api/profile", headers=foreign_auth).status_code == 401 - - -def test_oauth_state() -> None: - example = load_example("oauth_state") - # https: the binding cookie is Secure. - with TestClient(example.app, base_url="https://testserver") as client: - started = client.get("/login", follow_redirects=False) - assert started.status_code == 307 - location = urlsplit(started.headers["location"]) - assert location.netloc == "provider.example.com" - state = parse_qs(location.query)["state"][0] - nonce = client.cookies.get("oauth_binding") - assert nonce - - # A different browser (another binding) is rejected, and that attempt - # consumes the state too. - with TestClient(example.app, base_url="https://testserver") as attacker: - attacker.cookies.set("oauth_binding", "attackers-own-nonce") - rejected = attacker.get( - "/callback", - params={"state": state, "code": "x"}, - follow_redirects=False, - ) - assert rejected.status_code == 400 - assert ( - client.get( - "/callback", - params={"state": state, "code": "x"}, - follow_redirects=False, - ).status_code - == 400 - ) - - -def test_oauth_state_is_consumed_once() -> None: - example = load_example("oauth_state") - with TestClient(example.app, base_url="https://testserver") as client: - started = client.get("/login", follow_redirects=False) - state = parse_qs(urlsplit(started.headers["location"]).query)["state"][0] - nonce = client.cookies.get("oauth_binding") - assert nonce - params = {"state": state, "code": "x"} - - done = client.get("/callback", params=params, follow_redirects=False) - assert done.status_code == 307 - assert done.headers["location"] == "/dashboard" - assert "oauth_binding" not in client.cookies - - # Replaying the callback from the same browser fails: the state is gone. - client.cookies.set("oauth_binding", nonce) - again = client.get("/callback", params=params, follow_redirects=False) - assert again.status_code == 400 - - def test_cache_lock_serialises_rebuilds() -> None: example = load_example("cache_lock") with TestClient(example.app) as client, ThreadPoolExecutor(3) as pool: @@ -529,68 +232,3 @@ def test_redis_backend(monkeypatch: pytest.MonkeyPatch) -> None: # The lifespan unregistered the backend on shutdown. with pytest.raises(BackendNotFoundError): BackendProxy.get() - - -@pytest.mark.skipif( - importlib.util.find_spec("redis") is None, - reason="session_redis needs the redis extra", -) -def test_session_redis(monkeypatch: pytest.MonkeyPatch) -> None: - reason = redis_skip_reason() - if reason is not None: - pytest.skip(reason) - monkeypatch.setenv("REDIS_HOST", REDIS_HOST) - monkeypatch.setenv("REDIS_PORT", str(REDIS_PORT)) - monkeypatch.delenv("REDIS_PASSWORD", raising=False) - monkeypatch.delenv("REDIS_DB", raising=False) - example = load_example("session_redis") - credentials = {"username": "alice", "password": "alice-demo-password"} - with TestClient(example.app) as client: - portal = client.portal - assert portal is not None - # clear() removes only this app's namespace, not the whole server. - portal.call(example.backend.clear) - try: - wrong = {"username": "alice", "password": "nope"} - assert client.post("/api/auth/login", json=wrong).status_code == 401 - - first = client.post("/api/auth/login", json=credentials).json() - assert first["user"] == {"username": "alice", "roles": ["user"]} - auth = {"Authorization": f"Bearer {first['token']}"} - - profile = client.get("/api/user/profile", headers=auth).json() - assert profile["user_id"] == "user_alice" - assert profile["email"] == "alice@example.com" - - # The flash message from the login is shown once. - messages = client.get("/api/messages", headers=auth).json()["messages"] - assert [m["message"] for m in messages] == ["Login successful!"] - assert client.get("/api/messages", headers=auth).json() == {"messages": []} - - updated = client.post( - "/api/user/update", params={"email": "a@example.org"}, headers=auth - ) - assert updated.status_code == 200 - profile = client.get("/api/user/profile", headers=auth).json() - assert profile["email"] == "a@example.org" - - # The session is bound to the client's IP address. (A second - # TestClient would run on another event loop than the Redis pool.) - with pytest.raises(SessionSecurityError): - portal.call( - example.session_manager.get_session, first["token"], "203.0.113.9" - ) - - second = client.post("/api/auth/login", json=credentials).json() - auth2 = {"X-Session-Token": second["token"]} - assert client.post("/api/auth/logout", headers=auth2).status_code == 200 - assert client.get("/api/user/profile", headers=auth2).status_code == 401 - - third = client.post("/api/auth/login", json=credentials).json() - auth3 = {"Authorization": f"Bearer {third['token']}"} - ended = client.post("/api/auth/logout-all", headers=auth3).json() - assert ended == {"message": "Logged out from 2 devices"} - assert client.get("/api/user/profile", headers=auth).status_code == 401 - assert client.get("/api/user/profile", headers=auth3).status_code == 401 - finally: - portal.call(example.backend.clear) diff --git a/tests/test_exports.py b/tests/test_exports.py index 2a42aaf..d7e160c 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -1,5 +1,7 @@ """Exceptions raised by the core are importable from the package (#160).""" +import importlib + import pytest import fastapi_cachex @@ -13,3 +15,19 @@ def test_core_exception_is_exported(name: str) -> None: assert name in fastapi_cachex.__all__ assert getattr(fastapi_cachex, name) is getattr(exceptions, name) + + +@pytest.mark.parametrize( + "name", ["SessionManager", "FastAPICacheXSessionMiddleware", "StateManager"] +) +def test_removed_session_and_state_names_are_gone(name: str) -> None: + """0.5.0 removed sessions and OAuth state (#421): no lazy deprecated names.""" + assert name not in fastapi_cachex.__all__ + with pytest.raises(AttributeError, match=name): + getattr(fastapi_cachex, name) + + +@pytest.mark.parametrize("module", ["fastapi_cachex.session", "fastapi_cachex.state"]) +def test_removed_session_and_state_packages_are_gone(module: str) -> None: + with pytest.raises(ModuleNotFoundError): + importlib.import_module(module) diff --git a/tests/test_proxybackend.py b/tests/test_proxybackend.py index d72c14e..1571013 100644 --- a/tests/test_proxybackend.py +++ b/tests/test_proxybackend.py @@ -17,8 +17,6 @@ from fastapi_cachex.manager_proxy import CacheManagerProxy from fastapi_cachex.proxy import ProxyBase from fastapi_cachex.proxy import get_backend_or_fallback -from fastapi_cachex.session.proxy import SessionManagerProxy -from fastapi_cachex.state.proxy import StateManagerProxy from fastapi_cachex.types import CacheEntry from tests.conftest import Clock @@ -117,9 +115,7 @@ def test_backend_proxy_cannot_be_instantiated(): BackendProxy() -@pytest.mark.parametrize( - "proxy", [CacheManagerProxy, SessionManagerProxy, StateManagerProxy] -) +@pytest.mark.parametrize("proxy", [CacheManagerProxy]) def test_manager_proxies_raise_proxy_not_set_error(proxy) -> None: """An unset manager proxy is not a missing backend (#161). diff --git a/tests/test_session_state_deprecation.py b/tests/test_session_state_deprecation.py deleted file mode 100644 index 9ee249c..0000000 --- a/tests/test_session_state_deprecation.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Session and OAuth state are deprecated in 0.4.0 (#420). - -Each package warns once per process, on first import, so these tests run the -import in a fresh interpreter. -""" - -import ast -import pkgutil -import subprocess -import sys -from pathlib import Path - -import pytest - -import fastapi_cachex - - -def _run(script: str, tmp_path: Path) -> str: - """Run ``script`` as a file in a new interpreter; return its stderr.""" - path = tmp_path / "app.py" - path.write_text(script) - result = subprocess.run( # noqa: S603 - fixed interpreter, script written by the test - [sys.executable, "-W", "always::FutureWarning", str(path)], - capture_output=True, - text=True, - check=True, - ) - return result.stderr - - -def _core_modules() -> list[str]: - """Every fastapi_cachex module outside the session and state packages.""" - return sorted( - info.name - for info in pkgutil.walk_packages( - fastapi_cachex.__path__, prefix="fastapi_cachex." - ) - if not info.name.startswith(("fastapi_cachex.session", "fastapi_cachex.state")) - ) - - -def test_core_modules_do_not_warn(tmp_path: Path) -> None: - """Nothing outside session and state imports them, the backends included.""" - modules = _core_modules() - assert "fastapi_cachex.backends.redis" in modules - stderr = _run( - "import fastapi_cachex\n" - "from fastapi_cachex import cache, CacheManager, CacheLock\n" - + "".join(f"import {name}\n" for name in modules), - tmp_path, - ) - assert "FutureWarning" not in stderr - - -@pytest.mark.parametrize( - ("script", "package"), - [ - ("from fastapi_cachex.session import SessionConfig\n", "session"), - ("from fastapi_cachex.session.config import SessionConfig\n", "session"), - ("from fastapi_cachex import SessionConfig\n", "session"), - ("import fastapi_cachex\nfastapi_cachex.get_session\n", "session"), - ("from fastapi_cachex.state import StateManager\n", "state"), - ("from fastapi_cachex import StateManager\n", "state"), - ], -) -def test_import_warns_at_the_importing_line( - script: str, package: str, tmp_path: Path -) -> None: - stderr = _run(script, tmp_path) - line = script.count("\n") - assert ( - f"app.py:{line}: FutureWarning: fastapi_cachex.{package} is deprecated " - "and will be removed in fastapi-cachex 0.5.0" - ) in stderr - assert "MIGRATING_0_4/#session-state-deprecated" in stderr - - -def test_state_does_not_import_session(tmp_path: Path) -> None: - stderr = _run("from fastapi_cachex.state import StateManager\n", tmp_path) - assert "fastapi_cachex.session is deprecated" not in stderr - - -@pytest.mark.parametrize("name", sorted(fastapi_cachex._DEPRECATED_NAMES)) -def test_deprecated_name_resolves_but_is_not_exported(name: str) -> None: - module = __import__(fastapi_cachex._DEPRECATED_NAMES[name], fromlist=[name]) - assert getattr(fastapi_cachex, name) is getattr(module, name) - assert name not in fastapi_cachex.__all__ - - -def test_unknown_name_raises_attribute_error() -> None: - with pytest.raises(AttributeError, match="has no attribute 'nope'"): - fastapi_cachex.nope # noqa: B018 - - -def test_type_checking_imports_match_the_lazy_names() -> None: - """The names type checkers see are the names ``__getattr__`` resolves.""" - tree = ast.parse(Path(fastapi_cachex.__file__).read_text()) - block = next( - node - for node in tree.body - if isinstance(node, ast.If) and ast.unparse(node.test) == "TYPE_CHECKING" - ) - imported = { - alias.name: f"fastapi_cachex.{node.module}" - for node in block.body - if isinstance(node, ast.ImportFrom) - for alias in node.names - } - assert imported == fastapi_cachex._DEPRECATED_NAMES diff --git a/tests/test_warning_stacklevel.py b/tests/test_warning_stacklevel.py index f043a97..624c7b4 100644 --- a/tests/test_warning_stacklevel.py +++ b/tests/test_warning_stacklevel.py @@ -11,9 +11,6 @@ from fastapi_cachex._warnings import caller_stacklevel from fastapi_cachex.backends.memory import MemoryBackend -from fastapi_cachex.manager import CacheManager -from fastapi_cachex.types import CacheEntry -from tests.backends.test_base import LegacyDictBackend def test_caller_stacklevel_without_frame_support( @@ -23,26 +20,6 @@ def test_caller_stacklevel_without_frame_support( assert caller_stacklevel() == 2 -async def test_a_none_delete_warning_names_the_caller_directly() -> None: - backend = LegacyDictBackend() - await backend.set("k", CacheEntry(fingerprint="e", content=b"v")) - - with pytest.warns(FutureWarning, match="returned None") as record: - await backend.get_and_delete("k") - - assert record[0].filename == __file__ - - -async def test_a_none_delete_warning_names_the_caller_through_cache_manager() -> None: - manager = CacheManager(backend=LegacyDictBackend()) - await manager.set("k", 1) - - with pytest.warns(FutureWarning, match="returned None") as record: - await manager.delete("k") - - assert record[0].filename == __file__ - - async def test_a_path_shaped_pattern_warning_names_the_caller() -> None: with pytest.warns(RuntimeWarning, match="cleared nothing") as record: await MemoryBackend().clear_pattern("/users/*") diff --git a/tox.ini b/tox.ini index 5ffcee2..5e1b0d9 100644 --- a/tox.ini +++ b/tox.ini @@ -32,11 +32,16 @@ uv_resolution = lowest-direct extras = redis memcached - jwt # These are direct requirements here too, so they resolve to their floors. -# Starlette 1.0's TestClient needs httpx (newer releases use httpx2). +# starlette is not a direct dependency of the package, so it is listed here to +# test the oldest release the fastapi floor accepts (fastapi 0.128.2 requires +# starlette>=0.40.0); keep the two in step. That starlette's TestClient needs +# httpx (newer releases use httpx2), and its SessionMiddleware, used by the +# session-bypass tests, needs itsdangerous. deps = httpx>=0.27.0 + itsdangerous>=1.1.0 + starlette>=0.40.0 pytest>=8.3.5 pytest-asyncio>=0.26.0 diff --git a/uv.lock b/uv.lock index 4aaa04d..36ddd9c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'emscripten'", @@ -511,15 +511,10 @@ version = "0.4.1" source = { editable = "." } dependencies = [ { name = "fastapi" }, - { name = "itsdangerous" }, { name = "pydantic" }, - { name = "starlette" }, ] [package.optional-dependencies] -jwt = [ - { name = "pyjwt" }, -] memcached = [ { name = "pymemcache" }, ] @@ -532,10 +527,10 @@ redis = [ dev = [ { name = "coverage" }, { name = "httpx2" }, + { name = "itsdangerous" }, { name = "mypy" }, { name = "orjson" }, { name = "pre-commit" }, - { name = "pyjwt" }, { name = "pymemcache" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -556,25 +551,22 @@ docs = [ [package.metadata] requires-dist = [ - { name = "fastapi", specifier = ">=0.133.0" }, - { name = "itsdangerous", specifier = ">=1.1.0" }, + { name = "fastapi", specifier = ">=0.128.2" }, { name = "orjson", marker = "extra == 'redis'", specifier = ">=3.4.7" }, { name = "pydantic", specifier = ">=2.7.0" }, - { name = "pyjwt", marker = "extra == 'jwt'", specifier = ">=2.9.0" }, { name = "pymemcache", marker = "extra == 'memcached'", specifier = ">=4.0.0" }, { name = "redis", extras = ["hiredis"], marker = "extra == 'redis'", specifier = ">=5.3.0" }, - { name = "starlette", specifier = ">=1.0.0" }, ] -provides-extras = ["memcached", "redis", "jwt"] +provides-extras = ["memcached", "redis"] [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = ">=7.8.0" }, { name = "httpx2", specifier = ">=2.13.1" }, + { name = "itsdangerous", specifier = ">=1.1.0" }, { name = "mypy", specifier = ">=1.15.0" }, { name = "orjson", specifier = ">=3.10.16" }, { name = "pre-commit", specifier = ">=4.2.0" }, - { name = "pyjwt", specifier = ">=2.9.0" }, { name = "pymemcache", specifier = ">=4.0.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.26.0" }, @@ -1520,18 +1512,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] -[[package]] -name = "pyjwt" -version = "2.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" }, -] - [[package]] name = "pymdown-extensions" version = "12.1" diff --git a/zensical.toml b/zensical.toml index 255f6ba..bf8c81d 100644 --- a/zensical.toml +++ b/zensical.toml @@ -18,7 +18,9 @@ site_dir = "site" nav = [ { "Home" = "index.md" }, + { "Migrating to 0.5.0" = "MIGRATING_0_5.md" }, { "Migrating to 0.4.0" = "MIGRATING_0_4.md" }, + { "Migrating from fastapi-cache2" = "MIGRATING_FROM_FASTAPI_CACHE2.md" }, { "When to use it" = "COMPARISON.md" }, { "Caching" = [ { "HTTP caching" = "HTTP_CACHING.md" }, @@ -26,11 +28,6 @@ nav = [ { "Backends" = "BACKENDS.md" }, { "Distributed lock" = "LOCK.md" }, ] }, - { "Sessions and sign-in (deprecated)" = [ - { "Session management" = "SESSION.md" }, - { "OAuth state" = "STATE.md" }, - { "JWT claims" = "JWT_CLAIMS.md" }, - ] }, { "Under the hood" = [ { "Cache flow" = "CACHE_FLOW.md" }, ] }, @@ -39,8 +36,6 @@ nav = [ { "CacheManager" = "api/cache-manager.md" }, { "Backends" = "api/backends.md" }, { "CacheLock" = "api/lock.md" }, - { "Session (deprecated)" = "api/session.md" }, - { "State (deprecated)" = "api/state.md" }, { "Types and exceptions" = "api/types.md" }, ] }, { "Development" = [ diff --git a/zensical.zh-TW.toml b/zensical.zh-TW.toml index af45531..3a0286f 100644 --- a/zensical.zh-TW.toml +++ b/zensical.zh-TW.toml @@ -22,7 +22,9 @@ site_dir = "site-zh-TW" # to the local file once its translation lands. nav = [ { "首頁" = "index.md" }, + { "遷移至 0.5.0" = "MIGRATING_0_5.md" }, { "遷移至 0.4.0" = "MIGRATING_0_4.md" }, + { "從 fastapi-cache2 遷移" = "MIGRATING_FROM_FASTAPI_CACHE2.md" }, { "何時使用" = "COMPARISON.md" }, { "快取" = [ { "HTTP 快取" = "HTTP_CACHING.md" }, @@ -30,11 +32,6 @@ nav = [ { "後端" = "BACKENDS.md" }, { "分散式鎖" = "LOCK.md" }, ] }, - { "Session 與登入(已棄用)" = [ - { "Session 管理" = "SESSION.md" }, - { "OAuth state" = "STATE.md" }, - { "JWT claims" = "JWT_CLAIMS.md" }, - ] }, { "深入了解" = [ { "快取流程" = "CACHE_FLOW.md" }, ] },