From d22ca98a59e76a1d74b0d9d65eb760ee05be9e6b Mon Sep 17 00:00:00 2001 From: allen0099 Date: Wed, 30 Sep 2026 10:22:41 +0000 Subject: [PATCH] feat(session)!: add logout() and login(keep=), make Session.user read-only `await logout(request)` deletes the session from the backend at once, so its token stops resolving before the response is sent, and the middleware expires a cookie client's cookie. It returns False when no session was loaded or started in the request. `login(request, user, keep=[...])` carries only the listed keys of the session the request arrived with over to the logged-in one, both from the stored record and from what the handler wrote before the call. A string is rejected with TypeError. Assigning `Session.user` raises AttributeError, so a session gets a user only when it is built, from `login()` (which rotates the ID) or from `SessionManager.create_session(user=...)`. BREAKING CHANGE: assigning `Session.user` raises AttributeError. Under FastAPICacheXSessionMiddleware use `login(request, user)`; without it, create the session with `SessionManager.create_session(user=...)`. Closes #256 --- changelog.d/256.added.md | 6 + changelog.d/256.changed.2.md | 6 + docs/MIGRATING_0_4.md | 6 +- docs/SESSION.md | 27 +- examples/session_login.py | 6 +- fastapi_cachex/session/__init__.py | 2 + fastapi_cachex/session/dependencies.py | 84 ++++++- fastapi_cachex/session/middleware.py | 16 +- fastapi_cachex/session/models.py | 31 ++- i18n/zh-TW/docs/MIGRATING_0_4.md | 6 +- i18n/zh-TW/docs/SESSION.md | 10 +- tests/session/conftest.py | 5 +- tests/session/test_logout_and_keep.py | 333 +++++++++++++++++++++++++ tests/test_examples.py | 3 +- 14 files changed, 514 insertions(+), 27 deletions(-) create mode 100644 changelog.d/256.added.md create mode 100644 changelog.d/256.changed.2.md create mode 100644 tests/session/test_logout_and_keep.py diff --git a/changelog.d/256.added.md b/changelog.d/256.added.md new file mode 100644 index 0000000..6931ee1 --- /dev/null +++ b/changelog.d/256.added.md @@ -0,0 +1,6 @@ +**`logout()` ends a session, and `login()` takes `keep=`.** +`await logout(request)` (in `fastapi_cachex.session`) deletes the session from +the backend at once and expires the cookie, returning `False` when no session was +loaded or started in the request. `login(request, user, keep=["cart"])` carries only the +listed keys of the session the request arrived with over to the logged-in one; +`keep=[]` carries none. Both need `FastAPICacheXSessionMiddleware`. diff --git a/changelog.d/256.changed.2.md b/changelog.d/256.changed.2.md new file mode 100644 index 0000000..533807d --- /dev/null +++ b/changelog.d/256.changed.2.md @@ -0,0 +1,6 @@ +**`Session.user` is read-only.** Assigning it raises `AttributeError`: log a +user in with `login(request, user)` under `FastAPICacheXSessionMiddleware`, +which also gives the session a new ID, or create the session with +`SessionManager.create_session(user=...)`. A `user` given when a `Session` is +built is still accepted. See the +[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#login-logout). diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index e16b561..65f9577 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -90,9 +90,9 @@ config = SessionConfig( 0.4.0 makes becoming authenticated go through one explicit API that always issues a new session ID ([#256](https://github.com/allen0099/FastAPI-CacheX/issues/256)): - `login(request, user)` (in 0.3.9 already, `from fastapi_cachex.session import login`) attaches the user and rotates the ID. Use it today instead of setting `session.user` yourself. -- `await logout(request)` is added. It deletes the session, and a cookie client gets its cookie expired. `request.session.clear()` keeps meaning logout. -- `Session.user` becomes read-only outside `login()` and `SessionManager.create_session(user=...)`. Code that assigns it directly breaks: under the middleware, use `login()`; without it, create the session with `create_session(user=...)`. -- A login carries the anonymous session's data over by default, so a cart survives it. An optional `keep=` argument narrows that (`keep=["cart"]`, or `keep=[]` for nothing). +- `await logout(request)` (`from fastapi_cachex.session import logout`) deletes the session from the backend at once, so its token stops resolving before the response is sent, and a cookie client gets its cookie expired. It returns `False` when no session was loaded or started in the request. `request.session.clear()` keeps meaning logout. +- Assigning `session.user` raises `AttributeError`. The user is set by `login()` and `SessionManager.create_session(user=...)`, or given when a `Session` is built. Under the middleware, use `login()`; without it, create the session with `create_session(user=...)`. +- A login carries the anonymous session's data over by default, so a cart survives it. `login(request, user, keep=["cart"])` carries only the listed keys, and `keep=[]` carries nothing. A string is rejected with `TypeError`, since `keep="cart"` would otherwise mean its letters. - The old ID stops resolving the moment `login()` or `rotate_session_id()` rotates it, with no grace period: during one, a planted token would resolve to the logged-in session. - `rotate_session_id()` keeps its name, for privilege changes without a new user. diff --git a/docs/SESSION.md b/docs/SESSION.md index daa9d68..cf40deb 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -104,7 +104,10 @@ above instead hands an API client its token in the body: `create_session(user=.. `session.user` too, but the middleware sends nothing for a session it did not load or start. Keys written to `request.session` (`request.session["user_id"] = ...`) are application data: the library does not treat them as a login, so `AuthenticatedSession` still answers `401` for -such a session. +such a session. `session.user` itself is read-only: assigning it raises `AttributeError`, so +apart from a `Session` built with one, a session gets a user only from `login()` or +`create_session(user=...)`. Log out with +`await logout(request)`. ### 3. Full Example (Redis Backend) @@ -182,7 +185,9 @@ async def me(session=Depends(require_user_session)): - Clearing it (`request.session.clear()`) on a loaded session logs out: the backend session is deleted even if its data was already empty, and a cookie client also receives a `Set-Cookie` that expires the cookie. Keys written after `clear()` in the same request go into a new - anonymous session under a new ID. + anonymous session under a new ID. `await logout(request)` does the same, but deletes the + backend session at once instead of when the response is sent, and `get_session` finds no + session for the rest of the request. - Removing the last key with `del` or `pop()` is not a logout. A session with a user is saved with empty data; an anonymous one holds nothing and is deleted, as with `clear()`. - Logging in by writing to `request.session` keeps the session ID the request arrived with. @@ -499,6 +504,11 @@ What happens to the session the request arrived with depends on whose it is: - **None** (a new visitor, or a token that did not resolve): `login()` creates a session with the user, bound to the client IP and User-Agent as configured. +To carry over only some of the data, list the keys: `login(request, user, keep=["cart"])` +drops every other key, both from the loaded session and from what was written to +`request.session` earlier in the request; `keep=[]` drops them all. Keys written after the call +are kept. `keep` must be a collection of keys, so a string raises `TypeError`. + In every case the old token no longer resolves. The middleware then saves the session, keys written to `request.session` after the call included (and before it, unless the loaded session was a different user's), and sends its token through the transport the request used: the response header for a header or `Authorization: Bearer` token, otherwise @@ -514,12 +524,19 @@ session `login()` returned, or issue the token from a separate endpoint, as [`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py) does. The complete browser version is [`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py). +To log out, call `await logout(request)`. It deletes the session from the backend at once, so +its token stops resolving even before the response is sent, and a cookie client gets its cookie +expired. It returns `True`, or `False` when no session was loaded or started in the request (a +token that did not resolve included). Keys written to +`request.session` after it go into a new anonymous session, and a `login()` after it starts a +new session. + Within one request, `request.session.clear()` after `login()` is a logout: the new session is deleted and no token is sent (a cookie client gets its cookie expired). `clear()` before `login()` logs the loaded session out, and `login()` then starts a new session instead of -rotating it. Without `FastAPICacheXSessionMiddleware`, `login()` raises `RuntimeError`, because -nothing would send the token; create the session with `create_session(user=...)` and return its -token instead. +rotating it. Without `FastAPICacheXSessionMiddleware`, `login()` and `logout()` raise +`RuntimeError`, because nothing would send the token or expire the cookie; create the session +with `create_session(user=...)` and return its token, and end it with `delete_session()`. `request.session["user_id"] = "123"` is not a login. It is application data, which `require_user_session` and `AuthenticatedSession` do not recognise, and it keeps the session ID diff --git a/examples/session_login.py b/examples/session_login.py index 5318330..f4bee03 100644 --- a/examples/session_login.py +++ b/examples/session_login.py @@ -32,6 +32,7 @@ from fastapi_cachex import SessionUser from fastapi_cachex.backends import MemoryBackend from fastapi_cachex.session import login +from fastapi_cachex.session import logout as end_session from fastapi_cachex.session.dependencies import AuthenticatedSession backend = MemoryBackend() @@ -123,6 +124,5 @@ async def me(session: AuthenticatedSession) -> dict[str, object]: @app.post("/logout") async def logout(request: Request) -> dict[str, bool]: - """``clear()`` deletes the session and expires the cookie.""" - request.session.clear() - return {"logged_out": True} + """Delete the session now; the middleware expires the cookie.""" + return {"logged_out": await end_session(request)} diff --git a/fastapi_cachex/session/__init__.py b/fastapi_cachex/session/__init__.py index b7a16ae..7d7f76e 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 login +from .dependencies import logout from .dependencies import require_session from .dependencies import require_user_session from .dependencies import rotate_session_id @@ -29,6 +30,7 @@ "get_session_client_ip", "get_session_manager", "login", + "logout", "require_session", "require_user_session", "rotate_session_id", diff --git a/fastapi_cachex/session/dependencies.py b/fastapi_cachex/session/dependencies.py index 3e3a2c3..a3e61a1 100644 --- a/fastapi_cachex/session/dependencies.py +++ b/fastapi_cachex/session/dependencies.py @@ -1,6 +1,7 @@ """FastAPI dependency injection utilities for session management.""" import warnings +from collections.abc import Iterable from typing import TYPE_CHECKING from typing import Annotated @@ -259,7 +260,9 @@ async def sudo(request: Request, session: AuthenticatedSession): return True -async def login(request: Request, user: "SessionUser") -> Session: +async def login( + request: Request, user: "SessionUser", *, keep: Iterable[str] | None = None +) -> Session: """Log ``user`` in on the request's session, under a new session ID. This is the way to log in under ``FastAPICacheXSessionMiddleware`` when @@ -269,7 +272,7 @@ async def login(request: Request, user: "SessionUser") -> Session: - An anonymous loaded session (a visitor's cart, say) keeps its data and gets the user and a new ID, as with :func:`rotate_session_id`, so a token planted before the login is worthless: the old token no longer - resolves. + resolves. ``keep=`` narrows the data carried over. - A loaded session of the same ``user_id`` (a re-login) is handled the same way: its data is kept, the ID rotated, and ``user`` replaces the stored ``SessionUser``, so changed roles or metadata take effect. @@ -297,7 +300,10 @@ async def login(request: Request, user: "SessionUser") -> Session: client gets its cookie expired). ``clear()`` before ``login()`` logs the loaded session out, and ``login()`` then starts a new session instead of rotating it. Calling ``login()`` twice rotates again and keeps the last - user. + user. :func:`logout` ends the session. + + This is the only way to attach a user under the middleware: + ``Session.user`` is read-only. Example: ```python @@ -314,6 +320,11 @@ async def log_in(credentials: Credentials, request: Request): Args: request: FastAPI request object user: The user to attach + keep: The ``request.session`` keys to carry into the logged-in session + (``keep=["cart"]``), or ``[]`` for none. By default (None) all of + them are carried. Anything else, including what was written to + ``request.session`` earlier in this request, is dropped. It never + carries a different user's data, which is always dropped. Returns: The logged-in session, also what ``get_session`` returns for the rest @@ -324,18 +335,81 @@ async def log_in(credentials: Credentials, request: Request): ``FastAPICacheXSessionMiddleware``. Without it, create the session with ``SessionManager.create_session(user=...)`` and return its token. + TypeError: If ``keep`` is a string rather than a collection of keys """ + if isinstance(keep, str): + msg = f"keep must be a collection of keys, not a string: use keep=[{keep!r}]" + raise TypeError(msg) + request_session = _middleware_session(request, "login()") + kept = None if keep is None else frozenset(keep) + return await _log_in(request, request_session, user, kept) + + +async def logout(request: Request) -> bool: + """Log the request's session out: delete it and forget its token. + + The session record is deleted at once, so its token stops resolving for + every request, including ones already in flight. The middleware then + expires a cookie client's cookie; a header client simply drops its token, + as no new one is sent. For the rest of the request, ``get_session`` + finds no session, and ``request.session`` is empty: anything written to + it afterwards starts a new anonymous session. + + ``request.session.clear()`` also logs out, but the record is deleted only + when the response starts, and ``get_session`` keeps returning the session + for the rest of the request. Calling :func:`login` after either in the + same request starts a new session. + + Example: + ```python + from fastapi_cachex.session import logout + + + @app.post("/logout") + async def log_out(request: Request): + await logout(request) + return {"ok": True} + ``` + + Args: + request: FastAPI request object + + Returns: + True if a session was deleted, False if none was loaded or started + in this request (a token that did not resolve included) + + Raises: + RuntimeError: If the request did not pass through + ``FastAPICacheXSessionMiddleware``. Without it, call + ``SessionManager.delete_session()`` with the session's ID. + """ + request_session = _middleware_session(request, "logout()") + session = request_session.backend + # clear() makes the middleware expire the cookie (and delete the record + # again, which is harmless) when the response starts. + request_session.clear() + request.scope.setdefault("state", {})["__fastapi_cachex_session"] = None + if session is None: + return False + middleware = request_session.middleware + assert middleware is not None # noqa: S101 - checked by _middleware_session() + await middleware.session_manager.delete_session(session.session_id) + return True + + +def _middleware_session(request: Request, caller: str) -> _RequestSession: + """Return the middleware's ``request.session``, or raise for ``caller``.""" request_session = request.scope.get("session") if ( not isinstance(request_session, _RequestSession) or request_session.middleware is None ): msg = ( - "login() needs FastAPICacheXSessionMiddleware: add it to the app " + f"{caller} needs FastAPICacheXSessionMiddleware: add it to the app " "so it can save the session and send its token" ) raise RuntimeError(msg) - return await _log_in(request, request_session, user) + return request_session def get_session_client_ip( diff --git a/fastapi_cachex/session/middleware.py b/fastapi_cachex/session/middleware.py index 3c595e5..e078f94 100644 --- a/fastapi_cachex/session/middleware.py +++ b/fastapi_cachex/session/middleware.py @@ -22,6 +22,8 @@ from .proxy import SessionManagerProxy if TYPE_CHECKING: + from collections.abc import Collection + from .models import Session from .models import SessionUser @@ -550,6 +552,7 @@ async def _log_in( connection: HTTPConnection, request_session: _RequestSession, user: "SessionUser", + keep: "Collection[str] | None" = None, ) -> "Session": """Attach ``user`` to the request's session under a new session ID. @@ -561,6 +564,8 @@ async def _log_in( connection: The request being handled request_session: The middleware's ``request.session`` for it user: The user to attach + keep: The ``request.session`` keys to carry into the logged-in + session, or None for all of them Returns: The logged-in session @@ -589,6 +594,15 @@ async def _log_in( current = None dict.clear(request_session) + if keep is not None: + # pop() marks the dict modified, so the middleware saves what is left. + for key in [key for key in request_session if key not in keep]: + request_session.pop(key) + if current is not None: + # Rotation stores ``current`` under the new ID: keep the dropped + # data out of that record too. + current.data = {k: v for k, v in current.data.items() if k in keep} + if current is None: current, _ = await manager.create_session( user, @@ -598,7 +612,7 @@ async def _log_in( else: # Attach the user before the rotation, so the record under the old ID # never holds it. - current.user = user + current._attach_user(user) # noqa: SLF001 await manager.regenerate_session_id(current) # The session is already stored with the user. Its ID differs from the one diff --git a/fastapi_cachex/session/models.py b/fastapi_cachex/session/models.py index 648822c..00cdbf2 100644 --- a/fastapi_cachex/session/models.py +++ b/fastapi_cachex/session/models.py @@ -5,6 +5,7 @@ from datetime import timedelta from datetime import timezone from enum import Enum +from typing import TYPE_CHECKING from typing import Any from uuid import uuid4 @@ -39,7 +40,15 @@ class SessionUser(BaseModel): class Session(BaseModel): - """Core session model containing all session data.""" + """Core session model containing all session data. + + ``user`` is read-only (#256): a user is attached only by + ``login(request, user)`` under the session middleware, which always issues + a new session ID, or by ``SessionManager.create_session(user=...)``, which + starts a new session. Assigning it raises ``AttributeError``, so a session + whose ID an attacker may know cannot be promoted to a logged-in one in + place. + """ session_id: str = Field(default_factory=lambda: str(uuid4())) user: SessionUser | None = None @@ -54,6 +63,26 @@ class Session(BaseModel): model_config = {"use_enum_values": True} + # Hidden from type checkers: a visible __setattr__ would make them accept + # assignment to any attribute name, typos included. + if not TYPE_CHECKING: # pragma: no branch + + def __setattr__(self, name: str, value: Any) -> None: + """Refuse to assign ``user``; every other field is assigned as usual.""" + if name == "user": + msg = ( + "Session.user is read-only: log a user in with " + "login(request, user) under FastAPICacheXSessionMiddleware, or " + "start a session with SessionManager.create_session(user=...) " + "(https://github.com/allen0099/FastAPI-CacheX/issues/256)" + ) + raise AttributeError(msg) + super().__setattr__(name, value) + + def _attach_user(self, user: SessionUser) -> None: + """Set ``user``, for ``login()`` only, which rotates the ID right after.""" + super().__setattr__("user", user) + def is_valid(self) -> bool: """Check if session is valid (active and not expired).""" if self.status != SessionStatus.ACTIVE: diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index 073378b..534ba96 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -90,9 +90,9 @@ config = SessionConfig( 0.4.0 讓使用者只能透過一個明確的 API 成為已驗證狀態,而這個 API 一律會發出新的 Session ID([#256](https://github.com/allen0099/FastAPI-CacheX/issues/256)): - `login(request, user)`(0.3.9 已提供,`from fastapi_cachex.session import login`)會附加使用者並輪替 ID。現在就請改用它,而不是自行設定 `session.user`。 -- 新增 `await logout(request)`:刪除 Session,Cookie 用戶端會收到讓 Cookie 過期的回應。`request.session.clear()` 仍代表登出。 -- `Session.user` 在 `login()` 與 `SessionManager.create_session(user=...)` 之外變成唯讀。直接指定它的程式碼會失效:使用中介軟體時請改用 `login()`;沒有中介軟體時,請以 `create_session(user=...)` 建立 Session。 -- 登入時預設會帶入匿名 Session 的所有資料,因此購物車在登入後仍會保留。選用的 `keep=` 參數可以縮小範圍(`keep=["cart"]`,或以 `keep=[]` 什麼都不帶)。 +- `await logout(request)`(`from fastapi_cachex.session import logout`)會立即從後端刪除 Session,因此在回應送出之前其權杖就已失效,Cookie 用戶端也會收到讓 Cookie 過期的回應。該請求中沒有載入或建立任何 Session 時回傳 `False`。`request.session.clear()` 仍代表登出。 +- 指定 `session.user` 會拋出 `AttributeError`。使用者由 `login()` 與 `SessionManager.create_session(user=...)` 設定,或在建立 `Session` 時傳入。使用中介軟體時請改用 `login()`;沒有中介軟體時,請以 `create_session(user=...)` 建立 Session。 +- 登入時預設會帶入匿名 Session 的所有資料,因此購物車在登入後仍會保留。`login(request, user, keep=["cart"])` 只帶入列出的鍵,`keep=[]` 則什麼都不帶。傳入字串會拋出 `TypeError`,否則 `keep="cart"` 會被當成它的各個字母。 - `login()` 或 `rotate_session_id()` 輪替 ID 後,舊 ID 立即失效,沒有寬限期:若有寬限期,被植入的權杖在這段期間會解析到已登入的 Session。 - `rotate_session_id()` 保留原名,用於不更換使用者的權限變更。 diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index b1ecc70..3ba06aa 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -69,7 +69,7 @@ app.add_middleware(FastAPICacheXSessionMiddleware) # 從 proxy 取得 `UserSessionDep` 與 `AuthenticatedSession` 相同:匿名 Session 會得到 `401`。0.4.0 之前它是 `SessionDep` 的別名,也接受匿名 Session;路由確實需要接受匿名 Session 時,請使用 `SessionDep`(見[遷移至 0.4.0](MIGRATING_0_4.md#user-session-dep))。 -在 `FastAPICacheXSessionMiddleware` 底下,請以 `await login(request, user)` 讓使用者登入。它會以新的 Session ID 附加 `require_user_session`/`AuthenticatedSession` 檢查的 `SessionUser`,並由中介軟體送出權杖;見[登入後重新產生 Session ID](#5-regenerate-the-session-id-after-login)。上面的 `/login` 則是在本文中把權杖交給 API 用戶端:`create_session(user=...)` 同樣會設定 `session.user`,但中介軟體不會為不是由它載入或建立的 Session 送出任何東西。寫入 `request.session` 的鍵(`request.session["user_id"] = ...`)是應用程式資料:函式庫不會把它視為登入,因此這種 Session 仍會讓 `AuthenticatedSession` 回應 `401`。 +在 `FastAPICacheXSessionMiddleware` 底下,請以 `await login(request, user)` 讓使用者登入。它會以新的 Session ID 附加 `require_user_session`/`AuthenticatedSession` 檢查的 `SessionUser`,並由中介軟體送出權杖;見[登入後重新產生 Session ID](#5-regenerate-the-session-id-after-login)。上面的 `/login` 則是在本文中把權杖交給 API 用戶端:`create_session(user=...)` 同樣會設定 `session.user`,但中介軟體不會為不是由它載入或建立的 Session 送出任何東西。寫入 `request.session` 的鍵(`request.session["user_id"] = ...`)是應用程式資料:函式庫不會把它視為登入,因此這種 Session 仍會讓 `AuthenticatedSession` 回應 `401`。`session.user` 本身是唯讀的:指定它會拋出 `AttributeError`,因此除了建立時就帶有使用者的 `Session` 之外,Session 只能從 `login()` 或 `create_session(user=...)` 得到使用者。登出請使用 `await logout(request)`。 ### 3. 完整範例(Redis 後端) {#3-full-example-redis-backend} @@ -116,7 +116,7 @@ async def me(session=Depends(require_user_session)): - 在沒有載入任何 Session 時寫入 `request.session`,會建立一個新的**匿名** Session(`SessionManager.create_anonymous_session()`,並依設定套用 IP / User-Agent 綁定),並透過該請求的傳輸方式傳回其權杖。 - 修改已載入 Session 的 `request.session`,會透過 `update_session()` 將新內容儲存到後端,以 dict 的內容取代 `Session.data`。 -- 在已載入的 Session 上清除它(`request.session.clear()`)即為登出:即使資料原本就是空的,也會刪除後端的 Session;Cookie 用戶端還會收到一個使 Cookie 過期的 `Set-Cookie`。同一個請求中在 `clear()` 之後寫入的鍵,會存進一個使用新 ID 的新匿名 Session。 +- 在已載入的 Session 上清除它(`request.session.clear()`)即為登出:即使資料原本就是空的,也會刪除後端的 Session;Cookie 用戶端還會收到一個使 Cookie 過期的 `Set-Cookie`。同一個請求中在 `clear()` 之後寫入的鍵,會存進一個使用新 ID 的新匿名 Session。`await logout(request)` 的效果相同,但會立即刪除後端的 Session,而不是等到送出回應時,且在該請求剩下的處理中,`get_session` 找不到 Session。 - 以 `del` 或 `pop()` 移除最後一個鍵並不是登出。帶有使用者的 Session 會以空資料儲存;匿名 Session 已無任何內容,會和 `clear()` 一樣被刪除。 - 以寫入 `request.session` 的方式登入時,會沿用請求帶來的 Session ID。Starlette 的中介軟體中 Cookie *就是* Session,因此登入回應會取代任何被植入的 Cookie;這裡的 Cookie 只是指向伺服器端紀錄的名稱,被植入的 Cookie 會跟著受害者一起登入。請以 `await login(request, user)` 登入,它會為 Session 換一個新 ID 並附加使用者(見[登入後重新產生 Session ID](#5-regenerate-the-session-id-after-login))。 - 只要存取 `request.session`,或透過 Session 依賴項(`get_session`、`get_optional_session`,以及建立在它們之上的依賴項,例如 `AuthenticatedSession`)讀取 Session,就會為了尋找權杖而讀取過的每個請求標頭加入 `Vary`:依 `token_source_priority` 順序檢查的標頭(`header_name`,以及啟用 Bearer 權杖時的 `Authorization`),直到攜帶權杖的那一個為止。只有在沒有任何標頭攜帶權杖時才會讀取 Cookie,因此也只有這時才會加入 `Cookie`。 @@ -340,11 +340,15 @@ async def log_in(credentials: Credentials, request: Request): - **其他使用者的**:它會被刪除,連同該請求中先前寫入 `request.session` 的內容,`login()` 會建立新的 Session。前一位使用者的資料(購物車、`elevated` 旗標)都不會帶給新使用者。 - **沒有**(新訪客,或權杖無法解析):`login()` 會建立帶有使用者的 Session,並依設定綁定用戶端 IP 與 User-Agent。 +若只想帶入部分資料,請列出鍵:`login(request, user, keep=["cart"])` 會丟棄其他所有鍵,包括已載入 Session 中的鍵,以及該請求中先前寫入 `request.session` 的鍵;`keep=[]` 則全部丟棄。呼叫之後寫入的鍵會保留。`keep` 必須是鍵的集合,因此傳入字串會拋出 `TypeError`。 + 無論哪種情況,舊的權杖都無法再解析出 Session。接著中介軟體會儲存該 Session(包括呼叫之後寫入 `request.session` 的鍵;除非已載入的 Session 屬於其他使用者,也包括呼叫之前寫入的鍵),並透過該請求使用的傳輸方式送出權杖:以標頭或 `Authorization: Bearer` 權杖送來的請求使用回應標頭,否則使用帶有所有 `cookie_*` 屬性的 HttpOnly `Set-Cookie`。和每個帶有權杖的回應一樣,它會加上 `Cache-Control: private, no-store`。之後帶著該權杖的請求會通過 `require_user_session` 與 `AuthenticatedSession`。`login()` 會回傳該 Session,在該請求剩下的處理中,`get_session` 也會回傳它。 完全沒有帶權杖的請求只會收到 Cookie,頁面上的指令碼讀不到它。不要把權杖複製到瀏覽器登入回應的標頭或本文中。沒有權杖就登入的 API 用戶端需要從本文取得權杖:對 `login()` 回傳的 Session 回傳 `manager.issue_token(session)`,或由另一個端點發出權杖,如 [`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py) 所示。完整的瀏覽器版本請見 [`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py)。 -在同一個請求中,`login()` 之後呼叫 `request.session.clear()` 就是登出:新的 Session 會被刪除,也不會送出權杖(Cookie 用戶端的 Cookie 會被設為過期)。在 `login()` 之前呼叫 `clear()` 會讓已載入的 Session 登出,`login()` 接著會建立新的 Session,而不是為它換 ID。沒有 `FastAPICacheXSessionMiddleware` 時,`login()` 會拋出 `RuntimeError`,因為沒有人會送出權杖;請改以 `create_session(user=...)` 建立 Session 並回傳其權杖。 +登出請呼叫 `await logout(request)`。它會立即從後端刪除 Session,因此權杖在回應送出之前就已失效,Cookie 用戶端也會收到讓 Cookie 過期的回應。它回傳 `True`;若該請求中沒有載入或建立任何 Session(包括權杖無法解析的情況),則回傳 `False`。之後寫入 `request.session` 的鍵會存進新的匿名 Session,之後呼叫的 `login()` 則會建立新的 Session。 + +在同一個請求中,`login()` 之後呼叫 `request.session.clear()` 就是登出:新的 Session 會被刪除,也不會送出權杖(Cookie 用戶端的 Cookie 會被設為過期)。在 `login()` 之前呼叫 `clear()` 會讓已載入的 Session 登出,`login()` 接著會建立新的 Session,而不是為它換 ID。沒有 `FastAPICacheXSessionMiddleware` 時,`login()` 與 `logout()` 會拋出 `RuntimeError`,因為沒有人會送出權杖或讓 Cookie 過期;請改以 `create_session(user=...)` 建立 Session 並回傳其權杖,並以 `delete_session()` 結束它。 `request.session["user_id"] = "123"` 不是登入。它是應用程式資料,`require_user_session` 與 `AuthenticatedSession` 不會認得它,而且它會沿用請求帶來的 Session ID。 diff --git a/tests/session/conftest.py b/tests/session/conftest.py index 5401a9d..42b6b6c 100644 --- a/tests/session/conftest.py +++ b/tests/session/conftest.py @@ -17,8 +17,9 @@ def backend() -> MemoryBackend: def config() -> SessionConfig: """Create session config for testing. - The cookie settings are explicit so FastAPICacheXSessionMiddleware does not - warn about the 0.4.0 cookie defaults (#256). + TestClient talks plain HTTP, which the default ``__Host-session`` cookie + with the Secure flag is not sent back over, so the cookie settings are + explicit (#256). """ return SessionConfig( secret_key="a" * 32, cookie_name="session", cookie_https_only=False diff --git a/tests/session/test_logout_and_keep.py b/tests/session/test_logout_and_keep.py new file mode 100644 index 0000000..417e12f --- /dev/null +++ b/tests/session/test_logout_and_keep.py @@ -0,0 +1,333 @@ +"""``logout()``, ``login(keep=...)`` and the read-only ``Session.user`` (#256).""" + +from typing import Annotated +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi import Query +from fastapi import Request +from fastapi.testclient import TestClient + +from fastapi_cachex.backends.memory import MemoryBackend +from fastapi_cachex.session import Session +from fastapi_cachex.session import login +from fastapi_cachex.session import logout +from fastapi_cachex.session.config import SessionConfig +from fastapi_cachex.session.dependencies import AuthenticatedSession +from fastapi_cachex.session.exceptions import SessionNotFoundError +from fastapi_cachex.session.manager import SessionManager +from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware +from fastapi_cachex.session.models import SessionUser + +from .test_login import TRANSPORTS +from .test_login import _auth +from .test_login import _me +from .test_login import _token_sent + + +def _app(manager: SessionManager, config: SessionConfig) -> FastAPI: + app = FastAPI() + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) + + @app.post("/logout") + async def log_out(request: Request) -> dict[str, Any]: + was_loaded = await logout(request) + # The record is gone before the response, not when it is sent. + keys = await manager.backend.get_all_keys() + return { + "logged_out": was_loaded, + "keys": keys, + "state": getattr(request.state, "__fastapi_cachex_session", "unset"), + "dict": dict(request.session), + } + + @app.post("/logout-then-write") + async def logout_then_write(request: Request) -> dict[str, bool]: + await logout(request) + request.session["after"] = 1 + return {"ok": True} + + @app.post("/logout-then-login") + async def logout_then_login(request: Request) -> dict[str, bool]: + await logout(request) + await login(request, SessionUser(user_id="bob")) + return {"ok": True} + + @app.post("/login-keep") + async def login_keep( + request: Request, + keep: Annotated[list[str], Query()] = [], # noqa: B006 + name: str = "alice", + ) -> dict[str, Any]: + request.session["before"] = 1 + session = await login(request, SessionUser(user_id=name), keep=keep) + # The record stored under the new ID already lacks the dropped keys. + data_at_login = dict(session.data) + request.session["after"] = 2 + return {"data_at_login": data_at_login} + + @app.post("/login-then-logout") + async def login_then_logout(request: Request) -> dict[str, bool]: + await login(request, SessionUser(user_id="alice")) + return {"logged_out": await logout(request)} + + @app.get("/me") + async def me(session: AuthenticatedSession) -> dict[str, Any]: + assert session.user is not None + return {"user": session.user.user_id, "data": session.data} + + return app + + +def _post( + client: TestClient, + path: str, + transport: str, + config: SessionConfig, + token: str, + **kwargs: Any, +) -> Any: + client.cookies.clear() + if transport == "cookie": + client.cookies.set(config.cookie_name, token) + return client.post(path, **kwargs) + return client.post(path, headers=_auth(transport, config, token), **kwargs) + + +# --- logout() ------------------------------------------------------------------- + + +@pytest.mark.parametrize("transport", TRANSPORTS) +async def test_logout_deletes_the_session_at_once( + manager: SessionManager, config: SessionConfig, transport: str +) -> None: + """The record is deleted during the call; the token no longer resolves.""" + _session, token = await manager.create_session( + SessionUser(user_id="alice"), cart=["book"] + ) + client = TestClient(_app(manager, config)) + + response = _post(client, "/logout", transport, config, token) + + assert response.status_code == 200 + assert response.json() == { + "logged_out": True, + "keys": [], + "state": None, + "dict": {}, + } + with pytest.raises(SessionNotFoundError): + await manager.get_session(token) + assert config.header_name.lower() not in response.headers + if transport == "cookie": + assert "expires=Thu, 01 Jan 1970" in response.headers["set-cookie"] + assert response.headers["cache-control"] == "private, no-store" + else: + assert "set-cookie" not in response.headers + assert _me(client, transport, config, token).status_code == 401 + + +async def test_logout_without_a_session_sends_nothing( + manager: SessionManager, config: SessionConfig +) -> None: + response = TestClient(_app(manager, config)).post("/logout") + + assert response.json()["logged_out"] is False + assert "set-cookie" not in response.headers + + +async def test_writes_after_logout_start_a_new_anonymous_session( + manager: SessionManager, config: SessionConfig, backend: MemoryBackend +) -> None: + session, token = await manager.create_session( + SessionUser(user_id="alice"), cart=["book"] + ) + client = TestClient(_app(manager, config)) + + response = _post(client, "/logout-then-write", "cookie", config, token) + + new_session, _ = await manager.get_session(_token_sent(response, "cookie", config)) + assert new_session.session_id != session.session_id + assert new_session.user is None + assert new_session.data == {"after": 1} + assert len(await backend.get_all_keys()) == 1 + + +async def test_logout_then_login_starts_a_new_session( + manager: SessionManager, config: SessionConfig, backend: MemoryBackend +) -> None: + session, token = await manager.create_session( + SessionUser(user_id="alice"), cart=["book"] + ) + client = TestClient(_app(manager, config)) + + response = _post(client, "/logout-then-login", "cookie", config, token) + + new_session, _ = await manager.get_session(_token_sent(response, "cookie", config)) + assert new_session.session_id != session.session_id + assert new_session.user is not None + assert new_session.user.user_id == "bob" + assert new_session.data == {} + assert len(await backend.get_all_keys()) == 1 + + +async def test_login_then_logout_deletes_the_new_session( + manager: SessionManager, config: SessionConfig +) -> None: + """The session login() started is the one logout() ends.""" + client = TestClient(_app(manager, config)) + + response = client.post("/login-then-logout") + + assert response.json() == {"logged_out": True} + assert await manager.backend.get_all_keys() == [] + assert "01 jan 1970" in response.headers["set-cookie"].lower() + + +async def test_logout_outside_the_middleware_raises() -> None: + request = Request({"type": "http", "method": "POST", "headers": []}) + + with pytest.raises(RuntimeError, match=r"logout\(\) needs"): + await logout(request) + + +# --- login(keep=...) ------------------------------------------------------------ + + +@pytest.mark.parametrize( + ("keep", "kept"), + [ + (["cart"], {"cart": ["book"]}), + (["cart", "before", "missing"], {"cart": ["book"], "before": 1}), + ([], {}), + ], +) +async def test_login_keep_narrows_the_carried_data( + manager: SessionManager, + config: SessionConfig, + keep: list[str], + kept: dict[str, Any], +) -> None: + """Only the listed keys, stored or written before login(), are carried. + + The record stored under the new ID never holds a dropped key (``before`` + lives only in ``request.session`` until the response), and what the + handler writes after login() is saved as usual. + """ + _anonymous, token = await manager.create_anonymous_session( + cart=["book"], tracking="planted" + ) + client = TestClient(_app(manager, config)) + + response = _post( + client, "/login-keep", "cookie", config, token, params={"keep": keep} + ) + + stored_at_login = {k: v for k, v in kept.items() if k != "before"} + assert response.json() == {"data_at_login": stored_at_login} + session, _ = await manager.get_session(_token_sent(response, "cookie", config)) + assert session.data == {**kept, "after": 2} + with pytest.raises(SessionNotFoundError): + await manager.get_session(token) + + +async def test_login_keep_applies_to_a_relogin( + manager: SessionManager, config: SessionConfig +) -> None: + _session, token = await manager.create_session( + SessionUser(user_id="alice"), cart=["book"], elevated=True + ) + client = TestClient(_app(manager, config)) + + response = _post( + client, "/login-keep", "cookie", config, token, params={"keep": ["cart"]} + ) + + session, _ = await manager.get_session(_token_sent(response, "cookie", config)) + assert session.data == {"cart": ["book"], "after": 2} + + +@pytest.mark.parametrize(("keep", "kept"), [(["before"], {"before": 1}), ([], {})]) +async def test_login_keep_applies_without_a_loaded_session( + manager: SessionManager, + config: SessionConfig, + keep: list[str], + kept: dict[str, Any], +) -> None: + """A new visitor's writes before login() are narrowed the same way.""" + client = TestClient(_app(manager, config)) + + response = client.post("/login-keep", params={"keep": keep}) + + session, _ = await manager.get_session(_token_sent(response, "cookie", config)) + assert session.user is not None + assert session.data == {**kept, "after": 2} + + +async def test_login_keep_never_carries_another_users_data( + manager: SessionManager, config: SessionConfig +) -> None: + """``keep`` narrows what is carried; it cannot bring back what is dropped.""" + _session, token = await manager.create_session( + SessionUser(user_id="alice"), cart=["book"] + ) + client = TestClient(_app(manager, config)) + + response = _post( + client, + "/login-keep", + "cookie", + config, + token, + params={"keep": ["cart", "before"], "name": "bob"}, + ) + + assert response.json() == {"data_at_login": {}} + session, _ = await manager.get_session(_token_sent(response, "cookie", config)) + assert session.user is not None + assert session.user.user_id == "bob" + assert session.data == {"after": 2} + + +async def test_login_keep_rejects_a_string( + manager: SessionManager, config: SessionConfig +) -> None: + """``keep="cart"`` would keep the keys "c", "a", "r" and "t".""" + request = Request({"type": "http", "method": "POST", "headers": []}) + + with pytest.raises(TypeError, match=r"keep=\['cart'\]"): + await login(request, SessionUser(user_id="alice"), keep="cart") + + +# --- Read-only Session.user ----------------------------------------------------- + + +def test_session_user_cannot_be_assigned() -> None: + session = Session() + + with pytest.raises(AttributeError, match=r"login\(request, user\)"): + session.user = SessionUser(user_id="alice") + + assert session.user is None + + +def test_other_session_fields_stay_assignable() -> None: + session = Session() + + session.data = {"cart": ["book"]} + session.ip_address = "203.0.113.7" + + assert session.data == {"cart": ["book"]} + assert session.ip_address == "203.0.113.7" + + +def test_a_user_can_still_be_given_when_a_session_is_built() -> None: + """Construction and loading from the backend are not assignments.""" + session = Session(user=SessionUser(user_id="alice")) + loaded = Session.model_validate_json(session.model_dump_json()) + + assert loaded.user is not None + assert loaded.user.user_id == "alice" diff --git a/tests/test_examples.py b/tests/test_examples.py index afa5c64..7292cab 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -217,7 +217,8 @@ def test_session_login() -> None: == 200 ) - assert client.post("/logout").status_code == 200 + assert client.post("/logout").json() == {"logged_out": True} + assert client.post("/logout").json() == {"logged_out": False} with TestClient(example.app) as replay: replay.cookies.set("session", user_token) assert replay.get("/me").status_code == 401