Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
30 changes: 29 additions & 1 deletion docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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
)
Expand All @@ -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
)
```
Expand Down
2 changes: 2 additions & 0 deletions fastapi_cachex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -116,4 +117,5 @@ def _read_version() -> str:
"get_state_manager",
"invalidate",
"require_session",
"require_user_session",
]
2 changes: 2 additions & 0 deletions fastapi_cachex/session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,5 +30,6 @@
"get_session_client_ip",
"get_session_manager",
"require_session",
"require_user_session",
"rotate_session_id",
]
30 changes: 30 additions & 0 deletions fastapi_cachex/session/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)]
26 changes: 26 additions & 0 deletions fastapi_cachex/session/token_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import importlib
import logging
import warnings
from datetime import datetime
from datetime import timezone
from typing import TYPE_CHECKING
Expand All @@ -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."""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion i18n/zh-TW/docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` 的憑證),因此權杖中不需要放入任何敏感資料
Expand All @@ -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}

Expand Down Expand Up @@ -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
)
Expand All @@ -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
)
```
Expand Down
38 changes: 38 additions & 0 deletions tests/session/test_starlette_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}
34 changes: 34 additions & 0 deletions tests/session/test_token_serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from datetime import datetime
from datetime import timezone
from typing import cast
Expand Down Expand Up @@ -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 == []
Loading