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..ea7b020 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 @@ -401,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 @@ -666,18 +695,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 +732,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 +811,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: