diff --git a/CLAUDE.md b/CLAUDE.md index a9ed035..739cc72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,8 @@ The library has four independent subsystems: - Token signing: `simple` format uses HMAC-SHA256 (`SecurityManager`); `jwt` format uses PyJWT (optional dependency `fastapi-cachex[jwt]`). - Session token is passed via custom header (`X-Session-Token` by default) or `Authorization: Bearer` token. - `SessionManagerProxy` mirrors the `BackendProxy` pattern for managing the `SessionManager` singleton. -- Key FastAPI dependencies: `get_session`, `require_session`, `get_optional_session` (in `session/dependencies.py`). +- Key FastAPI dependencies: `get_session`, `require_session`, `get_optional_session` (in `session/dependencies.py`). These accept anonymous sessions (`user=None`); `require_user_session` / `AuthenticatedSession` also require a user. `UserSessionDep` is still an alias of `SessionDep` until 0.4.0. +- `JWTTokenSerializer` emits one `UserWarning` at construction when `secret_key` is shorter (in UTF-8 bytes) than the HMAC hash output (48 for HS384, 64 for HS512). - `rotate_session_id(request)` (same module) regenerates the loaded session's ID at login against session fixation; a no-op when none was loaded. The middleware notices the changed ID and sends the new token. - `FastAPICacheXSessionMiddleware` wraps `request.session` in `_RequestSession`, which records an explicit `clear()`: that deletes the loaded session (logout) even with empty data, and later writes start a new anonymous one. Emptying via `del`/`pop()` keeps a user session (saved empty) and deletes an anonymous one. diff --git a/docs/SESSION.md b/docs/SESSION.md index 8427adf..09162e9 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -156,6 +156,24 @@ The session object the dependencies return is the backend `Session` model. A ses `FastAPICacheXSessionMiddleware` from `request.session` (see the Migration section below) is anonymous, so `session.user` is `None`. +`get_session` accepts such a session, so it only proves the request carries *a* session, not +that anyone logged in. Any visitor who reaches a route that writes to `request.session` (a cart, +a CSRF value) gets one. Guard routes that need a logged-in user with `require_user_session` (or +its annotated form `AuthenticatedSession`), which also answers `401` when `session.user` is +`None`: + +```python +from fastapi_cachex.session.dependencies import AuthenticatedSession + + +@app.get("/account") +async def account(session: AuthenticatedSession): + return {"user_id": session.user.user_id} +``` + +`UserSessionDep` does not check for a user despite its name; it is an alias of `SessionDep` +until 0.4.0, which is planned to make it require one. + ### 3. Full Example (Redis Backend) ```python @@ -487,6 +505,12 @@ config = SessionConfig( only supports `HS256`, `HS384` and `HS512`: with an asymmetric algorithm, `SessionManager` raises `ValueError` unless you pass a custom `token_serializer` that holds the key pair. +An HMAC key must be at least as long as the hash output (RFC 7518 §3.2): 32 bytes for `HS256`, +48 for `HS384` and 64 for `HS512`, counted after UTF-8 encoding. `secret_key` only has to be 32 +characters, so with `HS384` or `HS512` a shorter key makes the serializer emit a `UserWarning` +once when it is built (PyJWT itself also warns with `InsecureKeyLengthWarning` whenever it signs +or verifies a token). Use a longer key, for example `secrets.token_urlsafe(64)`. + Security notes: - The server keeps **stateful** sessions (the JWT is only a credential carrying the `sid`), so no @@ -513,7 +537,8 @@ secret_key = secrets.token_urlsafe(32) config = SessionConfig(secret_key=secret_key) ``` -`secret_key` is stored as a `SecretStr` and must be at least 32 characters long. Load it from the +`secret_key` is stored as a `SecretStr` and must be at least 32 characters long (at least 48 +bytes for `jwt_algorithm="HS384"` and 64 for `"HS512"`; see the JWT section above). Load it from the environment or a secret store rather than hard-coding it; changing it invalidates every token issued so far. @@ -676,6 +701,7 @@ from fastapi_cachex.session import ( get_session, # authentication required (401 when there is no session) get_optional_session, # optional authentication (None when there is no session) require_session, # alias of get_session + require_user_session, # 401 also when the session has no user get_session_manager, # the SessionManager registered by the middleware rotate_session_id, # not a dependency: await it at login for a new session ID ) @@ -685,6 +711,8 @@ from fastapi_cachex.session.dependencies import ( OptionalSession, # Session | None RequiredSession, # Session SessionDep, # Session + UserSessionDep, # Session; anonymous sessions pass too, see above + AuthenticatedSession, # Session with a user (require_user_session) SessionManagerDep, # SessionManager ) ``` diff --git a/fastapi_cachex/__init__.py b/fastapi_cachex/__init__.py index 5a27056..7c20d7c 100644 --- a/fastapi_cachex/__init__.py +++ b/fastapi_cachex/__init__.py @@ -32,6 +32,7 @@ from .session import get_session as get_session from .session import get_session_manager as get_session_manager from .session import require_session as require_session +from .session import require_user_session as require_user_session from .session.exceptions import SessionError as SessionError from .session.exceptions import SessionExpiredError as SessionExpiredError from .session.exceptions import SessionInvalidError as SessionInvalidError @@ -116,4 +117,5 @@ def _read_version() -> str: "get_state_manager", "invalidate", "require_session", + "require_user_session", ] diff --git a/fastapi_cachex/session/__init__.py b/fastapi_cachex/session/__init__.py index ad14d51..26ba4d0 100644 --- a/fastapi_cachex/session/__init__.py +++ b/fastapi_cachex/session/__init__.py @@ -6,6 +6,7 @@ from .dependencies import get_session_client_ip from .dependencies import get_session_manager from .dependencies import require_session +from .dependencies import require_user_session from .dependencies import rotate_session_id from .manager import SessionManager from .middleware import FastAPICacheXSessionMiddleware @@ -29,5 +30,6 @@ "get_session_client_ip", "get_session_manager", "require_session", + "require_user_session", "rotate_session_id", ] diff --git a/fastapi_cachex/session/dependencies.py b/fastapi_cachex/session/dependencies.py index 468520a..352343f 100644 --- a/fastapi_cachex/session/dependencies.py +++ b/fastapi_cachex/session/dependencies.py @@ -77,6 +77,33 @@ def get_session( return session +def require_user_session(session: Session = Depends(get_session)) -> Session: + """Get the request's session, requiring a logged-in user. + + ``get_session`` only checks that a session exists. Under + ``FastAPICacheXSessionMiddleware`` any visitor who reaches a route that + writes to ``request.session`` (a cart, a CSRF value) gets an anonymous + session with ``user=None``, which ``get_session`` accepts. Use this + dependency to guard routes that need an authenticated user. + + Args: + session: The request's session, from ``get_session`` + + Returns: + Session object whose ``user`` is set + + Raises: + HTTPException: 401 if there is no session, or the session has no user + """ + if session.user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + return session + + def get_session_manager(request: Request) -> "SessionManager": """Get SessionManager instance from app state. @@ -205,6 +232,9 @@ async def login(manager: SessionManagerDep, client_ip: ClientIPDep): OptionalSession = Annotated[Session | None, Depends(get_optional_session)] RequiredSession = Annotated[Session, Depends(get_session)] SessionDep = Annotated[Session, Depends(get_session)] +# Despite its name, UserSessionDep accepts anonymous sessions too; making it +# require a user is a breaking change planned for 0.4.0. Use AuthenticatedSession. UserSessionDep = Annotated[Session, Depends(get_session)] +AuthenticatedSession = Annotated[Session, Depends(require_user_session)] SessionManagerDep = Annotated["SessionManager", Depends(get_session_manager)] ClientIPDep = Annotated[str | None, Depends(get_session_client_ip)] diff --git a/fastapi_cachex/session/token_serializers.py b/fastapi_cachex/session/token_serializers.py index d32a6b6..9479ff5 100644 --- a/fastapi_cachex/session/token_serializers.py +++ b/fastapi_cachex/session/token_serializers.py @@ -12,6 +12,7 @@ import importlib import logging +import warnings from datetime import datetime from datetime import timezone from typing import TYPE_CHECKING @@ -29,6 +30,9 @@ # Token format constant - 3 parts: session_id, signature, timestamp TOKEN_PARTS_COUNT = 3 +# RFC 7518 section 3.2: an HMAC key must be at least as long as the hash output. +_HMAC_MIN_KEY_BYTES = {"HS256": 32, "HS384": 48, "HS512": 64} + class TokenSerializer(Protocol): """Protocol for token serialization strategies.""" @@ -91,6 +95,27 @@ def from_string(self, token_str: str) -> SessionToken: ) +def _warn_if_key_too_short(secret: str, algorithm: str) -> None: + """Warn once when ``secret`` is shorter than ``algorithm``'s hash output. + + ``SessionConfig`` only requires 32 characters, enough for HS256 but not + for HS384 (48 bytes) or HS512 (64 bytes). PyJWT warns about a short key on + every token it signs or verifies; this names the setting to fix, once, + when the serializer is built. + """ + min_bytes = _HMAC_MIN_KEY_BYTES[algorithm] + key_bytes = len(secret.encode("utf-8")) + if key_bytes < min_bytes: + warnings.warn( + f"secret_key is {key_bytes} bytes, shorter than the {min_bytes} " + f"bytes RFC 7518 section 3.2 requires for {algorithm}. Use a longer " + f"secret_key (e.g. secrets.token_urlsafe({min_bytes})) or " + f'jwt_algorithm="HS256".', + UserWarning, + stacklevel=3, + ) + + class JWTTokenSerializer: """JWT-based token serializer. @@ -139,6 +164,7 @@ def __init__(self, config: SessionConfig, jwt_module: Any | None = None) -> None # Copy required parameters self._secret = config.secret_key.get_secret_value() + _warn_if_key_too_short(self._secret, config.jwt_algorithm) self._algorithm = config.jwt_algorithm self._issuer = config.jwt_issuer self._audience = config.jwt_audience diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index fbdac9f..381d801 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -139,6 +139,19 @@ async def logout(session=Depends(get_session)): 依賴項回傳的 Session 物件是後端的 `Session` 模型。由 `FastAPICacheXSessionMiddleware` 從 `request.session` 建立的 Session(見下方的遷移一節)是匿名的,因此 `session.user` 為 `None`。 +`get_session` 也接受這種 Session,因此它只能證明請求帶著「某個」Session,而不能證明有人登入。任何訪客只要進入會寫入 `request.session` 的路由(購物車、CSRF 值),就會得到一個。需要已登入使用者的路由,請改用 `require_user_session`(或其型別註記形式 `AuthenticatedSession`)保護,它在 `session.user` 為 `None` 時同樣回應 `401`: + +```python +from fastapi_cachex.session.dependencies import AuthenticatedSession + + +@app.get("/account") +async def account(session: AuthenticatedSession): + return {"user_id": session.user.user_id} +``` + +`UserSessionDep` 雖然名稱如此,卻不會檢查使用者;在 0.4.0 之前它是 `SessionDep` 的別名,0.4.0 預計改為要求使用者。 + ### 3. 完整範例(Redis 後端) {#3-full-example-redis-backend} ```python @@ -408,6 +421,8 @@ config = SessionConfig( `jwt_algorithm` 必須是 `HS256`、`HS384`、`HS512`、`RS256`、`RS384`、`RS512`、`ES256`、`ES384`、`ES512`、`PS256`、`PS384`、`PS512` 或 `EdDSA` 其中之一;其他任何值(包括 `none`)都會拋出 `ValidationError`。內建的序列化器以同一把 `secret_key` 簽署與驗證,因此只支援 `HS256`、`HS384` 與 `HS512`:使用非對稱演算法時,除非你傳入持有金鑰對的自訂 `token_serializer`,否則 `SessionManager` 會拋出 `ValueError`。 +HMAC 金鑰的長度至少須等於雜湊輸出(RFC 7518 §3.2):`HS256` 為 32 位元組、`HS384` 為 48、`HS512` 為 64,以 UTF-8 編碼後計算。`secret_key` 只要求 32 個字元,因此搭配 `HS384` 或 `HS512` 時,較短的金鑰會讓序列化器在建立時發出一次 `UserWarning`(PyJWT 本身每次簽署或驗證權杖時也會發出 `InsecureKeyLengthWarning`)。請使用更長的金鑰,例如 `secrets.token_urlsafe(64)`。 + 安全性注意事項: - 伺服器保存的是**有狀態**的 Session(JWT 只是帶著 `sid` 的憑證),因此權杖中不需要放入任何敏感資料 @@ -429,7 +444,7 @@ secret_key = secrets.token_urlsafe(32) config = SessionConfig(secret_key=secret_key) ``` -`secret_key` 以 `SecretStr` 儲存,且長度至少須為 32 個字元。請從環境變數或密鑰儲存服務載入,而不要寫死在程式碼中;變更它會使至今發行的所有權杖失效。 +`secret_key` 以 `SecretStr` 儲存,且長度至少須為 32 個字元(`jwt_algorithm="HS384"` 時至少 48 位元組,`"HS512"` 時至少 64;見上方的 JWT 一節)。請從環境變數或密鑰儲存服務載入,而不要寫死在程式碼中;變更它會使至今發行的所有權杖失效。 ### 2. 僅限 HTTPS {#2-https-only} @@ -549,6 +564,7 @@ from fastapi_cachex.session import ( get_session, # 需要驗證(沒有 Session 時回應 401) get_optional_session, # 可選驗證(沒有 Session 時為 None) require_session, # get_session 的別名 + require_user_session, # Session 沒有使用者時也回應 401 get_session_manager, # 中介軟體註冊的 SessionManager rotate_session_id, # 不是依賴項:在登入時 await 它以取得新的 Session ID ) @@ -558,6 +574,8 @@ from fastapi_cachex.session.dependencies import ( OptionalSession, # Session | None RequiredSession, # Session SessionDep, # Session + UserSessionDep, # Session;匿名 Session 也會通過,見上文 + AuthenticatedSession, # 帶有使用者的 Session(require_user_session) SessionManagerDep, # SessionManager ) ``` diff --git a/tests/session/test_starlette_middleware.py b/tests/session/test_starlette_middleware.py index b1d527e..666df95 100644 --- a/tests/session/test_starlette_middleware.py +++ b/tests/session/test_starlette_middleware.py @@ -19,6 +19,8 @@ from fastapi_cachex.backends.memory import MemoryBackend from fastapi_cachex.session.config import SessionConfig +from fastapi_cachex.session.dependencies import AuthenticatedSession +from fastapi_cachex.session.dependencies import RequiredSession from fastapi_cachex.session.dependencies import get_session from fastapi_cachex.session.dependencies import rotate_session_id from fastapi_cachex.session.exceptions import SessionNotFoundError @@ -1127,3 +1129,39 @@ async def test_writing_after_clear_starts_a_new_anonymous_session( assert fresh.session_id != _session.session_id assert fresh.user is None assert fresh.data == {"flash": "signed out"} + + +@pytest.mark.asyncio +async def test_require_user_session_rejects_anonymous_sessions( + manager: SessionManager, config: SessionConfig +) -> None: + """An anonymous cart session passes get_session but not require_user_session (#114).""" + app = FastAPI() + app.add_middleware(FastAPICacheXSessionMiddleware, session_manager=manager) + + @app.post("/cart") + async def add_to_cart(request: Request) -> dict[str, bool]: + request.session["cart"] = [1] + return {"ok": True} + + @app.get("/any") + async def any_session(session: RequiredSession) -> dict[str, bool]: + return {"user": session.user is not None} + + @app.get("/account") + async def account(session: AuthenticatedSession) -> dict[str, str]: + assert session.user is not None + return {"user_id": session.user.user_id} + + visitor = TestClient(app) + assert visitor.get("/account").status_code == 401 + visitor.post("/cart") + assert visitor.get("/any").json() == {"user": False} + response = visitor.get("/account") + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + + _session, token = await manager.create_session(user=SessionUser(user_id="alice")) + member = TestClient(app) + response = member.get("/account", headers={config.header_name: token}) + assert response.json() == {"user_id": "alice"} diff --git a/tests/session/test_token_serializers.py b/tests/session/test_token_serializers.py index 63e9963..b33e98d 100644 --- a/tests/session/test_token_serializers.py +++ b/tests/session/test_token_serializers.py @@ -1,3 +1,4 @@ +import warnings from datetime import datetime from datetime import timezone from typing import cast @@ -307,3 +308,36 @@ def test_jwt_serializer_round_trips_hmac_algorithms(algorithm: str) -> None: ) assert serializer.from_string(serializer.to_string(token)).session_id == "sid-1" + + +@pytest.mark.parametrize( + ("algorithm", "secret", "warns"), + [ + ("HS256", "a" * 32, False), + ("HS384", "a" * 47, True), + ("HS384", "a" * 48, False), + ("HS512", "a" * 63, True), + ("HS512", "a" * 64, False), + # 32 characters but 64 UTF-8 bytes: the key length is counted in bytes. + ("HS512", "é" * 32, False), + ], +) +def test_jwt_serializer_warns_once_about_a_short_hmac_key( + algorithm: str, secret: str, warns: bool +) -> None: + """A secret shorter than the hash output warns when the serializer is built (#116).""" + config = SessionConfig( + secret_key=SecretStr(secret), token_format="jwt", jwt_algorithm=algorithm + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + JWTTokenSerializer(config, jwt_module=StubJWTModule()) + + messages = [str(w.message) for w in caught if w.category is UserWarning] + if warns: + assert len(messages) == 1 + assert f"requires for {algorithm}" in messages[0] + assert caught[0].filename == __file__ + else: + assert messages == []