From 9bba925ee5430cb49b14139b95cfe41cfcc624f3 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 14:51:29 +0000 Subject: [PATCH 1/2] feat(session)!: remove the deprecated SessionMiddleware SessionMiddleware has been deprecated in favour of FastAPICacheXSessionMiddleware since 0.3.1 and was kept until 0.4.0 because a patch release could not drop an exported class. Remove it, its package exports, its API docs entry and the private _extract_header_token helper only it used, together with the tests that exercised it. Tests that installed it only to reach the session dependencies now use FastAPICacheXSessionMiddleware, and the docs describe it as removed. BREAKING CHANGE: SessionMiddleware is removed; use FastAPICacheXSessionMiddleware. --- changelog.d/69.removed.md | 7 + docs/HTTP_CACHING.md | 4 +- docs/SESSION.md | 49 ++- docs/api/session.md | 2 - fastapi_cachex/__init__.py | 2 - fastapi_cachex/cache.py | 4 +- fastapi_cachex/session/__init__.py | 2 - fastapi_cachex/session/dependencies.py | 10 +- fastapi_cachex/session/middleware.py | 168 +--------- i18n/zh-TW/docs/HTTP_CACHING.md | 2 +- i18n/zh-TW/docs/SESSION.md | 25 +- .../session/test_cache_authorized_private.py | 27 -- tests/session/test_dependencies.py | 20 +- tests/session/test_get_session_manager.py | 22 +- tests/session/test_login.py | 19 -- tests/session/test_lookup_writes.py | 26 -- tests/session/test_middleware.py | 305 +----------------- tests/session/test_starlette_middleware.py | 42 +-- tests/session/test_token_response_caching.py | 37 +-- 19 files changed, 93 insertions(+), 680 deletions(-) create mode 100644 changelog.d/69.removed.md diff --git a/changelog.d/69.removed.md b/changelog.d/69.removed.md new file mode 100644 index 0000000..22fb585 --- /dev/null +++ b/changelog.d/69.removed.md @@ -0,0 +1,7 @@ +**The deprecated header-only `SessionMiddleware` is removed.** It was +deprecated in favour of `FastAPICacheXSessionMiddleware` since 0.3.1, which +reads the same custom header and `Authorization: Bearer` token and adds +`request.session` and the session cookie. Replace +`app.add_middleware(SessionMiddleware, ...)` with +`app.add_middleware(FastAPICacheXSessionMiddleware, ...)` and set the cookie +options (see the 0.4.0 migration guide). diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index ae4008e..129cf3e 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -146,8 +146,8 @@ 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` (or the - deprecated `SessionMiddleware`) loaded one for it, from the token header, a + A request has a session when `FastAPICacheXSessionMiddleware` 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 diff --git a/docs/SESSION.md b/docs/SESSION.md index 782cdeb..25989db 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -4,24 +4,19 @@ FastAPI-CacheX Session Management provides complete user session handling, inclu 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 depends on which middleware you install:** +**How the token travels with `FastAPICacheXSessionMiddleware`:** -| Middleware | Token source | Response side | Status | -|------------|--------------|---------------|--------| -| `FastAPICacheXSessionMiddleware` | Custom header (default `X-Session-Token`) / `Authorization: Bearer` / **cookie** (default name `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` | **Recommended** | -| `SessionMiddleware` | Custom header / `Authorization: Bearer`; **no cookie support** | A renewed token, or one for a regenerated ID, is sent back in the response header | Deprecated, **removed in 0.4.0** | +| Token source | Response side | +|--------------|---------------| +| Custom header (default `X-Session-Token`) / `Authorization: Bearer` / **cookie** (default name `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` | -**Use `FastAPICacheXSessionMiddleware` for all new projects.** It covers every transport of -`SessionMiddleware` (it reads `X-Session-Token` and `Authorization: Bearer` in the same way) and -adds cookie support. Since 0.3.1, `SessionMiddleware` emits a `DeprecationWarning` when it is -constructed, and it will be **removed in 0.4.0**. Both middlewares feed the same session -dependencies (`get_session`, `get_optional_session`, `require_session`), so migrating usually only -means changing the `add_middleware` line; existing clients that send the token in a header need no -changes. +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 `SessionMiddleware` is installed. +`FastAPICacheXSessionMiddleware`**; setting them has no effect when `SessionManager` is used +without it. 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). @@ -136,12 +131,12 @@ Both methods delete what they find with a single `backend.delete_many()` call. ## Migration: SessionMiddleware → FastAPICacheXSessionMiddleware -`SessionMiddleware` has been deprecated since 0.3.1 (it emits a `DeprecationWarning` when -constructed) and will be removed in 0.4.0. Use `FastAPICacheXSessionMiddleware` instead: +`SessionMiddleware`, deprecated since 0.3.1, was removed in 0.4.0. Use +`FastAPICacheXSessionMiddleware` instead: -- **`SessionMiddleware`** (a `BaseHTTPMiddleware`): passes 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 is not supported. +- **`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 `session`), while the session contents are stored @@ -155,9 +150,9 @@ constructed) and will be removed in 0.4.0. Use `FastAPICacheXSessionMiddleware` `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`. -Both middlewares put the loaded `Session` object into `request.state`, so the existing session -dependencies `get_session`, `get_optional_session`, `require_session` and `require_user_session` -work under either middleware without any changes: +`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 @@ -205,8 +200,7 @@ async def me(session=Depends(require_user_session)): `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. The deprecated `SessionMiddleware` - does the same when it sends a token in its response header. + 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` @@ -293,8 +287,7 @@ token's source (header in, header out; cookie in, `Set-Cookie` out). Until 0.4.0 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 yet; any other position -raises a `ValidationError`. The deprecated `SessionMiddleware` never reads the cookie and ignores -the entry. +raises a `ValidationError`. In 0.4.0 the list names every token source, and its default becomes `["header", "bearer", "cookie"]`, the order used today. A list without `"cookie"` then means no cookie at all: the @@ -525,9 +518,9 @@ session `login()` returned, or issue the token from a separate endpoint, as 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()` raises `RuntimeError`: the -deprecated `SessionMiddleware` cannot send a token for a session it did not load, so there -create the session with `create_session(user=...)` and return its token. +rotating it. Without `FastAPICacheXSessionMiddleware`, `login()` raises `RuntimeError`, because +nothing would send the token; create the session with `create_session(user=...)` and return its +token instead. `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 diff --git a/docs/api/session.md b/docs/api/session.md index 206fdbb..54a1825 100644 --- a/docs/api/session.md +++ b/docs/api/session.md @@ -19,5 +19,3 @@ See the [session management guide](../SESSION.md) for how the pieces fit togethe ::: fastapi_cachex.session.dependencies ::: fastapi_cachex.session.exceptions - -::: fastapi_cachex.session.middleware.SessionMiddleware diff --git a/fastapi_cachex/__init__.py b/fastapi_cachex/__init__.py index 216f088..29711c8 100644 --- a/fastapi_cachex/__init__.py +++ b/fastapi_cachex/__init__.py @@ -27,7 +27,6 @@ from .session import SessionConfig as SessionConfig from .session import SessionManager as SessionManager from .session import SessionManagerProxy as SessionManagerProxy -from .session import SessionMiddleware as SessionMiddleware from .session import SessionUser as SessionUser from .session import get_optional_session as get_optional_session from .session import get_session as get_session @@ -94,7 +93,6 @@ def _read_version() -> str: "SessionInvalidError", "SessionManager", "SessionManagerProxy", - "SessionMiddleware", "SessionNotFoundError", "SessionSecurityError", "SessionTokenError", diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 6b3bcf1..40c56b7 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -250,8 +250,8 @@ def _vary_components(request: Request, names: Sequence[str]) -> list[str]: return components -# Where `FastAPICacheXSessionMiddleware` (and the deprecated -# `SessionMiddleware`) put the session they loaded; `get_session` reads it. +# Where `FastAPICacheXSessionMiddleware` puts the session it loaded; +# `get_session` reads it. _SESSION_STATE_KEY = "__fastapi_cachex_session" diff --git a/fastapi_cachex/session/__init__.py b/fastapi_cachex/session/__init__.py index 66fea59..b7a16ae 100644 --- a/fastapi_cachex/session/__init__.py +++ b/fastapi_cachex/session/__init__.py @@ -11,7 +11,6 @@ from .dependencies import rotate_session_id from .manager import SessionManager from .middleware import FastAPICacheXSessionMiddleware -from .middleware import SessionMiddleware from .middleware import get_client_ip from .models import Session from .models import SessionUser @@ -23,7 +22,6 @@ "SessionConfig", "SessionManager", "SessionManagerProxy", - "SessionMiddleware", "SessionUser", "get_client_ip", "get_optional_session", diff --git a/fastapi_cachex/session/dependencies.py b/fastapi_cachex/session/dependencies.py index 7199076..c057b11 100644 --- a/fastapi_cachex/session/dependencies.py +++ b/fastapi_cachex/session/dependencies.py @@ -213,8 +213,7 @@ async def rotate_session_id(request: Request) -> bool: ``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), or - under the deprecated ``SessionMiddleware``. + 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 @@ -322,10 +321,9 @@ async def log_in(credentials: Credentials, request: Request): Raises: RuntimeError: If the request did not pass through - ``FastAPICacheXSessionMiddleware``. The deprecated - ``SessionMiddleware`` cannot send a token for a session it did not - load; there, create the session with - ``SessionManager.create_session(user=...)`` and return its token. + ``FastAPICacheXSessionMiddleware``. Without it, create the + session with ``SessionManager.create_session(user=...)`` and + return its token. """ request_session = request.scope.get("session") if ( diff --git a/fastapi_cachex/session/middleware.py b/fastapi_cachex/session/middleware.py index 3c32131..a335848 100644 --- a/fastapi_cachex/session/middleware.py +++ b/fastapi_cachex/session/middleware.py @@ -5,11 +5,7 @@ from typing import TYPE_CHECKING from typing import Any -from fastapi import Request -from fastapi import Response from starlette.datastructures import MutableHeaders -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.middleware.base import RequestResponseEndpoint from starlette.middleware.sessions import Session as StarletteSession from starlette.requests import HTTPConnection from starlette.types import ASGIApp @@ -85,26 +81,6 @@ def get_client_ip(connection: HTTPConnection, config: SessionConfig) -> str | No return peer -def _extract_header_token( - connection: HTTPConnection, config: SessionConfig -) -> str | None: - """Extract a session token from request headers. - - Honours ``SessionConfig.token_source_priority``: checks the configured - custom header (``config.header_name``) and/or an ``Authorization: Bearer`` - token. This is the header/bearer transport shared with ``SessionMiddleware``. - - Args: - connection: Incoming HTTP connection (or a `Request`, which IS-A - `HTTPConnection`) - config: Session configuration - - Returns: - Session token or None - """ - return _read_header_token(connection, config)[0] - - def _read_header_token( connection: HTTPConnection, config: SessionConfig ) -> tuple[str | None, list[str]]: @@ -249,146 +225,6 @@ def _warn_if_priority_without_cookie(config: SessionConfig) -> None: ) -class SessionMiddleware(BaseHTTPMiddleware): - """Middleware to handle session loading and token extraction. - - Extracts the session token from the request (via a custom header and/or - an ``Authorization: Bearer`` header, per ``SessionConfig.token_source_priority``) - and loads the corresponding session into ``request.state``. Cookie-based - token transport is not supported, so a ``"cookie"`` entry in the list is - ignored. - - .. deprecated:: 0.3.1 - Use :class:`FastAPICacheXSessionMiddleware` instead. Will be removed in - version 0.4.0. - """ - - def __init__( - self, - app: ASGIApp, - session_manager: SessionManager | None = None, - config: SessionConfig | None = None, - ) -> None: - """Initialize 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. - - Warns: - DeprecationWarning: Always; use ``FastAPICacheXSessionMiddleware``. - """ - warnings.warn( - "SessionMiddleware is deprecated, use FastAPICacheXSessionMiddleware. " - "Will be removed in version 0.4.0.", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(app) - self.session_manager = session_manager or SessionManagerProxy.get() - - if config is None: - config = self.session_manager.config - - self.config = config - - logger.debug( - "SessionMiddleware initialized; header=%s bearer=%s", - config.header_name, - config.use_bearer_token, - ) - - async def dispatch( - self, - request: Request, - call_next: RequestResponseEndpoint, - ) -> Response: - """Process request and handle session. - - Args: - request: Incoming request - call_next: Next handler in chain - - Returns: - Response - """ - _stash_session_manager(request.app, self.session_manager) - - # Extract session token from request - token = self._extract_token(request) - - # Try to load session - session: Session | None = None - renewed_token: str | None = None - if token: - try: - ip_address = self._get_client_ip(request) - user_agent = request.headers.get("user-agent") - session, renewed_token = await self.session_manager.get_session( - token, - ip_address=ip_address, - user_agent=user_agent, - ) - logger.debug("Session loaded in middleware; id=%s", session.session_id) - except SessionError: - # Session invalid/expired, continue without session - session = None - logger.debug("Session failed to load; token invalid/expired") - - # Store session in request state - setattr(request.state, "__fastapi_cachex_session", session) - - loaded_session_id = session.session_id if session is not None else None - - # Process request - response: Response = await call_next(request) - - if session is not None and session.session_id != loaded_session_id: - # The handler regenerated the session ID; a renewed token would - # name the deleted record, so send a token for the new ID. - response_token: str | None = self.session_manager.issue_token(session) - else: - # Propagate renewed token to client so its JWT exp stays in sync - response_token = renewed_token - - if response_token is not None or _session_was_read(request): - add_vary(response.headers, _read_header_token(request, self.config)[1]) - if response_token is not None: - response.headers[self.config.header_name] = response_token - _forbid_storing(response.headers) - - return response - - def _extract_token(self, request: Request) -> str | None: - """Extract session token from request. - - Args: - request: Incoming request - - Returns: - Session token or None - """ - return _extract_header_token(request, self.config) - - def _get_client_ip(self, request: Request) -> str | None: - """Get client IP address from request. - - Args: - request: Incoming request - - Returns: - Client IP address or None - """ - return get_client_ip(request, self.config) - - class _RequestSession(StarletteSession): """``request.session`` that remembers an explicit ``clear()``. @@ -489,8 +325,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: loaded_session_id: str | None = None # Resolve the incoming session token: prefer the header/bearer transport - # (e.g. X-Session-Token, as used by SessionMiddleware) and fall back to - # the session cookie, so header-based clients authenticate here too. + # (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). diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 3c7d6be..3d18031 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -87,7 +87,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`(或已棄用的 `SessionMiddleware`)為它載入了 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」是指 `FastAPICacheXSessionMiddleware` 為它載入了 Session(權杖來自標頭、Bearer 權杖或 Session Cookie 皆可,有沒有使用者都算),或在任何 Session 中介軟體(包括 Starlette 的)下 `request.session` 不是空的。解析不出 Session 的權杖(偽造、過期)不算,因此無法用來略過快取。0.3.9 以前只有 `Authorization` 會觸發繞過,讀取 Session 的路由只加上 `@cache` 時,會把一位訪客的回應提供給下一位(#319)。會讀取後端的路由第一次繞過時,會以 `WARNING` 等級記錄(見[帶有憑證的請求](#requests-with-credentials))。 - **handler 自己的 `Cache-Control` 含有 `private` 或 `no-store`**(完整指令,不分大小寫)。回應照常送出但不儲存,而且 handler 的標頭會原樣送出,不會被裝飾器的標頭取代。 - **回應設定了 cookie。** 回應照常送出(包含 `Set-Cookie`),但不儲存;它(以及 304)會以 `private` 取代 `public` 送出並保留其他指令,讓下游的共用快取也不會儲存它。 diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index de8d9bf..79fee48 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -2,16 +2,15 @@ FastAPI-CacheX 的 Session 管理提供完整的使用者 Session 處理,包括簽署過的權杖、滑動過期,以及可選的 IP / User-Agent 綁定。Session 內容一律存放在快取後端;用戶端只持有一個簽署過的權杖。 -**權杖如何傳遞,取決於你安裝的是哪一個中介軟體:** +**`FastAPICacheXSessionMiddleware` 如何傳遞權杖:** -| 中介軟體 | 權杖來源 | 回應端 | 狀態 | -|------------|--------------|---------------|--------| -| `FastAPICacheXSessionMiddleware` | 自訂標頭(預設 `X-Session-Token`)/`Authorization: Bearer`/**Cookie**(預設名稱 `session`) | 依來源決定:送出標頭或 Bearer 權杖的請求(即使該權杖已無法解析)會在回應標頭中收到權杖;其他情況(Cookie,或完全沒有權杖)則使用 `Set-Cookie` | **建議使用** | -| `SessionMiddleware` | 自訂標頭/`Authorization: Bearer`;**不支援 Cookie** | 更新後的權杖,或重新產生 ID 後的權杖,會在回應標頭中傳回 | 已棄用,**將於 0.4.0 移除** | +| 權杖來源 | 回應端 | +|--------------|---------------| +| 自訂標頭(預設 `X-Session-Token`)/`Authorization: Bearer`/**Cookie**(預設名稱 `session`) | 依來源決定:送出標頭或 Bearer 權杖的請求(即使該權杖已無法解析)會在回應標頭中收到權杖;其他情況(Cookie,或完全沒有權杖)則使用 `Set-Cookie` | -**所有新專案請使用 `FastAPICacheXSessionMiddleware`。** 它涵蓋 `SessionMiddleware` 的所有傳輸方式(以相同方式讀取 `X-Session-Token` 與 `Authorization: Bearer`),並加入 Cookie 支援。自 0.3.1 起,`SessionMiddleware` 在建構時會發出 `DeprecationWarning`,並將於 **0.4.0 移除**。兩個中介軟體提供給相同的 Session 依賴項(`get_session`、`get_optional_session`、`require_session`),因此遷移通常只需要修改 `add_middleware` 那一行;以標頭傳送權杖的既有用戶端不需要任何修改。 +只支援標頭的 `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` 會讀取**;安裝的是 `SessionMiddleware` 時,設定它們不會有任何效果。 +`SessionConfig` 的六個 `cookie_*` 設定(`cookie_name`、`cookie_max_age`、`cookie_path`、`cookie_same_site`、`cookie_https_only`、`cookie_domain`)**只有 `FastAPICacheXSessionMiddleware` 會讀取**;不透過它而直接使用 `SessionManager` 時,設定它們不會有任何效果。 完整可執行範例(英文):[`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)。 @@ -90,12 +89,12 @@ app.add_middleware(FastAPICacheXSessionMiddleware) # 從 proxy 取得 ## 遷移:SessionMiddleware → FastAPICacheXSessionMiddleware {#migration-sessionmiddleware-fastapicachexsessionmiddleware} -`SessionMiddleware` 自 0.3.1 起已棄用(建構時會發出 `DeprecationWarning`),並將於 0.4.0 移除。請改用 `FastAPICacheXSessionMiddleware`: +`SessionMiddleware` 自 0.3.1 起已棄用,已於 0.4.0 移除。請改用 `FastAPICacheXSessionMiddleware`: -- **`SessionMiddleware`**(一個 `BaseHTTPMiddleware`):以自訂標頭(預設 `X-Session-Token`)和/或 `Authorization: Bearer` 傳遞權杖,適合由用戶端管理權杖的 API 優先架構。不支援以 Cookie 傳輸。 +- **`SessionMiddleware`**(一個 `BaseHTTPMiddleware`,已移除):以自訂標頭(預設 `X-Session-Token`)和/或 `Authorization: Bearer` 傳遞權杖,適合由用戶端管理權杖的 API 優先架構。不支援以 Cookie 傳輸。 - **`FastAPICacheXSessionMiddleware`**(一個純 ASGI 中介軟體):與 Starlette 內建的 `SessionMiddleware` 相容,提供相同的類 dict `request.session`。它以 Cookie(預設 Cookie 名稱 `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`。 -兩個中介軟體都會將載入的 `Session` 物件放進 `request.state`,因此既有的 Session 依賴項 `get_session`、`get_optional_session`、`require_session` 與 `require_user_session` 在任一個中介軟體下都能直接運作,不需任何修改: +`FastAPICacheXSessionMiddleware` 和 `SessionMiddleware` 一樣會將載入的 `Session` 物件放進 `request.state`,因此 Session 依賴項 `get_session`、`get_optional_session`、`require_session` 與 `require_user_session` 都能直接運作,不需任何修改: ```python from fastapi import Depends @@ -121,7 +120,7 @@ async def me(session=Depends(require_user_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)` 的路由),並且即使處理函式沒有碰過 `request.session`,也會加入與上一項相同的 `Vary` 名稱。否則 CDN 或反向 proxy 可能存下權杖,再交給下一位訪客。不帶權杖的回應則維持原本的標頭。已棄用的 `SessionMiddleware` 在回應標頭送出權杖時也會這麼做。 +- 帶有 Session 權杖的回應(新建立的 Session、滑動續期、重新產生的 ID),或帶有讓 Session Cookie 失效之 `Set-Cookie` 的回應,一律不可快取。中介軟體會設定 `Cache-Control: private, no-store`,取代路由原本設定的值(包括 `@cache(public=True)` 的路由),並且即使處理函式沒有碰過 `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`)。 @@ -186,7 +185,7 @@ Session 會在 `session_ttl` 秒後過期。啟用 `sliding_expiration` 時, `FastAPICacheXSessionMiddleware` 先依照 `token_source_priority` 的順序讀取標頭來源,只有它們都沒有產生權杖時才退回使用 Cookie。回應端依權杖的來源決定(標頭進、標頭出;Cookie 進、`Set-Cookie` 出)。 -在 0.4.0 以前,不論清單是否列出 Cookie,都會讀取 Cookie。`"cookie"` 只能放在清單的最後一項,也就是它現在本來就被讀取的位置,因此列出它目前不會改變任何行為;放在其他位置會引發 `ValidationError`。已棄用的 `SessionMiddleware` 從不讀取 Cookie,會忽略這一項。 +在 0.4.0 以前,不論清單是否列出 Cookie,都會讀取 Cookie。`"cookie"` 只能放在清單的最後一項,也就是它現在本來就被讀取的位置,因此列出它目前不會改變任何行為;放在其他位置會引發 `ValidationError`。 0.4.0 起,這個清單列出所有權杖來源,預設值改為 `["header", "bearer", "cookie"]`,也就是目前使用的順序。沒有 `"cookie"` 的清單代表完全不使用 Cookie:中介軟體既不讀取也不設定它,而為沒有權杖的請求建立的 Session 會在 `header_name` 回應標頭中送出權杖([#75](https://github.com/allen0099/FastAPI-CacheX/issues/75))。因此,明確設定了不含 `"cookie"` 的清單時,`FastAPICacheXSessionMiddleware` 會發出 `FutureWarning`。要保留 Cookie,請把 `"cookie"` 加在最後一項;預設清單不會發出警告。見[遷移至 0.4.0](MIGRATING_0_4.md#token-source-priority)。 @@ -348,7 +347,7 @@ async def log_in(credentials: Credentials, request: Request): 完全沒有帶權杖的請求只會收到 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)。 -在同一個請求中,`login()` 之後呼叫 `request.session.clear()` 就是登出:新的 Session 會被刪除,也不會送出權杖(Cookie 用戶端的 Cookie 會被設為過期)。在 `login()` 之前呼叫 `clear()` 會讓已載入的 Session 登出,`login()` 接著會建立新的 Session,而不是為它換 ID。沒有 `FastAPICacheXSessionMiddleware` 時,`login()` 會拋出 `RuntimeError`:已棄用的 `SessionMiddleware` 無法為不是由它載入的 Session 送出權杖,因此在那裡請以 `create_session(user=...)` 建立 Session 並回傳其權杖。 +在同一個請求中,`login()` 之後呼叫 `request.session.clear()` 就是登出:新的 Session 會被刪除,也不會送出權杖(Cookie 用戶端的 Cookie 會被設為過期)。在 `login()` 之前呼叫 `clear()` 會讓已載入的 Session 登出,`login()` 接著會建立新的 Session,而不是為它換 ID。沒有 `FastAPICacheXSessionMiddleware` 時,`login()` 會拋出 `RuntimeError`,因為沒有人會送出權杖;請改以 `create_session(user=...)` 建立 Session 並回傳其權杖。 `request.session["user_id"] = "123"` 不是登入。它是應用程式資料,`require_user_session` 與 `AuthenticatedSession` 不會認得它,而且它會沿用請求帶來的 Session ID。 diff --git a/tests/session/test_cache_authorized_private.py b/tests/session/test_cache_authorized_private.py index 0b44f7e..200e220 100644 --- a/tests/session/test_cache_authorized_private.py +++ b/tests/session/test_cache_authorized_private.py @@ -20,7 +20,6 @@ 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.middleware import SessionMiddleware from fastapi_cachex.session.models import SessionUser _AUTH = {"Authorization": "Bearer alice"} @@ -182,29 +181,3 @@ async def test_routes_that_do_not_read_the_session_do_not_vary( ) assert "vary" not in response.headers - - -async def test_deprecated_middleware_varies_on_a_session_read( - manager: SessionManager, config: SessionConfig -) -> None: - app = FastAPI() - app.add_middleware(SessionMiddleware, session_manager=manager, config=config) - - @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} - - client = TestClient(app) - token = await _token(manager, "alice") - with pytest.warns(DeprecationWarning, match="FastAPICacheXSessionMiddleware"): - response = client.get("/greeting", headers={config.header_name: token}) - - assert response.json() == {"hello": "alice"} - assert config.header_name.lower() in _vary(response) - assert ( - "vary" not in client.get("/plain", headers={config.header_name: token}).headers - ) diff --git a/tests/session/test_dependencies.py b/tests/session/test_dependencies.py index bc008c3..4b0477c 100644 --- a/tests/session/test_dependencies.py +++ b/tests/session/test_dependencies.py @@ -13,7 +13,7 @@ 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 SessionMiddleware +from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware from fastapi_cachex.session.models import SessionUser @@ -65,18 +65,17 @@ class TestRequireSessionAlias: def test_require_session_is_get_session_alias(self) -> None: assert require_session is get_session - @pytest.mark.filterwarnings( - "ignore:SessionMiddleware is deprecated:DeprecationWarning" - ) 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) + 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( - SessionMiddleware, session_manager=manager, config=config + FastAPICacheXSessionMiddleware, session_manager=manager, config=config ) @dep_app.get("/protected") @@ -89,18 +88,17 @@ async def protected(session=Depends(require_session)): r = dep_client.get("/protected") assert r.status_code == 401 - @pytest.mark.filterwarnings( - "ignore:SessionMiddleware is deprecated:DeprecationWarning" - ) 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) + 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( - SessionMiddleware, session_manager=manager, config=config + FastAPICacheXSessionMiddleware, session_manager=manager, config=config ) @dep_app.get("/me") diff --git a/tests/session/test_get_session_manager.py b/tests/session/test_get_session_manager.py index 43c3432..92164b9 100644 --- a/tests/session/test_get_session_manager.py +++ b/tests/session/test_get_session_manager.py @@ -8,15 +8,14 @@ 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 SessionMiddleware from fastapi_cachex.session import SessionUser from fastapi_cachex.session import get_session_manager from fastapi_cachex.session.proxy import SessionManagerProxy -@pytest.mark.filterwarnings("ignore:SessionMiddleware is deprecated:DeprecationWarning") def test_get_session_manager_dependency( manager: SessionManager, config: SessionConfig ) -> None: @@ -25,7 +24,9 @@ def test_get_session_manager_dependency( SessionManagerProxy.set(manager) # Add middleware which stores manager in app.state - app.add_middleware(SessionMiddleware, session_manager=manager, config=config) + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) # Create endpoint that uses get_session_manager @app.get("/test") @@ -41,7 +42,6 @@ async def test_endpoint( assert response.json()["has_manager"] is True -@pytest.mark.filterwarnings("ignore:SessionMiddleware is deprecated:DeprecationWarning") async def test_get_session_manager_allows_create_session( manager: SessionManager, config: SessionConfig ) -> None: @@ -50,7 +50,9 @@ async def test_get_session_manager_allows_create_session( SessionManagerProxy.set(manager) # Add middleware - app.add_middleware(SessionMiddleware, session_manager=manager, config=config) + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) # Create login endpoint @app.post("/login") @@ -92,14 +94,15 @@ async def test_endpoint( assert "SessionManager not initialized" in response.json()["detail"] -@pytest.mark.filterwarnings("ignore:SessionMiddleware is deprecated:DeprecationWarning") 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(SessionMiddleware, session_manager=manager, config=config) + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) @app.post("/login") async def login( @@ -134,7 +137,6 @@ async def logout( assert logout_response.json()["message"] == "logged out" -@pytest.mark.filterwarnings("ignore:SessionMiddleware is deprecated:DeprecationWarning") def test_session_manager_type_annotation( manager: SessionManager, config: SessionConfig ) -> None: @@ -143,7 +145,9 @@ def test_session_manager_type_annotation( app = FastAPI() SessionManagerProxy.set(manager) - app.add_middleware(SessionMiddleware, session_manager=manager, config=config) + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) @app.get("/test") async def test_endpoint(session_manager: SessionManagerDep): diff --git a/tests/session/test_login.py b/tests/session/test_login.py index bb4e899..1b5906a 100644 --- a/tests/session/test_login.py +++ b/tests/session/test_login.py @@ -15,7 +15,6 @@ 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.middleware import SessionMiddleware from fastapi_cachex.session.models import SessionUser TRANSPORTS = ["cookie", "header", "bearer"] @@ -330,24 +329,6 @@ async def test_login_outside_the_middleware_raises() -> None: await login(request, SessionUser(user_id="alice")) -def test_login_under_the_deprecated_middleware_raises( - manager: SessionManager, config: SessionConfig -) -> None: - """The header-only SessionMiddleware cannot send a session it did not load.""" - app = FastAPI() - app.add_middleware(SessionMiddleware, session_manager=manager, config=config) - - @app.post("/login") - async def log_in(request: Request) -> None: - await login(request, SessionUser(user_id="alice")) - - with ( - pytest.warns(DeprecationWarning, match="SessionMiddleware is deprecated"), - pytest.raises(RuntimeError, match="FastAPICacheXSessionMiddleware"), - ): - TestClient(app).post("/login", headers={config.header_name: "stale"}) - - async def test_login_as_another_user_starts_a_clean_session( manager: SessionManager, config: SessionConfig, backend: MemoryBackend ) -> None: diff --git a/tests/session/test_lookup_writes.py b/tests/session/test_lookup_writes.py index 2c402ef..57fad9d 100644 --- a/tests/session/test_lookup_writes.py +++ b/tests/session/test_lookup_writes.py @@ -8,20 +8,16 @@ from datetime import datetime from datetime import timedelta from datetime import timezone -from typing import Annotated 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 require_session from fastapi_cachex.session.manager import SessionManager from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware -from fastapi_cachex.session.middleware import SessionMiddleware from fastapi_cachex.session.models import Session from fastapi_cachex.session.models import SessionUser from fastapi_cachex.types import CacheEntry @@ -138,25 +134,3 @@ async def bump(request: Request) -> dict[str, object]: assert client.get("/bump").json() == {"count": 2} assert len(backend.writes) == 1 assert client.get("/read").json() == {"count": 2} - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -async def test_header_middleware_lookup_does_not_write() -> None: - """The deprecated header middleware loads sessions the same way.""" - manager, backend = _manager() - app = FastAPI() - app.add_middleware(SessionMiddleware, session_manager=manager) - - @app.get("/me") - async def me( - session: Annotated[Session, Depends(require_session)], - ) -> dict[str, object]: - return {"user": session.user.user_id if session.user else None} - - _, token = await manager.create_session(SessionUser(user_id="u1")) - backend.writes.clear() - - response = TestClient(app).get("/me", headers={"X-Session-Token": token}) - - assert response.json() == {"user": "u1"} - assert backend.writes == [] diff --git a/tests/session/test_middleware.py b/tests/session/test_middleware.py index 80ca56e..c5bb2f1 100644 --- a/tests/session/test_middleware.py +++ b/tests/session/test_middleware.py @@ -1,288 +1,11 @@ -"""Tests for session middleware and token extraction.""" - -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from typing import Annotated +"""Tests for client address resolution and header token extraction.""" 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 get_optional_session -from fastapi_cachex.session.dependencies import rotate_session_id -from fastapi_cachex.session.manager import SessionManager -from fastapi_cachex.session.middleware import SessionMiddleware -from fastapi_cachex.session.middleware import _extract_header_token +from fastapi_cachex.session.middleware import _read_header_token from fastapi_cachex.session.middleware import get_client_ip -from fastapi_cachex.session.models import Session -from fastapi_cachex.session.models import SessionUser -from fastapi_cachex.session.proxy import SessionManagerProxy - -# The deprecated `SessionMiddleware` is exercised the way an application uses -# it: installed with `add_middleware`, reached over HTTP, and observed through -# `get_optional_session` and the response headers. - -_DEPRECATION = "SessionMiddleware is deprecated" - - -def _client( - manager: SessionManager | None, - config: SessionConfig | None = None, - *, - peer: str = "testclient", -) -> TestClient: - """A client for an app behind `SessionMiddleware`, its stack already built. - - Starlette constructs middleware on the first request, so the warm-up - request below is where the `DeprecationWarning` is raised and expected. - `/whoami` reports the session the middleware loaded; `/rotate` gives it a - new ID, as a login handler would. - """ - app = FastAPI() - app.add_middleware(SessionMiddleware, session_manager=manager, config=config) - - @app.get("/whoami") - async def whoami( - session: Annotated[Session | None, Depends(get_optional_session)], - ) -> dict[str, str | None]: - if session is None: - return {"session_id": None, "user": None} - return { - "session_id": session.session_id, - "user": session.user.user_id if session.user else None, - } - - @app.post("/rotate") - async def rotate(request: Request) -> dict[str, bool]: - return {"rotated": await rotate_session_id(request)} - - client = TestClient(app, client=(peer, 50000)) - with pytest.warns(DeprecationWarning, match=_DEPRECATION): - client.get("/whoami") - return client - - -def test_construction_warns_and_points_to_the_replacement( - manager: SessionManager, -) -> None: - app = FastAPI() - app.add_middleware(SessionMiddleware, session_manager=manager) - - with pytest.warns(DeprecationWarning, match="FastAPICacheXSessionMiddleware"): - TestClient(app).get("/") - - -async def test_a_header_token_loads_the_session( - manager: SessionManager, config: SessionConfig -) -> None: - session, token = await manager.create_session(user=SessionUser(user_id="u1")) - client = _client(manager, config) - - response = client.get("/whoami", headers={config.header_name: token}) - - assert response.json() == {"session_id": session.session_id, "user": "u1"} - assert config.header_name not in response.headers - - -async def test_a_bearer_token_loads_the_session( - manager: SessionManager, config: SessionConfig -) -> None: - session, token = await manager.create_session(user=SessionUser(user_id="u1")) - client = _client(manager, config) - - response = client.get("/whoami", headers={"Authorization": f"Bearer {token}"}) - - assert response.json() == {"session_id": session.session_id, "user": "u1"} - - -@pytest.mark.parametrize( - "headers", - [ - {}, - {"X-Session-Token": "invalid-token"}, - {"Authorization": "Bearer"}, - {"Authorization": "Bearer invalid-token"}, - ], - ids=["no-token", "invalid-header", "empty-bearer", "invalid-bearer"], -) -def test_a_missing_or_invalid_token_loads_no_session( - manager: SessionManager, config: SessionConfig, headers: dict[str, str] -) -> None: - """The request still reaches the handler, without a session.""" - client = _client(manager, config) - - response = client.get("/whoami", headers=headers) - - assert response.status_code == 200 - assert response.json() == {"session_id": None, "user": None} - assert config.header_name not in response.headers - - -async def test_an_expired_session_is_not_loaded( - manager: SessionManager, config: SessionConfig -) -> None: - session, token = await manager.create_session(user=SessionUser(user_id="u1")) - session.expires_at = datetime.now(timezone.utc) - timedelta(seconds=10) - await manager._save_session(session) - client = _client(manager, config) - - response = client.get("/whoami", headers={config.header_name: token}) - - assert response.status_code == 200 - assert response.json() == {"session_id": None, "user": None} - - -async def test_config_defaults_to_the_managers() -> None: - config = SessionConfig(secret_key="a" * 32, header_name="X-Custom-Session") - manager = SessionManager(MemoryBackend(), config) - _session, token = await manager.create_session(user=SessionUser(user_id="u1")) - client = _client(manager) - - response = client.get("/whoami", headers={"X-Custom-Session": token}) - - assert response.json()["user"] == "u1" - - -async def test_an_explicit_config_overrides_the_managers( - manager: SessionManager, -) -> None: - override = SessionConfig(secret_key="a" * 32, header_name="X-Other-Session") - _session, token = await manager.create_session(user=SessionUser(user_id="u1")) - client = _client(manager, override) - - assert ( - client.get("/whoami", headers={"X-Other-Session": token}).json()["user"] == "u1" - ) - assert ( - client.get("/whoami", headers={"X-Session-Token": token}).json()["user"] is None - ) - - -async def test_the_manager_defaults_to_the_proxy( - manager: SessionManager, config: SessionConfig -) -> None: - SessionManagerProxy.set(manager) - _session, token = await manager.create_session(user=SessionUser(user_id="u1")) - client = _client(None) - - response = client.get("/whoami", headers={config.header_name: token}) - - assert response.json()["user"] == "u1" - - -@pytest.mark.parametrize( - ("peer", "loaded"), [("203.0.113.7", True), ("198.51.100.1", False)] -) -async def test_ip_binding_checks_the_peer_address( - config: SessionConfig, peer: str, loaded: bool -) -> None: - config.ip_binding = True - manager = SessionManager(MemoryBackend(), config) - _session, token = await manager.create_session( - user=SessionUser(user_id="u1"), ip_address="203.0.113.7" - ) - client = _client(manager, config, peer=peer) - - response = client.get("/whoami", headers={config.header_name: token}) - - assert (response.json()["user"] == "u1") is loaded - - -@pytest.mark.parametrize( - ("forwarded_for", "loaded"), [("203.0.113.7", True), ("198.51.100.1", False)] -) -async def test_ip_binding_uses_the_forwarded_address_behind_a_trusted_proxy( - forwarded_for: str, loaded: bool -) -> None: - config = SessionConfig( - secret_key="a" * 32, ip_binding=True, trusted_proxies=["10.0.0.9"] - ) - manager = SessionManager(MemoryBackend(), config) - _session, token = await manager.create_session( - user=SessionUser(user_id="u1"), ip_address="203.0.113.7" - ) - client = _client(manager, config, peer="10.0.0.9") - - response = client.get( - "/whoami", - headers={config.header_name: token, "X-Forwarded-For": forwarded_for}, - ) - - assert (response.json()["user"] == "u1") is loaded - - -@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: - config.user_agent_binding = True - manager = SessionManager(MemoryBackend(), config) - _session, token = await manager.create_session( - user=SessionUser(user_id="u1"), user_agent="App/1.0" - ) - client = _client(manager, config) - - response = client.get( - "/whoami", headers={config.header_name: token, "User-Agent": user_agent} - ) - - assert (response.json()["user"] == "u1") is loaded - - -async def test_a_rotated_session_id_is_sent_back_as_a_new_token( - manager: SessionManager, config: SessionConfig -) -> None: - SessionManagerProxy.set(manager) - session, token = await manager.create_session(user=SessionUser(user_id="u1")) - client = _client(manager, config) - - response = client.post("/rotate", headers={config.header_name: token}) - - assert response.json() == {"rotated": True} - new_token = response.headers[config.header_name] - rotated = client.get("/whoami", headers={config.header_name: new_token}).json() - assert rotated["user"] == "u1" - assert rotated["session_id"] != session.session_id - old = client.get("/whoami", headers={config.header_name: token}).json() - assert old["user"] is None - - -async def test_sliding_expiration_sends_the_renewed_token() -> None: - """The refreshed token goes back in the response header, with a later expiry.""" - slide_config = SessionConfig( - secret_key="a" * 32, - session_ttl=3600, - sliding_expiration=True, - sliding_threshold=0.5, - ) - manager = SessionManager(MemoryBackend(), slide_config) - created, original_token = await manager.create_session( - user=SessionUser(user_id="slide-user") - ) - - # Shorten expiry so time_remaining < sliding threshold (< 50% of 3600 s) - shortened_expiry = datetime.now(timezone.utc) + timedelta(seconds=1000) - created.expires_at = shortened_expiry - await manager._save_session(created) - client = _client(manager, slide_config) - - response = client.get("/whoami", headers={slide_config.header_name: original_token}) - - assert response.json()["user"] == "slide-user" - renewed = response.headers.get(slide_config.header_name) - assert renewed is not None - - renewed_session, _ = await manager.get_session(renewed) - assert renewed_session.expires_at is not None - assert renewed_session.expires_at > shortened_expiry - # `get_client_ip` is the address resolution the middleware binds sessions to. @@ -374,9 +97,9 @@ def test_bearer_is_used_when_the_header_source_finds_nothing( loop always returned on its first pass and an implementation that only ever checked `token_source_priority[0]` would have passed them all. """ - token = _extract_header_token( + token = _read_header_token( _connection({"Authorization": "Bearer from-bearer"}), config - ) + )[0] assert token == "from-bearer" @@ -396,7 +119,7 @@ def test_bearer_scheme_is_matched_case_insensitively( """Auth schemes are case-insensitive (RFC 9110 §11.1), and RFC 6750 allows more than one space before the token (#166). """ - token = _extract_header_token(_connection({"Authorization": authorization}), config) + token = _read_header_token(_connection({"Authorization": authorization}), config)[0] assert token == "from-bearer" @@ -408,7 +131,7 @@ def test_bearer_scheme_is_matched_case_insensitively( def test_an_empty_or_non_bearer_authorization_header_yields_no_token( config: SessionConfig, authorization: str ) -> None: - token = _extract_header_token(_connection({"Authorization": authorization}), config) + token = _read_header_token(_connection({"Authorization": authorization}), config)[0] assert token is None @@ -417,7 +140,7 @@ def test_header_wins_over_bearer_when_both_are_present( config: SessionConfig, ) -> None: """Order in the list is the order that is honoured.""" - token = _extract_header_token( + token = _read_header_token( _connection( { config.header_name: "from-header", @@ -425,7 +148,7 @@ def test_header_wins_over_bearer_when_both_are_present( } ), config, - ) + )[0] assert token == "from-header" @@ -435,9 +158,9 @@ def test_bearer_source_is_skipped_when_bearer_tokens_are_disabled() -> None: with pytest.warns(DeprecationWarning, match="use_bearer_token"): config = SessionConfig(secret_key="a" * 32, use_bearer_token=False) - token = _extract_header_token( + token = _read_header_token( _connection({"Authorization": "Bearer from-bearer"}), config - ) + )[0] assert token is None @@ -456,9 +179,9 @@ def test_an_unknown_source_is_skipped_rather_than_read_as_a_bearer_token() -> No config = SessionConfig(secret_key="a" * 32) config.token_source_priority[:] = ["query"] # type: ignore[list-item] - token = _extract_header_token( + token = _read_header_token( _connection({"Authorization": "Bearer from-bearer"}), config - ) + )[0] assert token is None @@ -469,8 +192,8 @@ def test_a_known_source_after_an_unknown_one_is_still_honoured( """Falling through must continue the chain, not abandon it.""" config.token_source_priority[:] = ["query", "header"] # type: ignore[list-item] - token = _extract_header_token( + token = _read_header_token( _connection({config.header_name: "from-header"}), config - ) + )[0] assert token == "from-header" diff --git a/tests/session/test_starlette_middleware.py b/tests/session/test_starlette_middleware.py index ac4cf52..e6dc7b4 100644 --- a/tests/session/test_starlette_middleware.py +++ b/tests/session/test_starlette_middleware.py @@ -26,7 +26,6 @@ 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.middleware import SessionMiddleware from fastapi_cachex.session.models import SessionUser from fastapi_cachex.session.proxy import SessionManagerProxy @@ -666,18 +665,6 @@ async def set_route(request: Request) -> dict[str, bool]: assert created.data == {"k": "v"} -def test_session_middleware_construction_is_deprecated( - manager: SessionManager, config: SessionConfig -) -> None: - """SessionMiddleware is deprecated in favor of FastAPICacheXSessionMiddleware.""" - - async def app(scope, receive, send): - pass - - with pytest.warns(DeprecationWarning, match="FastAPICacheXSessionMiddleware"): - SessionMiddleware(app, manager, config) - - async def test_header_source_cleared_session_is_deleted_without_a_cookie( manager: SessionManager, config: SessionConfig ) -> None: @@ -715,11 +702,12 @@ def _regenerating_app( config: SessionConfig, *, write_data: bool, - middleware: Any = FastAPICacheXSessionMiddleware, ) -> FastAPI: """An app whose /login regenerates the request's session ID, as docs advise.""" app = FastAPI() - app.add_middleware(middleware, session_manager=manager, config=config) + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) @app.post("/login") async def login(request: Request, session=Depends(get_session)): @@ -793,30 +781,6 @@ async def test_regenerated_session_id_is_sent_in_the_header( await manager.get_session(old_token) -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -@pytest.mark.parametrize("sliding", [True, False]) -async def test_deprecated_middleware_sends_regenerated_token( - manager: SessionManager, config: SessionConfig, sliding: bool -) -> None: - """SessionMiddleware must not overwrite the new ID's token with a renewed old one.""" - _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=False, middleware=SessionMiddleware - ) - ) - - response = client.post("/login", headers={config.header_name: old_token}) - - assert response.status_code == 200 - new_token = response.headers[config.header_name] - 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) diff --git a/tests/session/test_token_response_caching.py b/tests/session/test_token_response_caching.py index 136bd8b..7f787cc 100644 --- a/tests/session/test_token_response_caching.py +++ b/tests/session/test_token_response_caching.py @@ -22,7 +22,6 @@ 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.middleware import SessionMiddleware from fastapi_cachex.session.models import SessionUser _NO_STORE = "private, no-store" @@ -59,11 +58,12 @@ def _vary(response: Any) -> list[str]: def _app( manager: SessionManager, config: SessionConfig, - middleware: Any = FastAPICacheXSessionMiddleware, ) -> FastAPI: """An app with a publicly cacheable route and routes that touch the session.""" app = FastAPI() - app.add_middleware(middleware, session_manager=manager, config=config) + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) @app.get("/public") @cache(ttl=60, public=True) @@ -274,37 +274,6 @@ async def test_an_emptied_anonymous_header_session_sends_nothing_to_forbid( assert "cache-control" not in response.headers -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -async def test_the_deprecated_middleware_renewal_is_not_storable( - 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, middleware=SessionMiddleware) - ) - - response = client.get("/public", headers={sliding_config.header_name: token}) - - assert sliding_config.header_name in response.headers - _assert_not_storable(response, {sliding_config.header_name.lower()}) - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -async def test_the_deprecated_middleware_leaves_a_tokenless_response_alone( - manager: SessionManager, config: SessionConfig -) -> None: - _session, token = await manager.create_session(user=SessionUser(user_id="u")) - client = TestClient(_app(manager, config, middleware=SessionMiddleware)) - - response = client.get("/public", headers={config.header_name: token}) - - assert config.header_name not in response.headers - assert response.headers["cache-control"] == _PUBLIC - assert "vary" not in response.headers - - async def test_a_response_without_a_token_keeps_its_cache_control( manager: SessionManager, config: SessionConfig ) -> None: From 10748b598dda5efec60f81b7dc953ad8b6465e73 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 14:57:27 +0000 Subject: [PATCH 2/2] test(session): port the user_agent_binding test to FastAPICacheXSessionMiddleware --- tests/session/test_starlette_middleware.py | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/session/test_starlette_middleware.py b/tests/session/test_starlette_middleware.py index e6dc7b4..ea7b020 100644 --- a/tests/session/test_starlette_middleware.py +++ b/tests/session/test_starlette_middleware.py @@ -400,6 +400,36 @@ async def test_route(request: Request) -> dict[str, bool]: 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