From 818644c2475eab7bdcb9aba8016986c955347cd2 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 06:16:53 +0000 Subject: [PATCH 1/2] feat(session): add login() that attaches the user through the middleware login(request, user) rotates the loaded session's ID (or starts a new session), attaches the SessionUser, and lets FastAPICacheXSessionMiddleware send the token through the request's transport, so AuthenticatedSession accepts the next request. The login example drops its hand-built cookie and update_session() call, and the session guide documents login() as the way to log in. --- changelog.d/293.added.md | 12 + docs/SESSION.md | 121 +++++---- examples/README.md | 2 +- examples/session_login.py | 54 +--- fastapi_cachex/session/__init__.py | 2 + fastapi_cachex/session/dependencies.py | 113 +++++++-- fastapi_cachex/session/middleware.py | 75 +++++- i18n/zh-TW/docs/SESSION.md | 57 +++-- tests/session/test_login.py | 337 +++++++++++++++++++++++++ 9 files changed, 625 insertions(+), 148 deletions(-) create mode 100644 changelog.d/293.added.md create mode 100644 tests/session/test_login.py diff --git a/changelog.d/293.added.md b/changelog.d/293.added.md new file mode 100644 index 0000000..a7b9848 --- /dev/null +++ b/changelog.d/293.added.md @@ -0,0 +1,12 @@ +**`login(request, user)` logs a user in through `FastAPICacheXSessionMiddleware`.** +It gives the request's session a new ID against session fixation, or starts a +new session for a visitor who has none, and attaches the `SessionUser`, so a +later request with the new token passes `require_user_session` / +`AuthenticatedSession`. The middleware sends that token through the request's +transport (cookie, header or `Authorization: Bearer`) with +`Cache-Control: private, no-store`. Before, a login needed +`rotate_session_id()`, then `update_session()` to save the user, and for a new +visitor a hand-built cookie; writing `request.session["user_id"]`, as the +`rotate_session_id()` docstring showed, never attached a user at all. +`login()` raises `RuntimeError` outside `FastAPICacheXSessionMiddleware`. +`examples/session_login.py` and the session guide now use it. diff --git a/docs/SESSION.md b/docs/SESSION.md index 6f5ec27..bfcde56 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -178,12 +178,15 @@ there. `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. -`session.user` is set only by passing `user=` to `create_session()` or by assigning it and -saving the session. 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. See -[Regenerate the Session ID After Login](#5-regenerate-the-session-id-after-login) for a login -that sets the user. +Under `FastAPICacheXSessionMiddleware`, log a user in with `await login(request, user)`. It +attaches the `SessionUser` that `require_user_session` / `AuthenticatedSession` check, under a +new session ID, and the middleware sends the token; see +[Regenerate the Session ID After Login](#5-regenerate-the-session-id-after-login). The `/login` +above instead hands an API client its token in the body: `create_session(user=...)` sets +`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. ### 3. Full Example (Redis Backend) @@ -415,8 +418,9 @@ async def me(session=Depends(require_user_session)): - Logging in by writing to `request.session` keeps the session ID the request arrived with. With Starlette's middleware the cookie *is* the session, so the login response replaces whatever cookie was planted; here the cookie only names a server-side record, and a planted - one would be logged in along with the victim. Call `await rotate_session_id(request)` before - attaching the user (see [Regenerate the Session ID After Login](#5-regenerate-the-session-id-after-login)). + one would be logged in along with the victim. Log in with `await login(request, user)`, which + gives the session a new ID and attaches the user (see + [Regenerate the Session ID After Login](#5-regenerate-the-session-id-after-login)). - Any access to `request.session` adds `Vary` for every request header read to find the token: the headers checked in `token_source_priority` order (`header_name`, and `Authorization` when bearer tokens are enabled) up to the one that carried the token. `Cookie` is added only when @@ -673,17 +677,65 @@ match the bound one (or has no address) is treated as having no session. Prevents session fixation. The token a client arrives with may have been planted by someone else (from a sibling subdomain, say); a login that keeps it hands that person a -logged-in session. Give the session a new ID before attaching the user: +logged-in session. Under `FastAPICacheXSessionMiddleware`, `login()` gives the session a new ID +and attaches the user in one call: ```python -from fastapi_cachex.session import rotate_session_id +from fastapi import Request + +from fastapi_cachex.session import SessionUser, login +# LoginRequest is the body model from Basic Usage @app.post("/login") -async def login(request: Request): - ... # verify the credentials +async def log_in(credentials: LoginRequest, request: Request): + ... # verify credentials.password + await login(request, SessionUser(user_id=credentials.username)) + return {"ok": True} +``` + +A loaded session (an anonymous visitor's cart, say) keeps its data under a new ID and gets the +user; the old token no longer resolves. With no session loaded (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. The middleware then saves the session, keys written to +`request.session` before or after the call included, and sends its token through the transport +the request used: the response header for a header or `Authorization: Bearer` token, otherwise +an HttpOnly `Set-Cookie` with every `cookie_*` attribute. Like every response that carries a +token, it gets `Cache-Control: private, no-store`. A later request with that token passes +`require_user_session` and `AuthenticatedSession`. `login()` returns the session, which +`get_session` also returns for the rest of the request. + +A request that carried no token at all gets only the cookie, which page scripts cannot read. Do +not copy the token into a response header or the body of a browser login. An API client that +logs in without a token needs it in the body: return `manager.issue_token(session)` for the +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). + +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. + +`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 +the request arrived with. + +To change the ID without logging in (after a privilege change, say), call +`await rotate_session_id(request)`: + +```python +from fastapi_cachex.session import rotate_session_id +from fastapi_cachex.session.dependencies import AuthenticatedSession + + +@app.post("/sudo") +async def sudo(request: Request, session: AuthenticatedSession): + ... # check the password again await rotate_session_id(request) - request.session["user_id"] = "123" + request.session["elevated"] = True return {"ok": True} ``` @@ -693,51 +745,13 @@ new ID, keeping its data, user, `created_at` and expiry. Either middleware sees and sends a token for it through the transport the request used: `Set-Cookie` for a cookie, the response header for a header token. After that the old token no longer resolves to a session. For a new visitor there is no session to rotate, so it returns -`False` and the first write starts a session under a fresh ID. +`False`. A handler that already holds the request's session object can call `await manager.regenerate_session_id(session)` directly, with the same effect. Get it from `get_optional_session` and skip the call when it is `None`; `SessionDep` answers `401` to a visitor who has no session yet. -The example above stores the user ID as application data, which `require_user_session` and -`AuthenticatedSession` do not recognise. To pass them, attach a `SessionUser` after the -rotation and save the session yourself: assigning `session.user` does not mark -`request.session` as modified, so the middleware would not save it. - -```python -from fastapi_cachex.session import SessionUser -from fastapi_cachex.session.dependencies import OptionalSession - - -@app.post("/login") -async def login(request: Request, session: OptionalSession): - ... # verify the credentials - user = SessionUser(user_id="123") - if session is not None: - await rotate_session_id(request) - session.user = user - await session_manager.update_session(session) - else: - # No session yet: the middleware has no token to send, so create one - # with the user and deliver its token yourself. - _, token = await session_manager.create_session(user=user) - ... - return {"ok": True} -``` - -In that last branch the response is yours to secure, since the middleware adds nothing -to a token it did not send: set the cookie with every `cookie_*` attribute of the config -(`domain` included, or the cookie cleared at logout will not match it) and send -`Cache-Control: private, no-store` so no shared cache stores the credential. Do not copy the -token into a response header or the body of a browser login: page scripts could read it, -which is what the HttpOnly cookie prevents. Give API clients their token from a separate -endpoint that returns it in the body, as -[`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py) does. The complete version is -[`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py). -A `login()` helper that does all of this is planned -([#293](https://github.com/allen0099/FastAPI-CacheX/issues/293)). - Outside a middleware, load the session with the same bindings the middleware would pass, and hand the returned token to the client yourself: @@ -780,7 +794,8 @@ from fastapi_cachex.session import ( 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 + login, # not a dependency: await it to log a user in under a new session ID + rotate_session_id, # not a dependency: await it for a new session ID ) # Type annotations diff --git a/examples/README.md b/examples/README.md index 7517fbc..ea5ffec 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,7 +8,7 @@ FastAPI-CacheX. They use only the public API and the in-memory backend (except |---------|---------------|-------| | [`http_cache.py`](http_cache.py) | `@cache` with a TTL, ETag and `304 Not Modified`, `no_cache` and `private` routes, `clear_path()` after an update, the monitoring routes behind an admin check | — | | [`app_cache.py`](app_cache.py) | `CacheManager.get_or_set()` for an expensive call, `add()` as an idempotency check, the `AppCache` dependency | — | -| [`session_login.py`](session_login.py) | `FastAPICacheXSessionMiddleware` with cookies: an anonymous session, login with `rotate_session_id()`, `AuthenticatedSession`, logout with `request.session.clear()` | — | +| [`session_login.py`](session_login.py) | `FastAPICacheXSessionMiddleware` with cookies: an anonymous session, login with `login()`, `AuthenticatedSession`, logout with `request.session.clear()` | — | | [`session_jwt.py`](session_jwt.py) | Sessions with `token_format="jwt"` for API clients (`Authorization: Bearer`), revoked on logout | `jwt` extra | | [`oauth_state.py`](oauth_state.py) | One-time OAuth `state` values with `StateManager`, bound to the browser that started the flow | — | | [`cache_lock.py`](cache_lock.py) | `CacheLock`, waiting (`async with`) and non-blocking (`acquire(blocking=False)`) | — | diff --git a/examples/session_login.py b/examples/session_login.py index a893953..258f008 100644 --- a/examples/session_login.py +++ b/examples/session_login.py @@ -1,9 +1,9 @@ """Server-side sessions with ``FastAPICacheXSessionMiddleware``. A visitor gets an anonymous session as soon as something is written to -``request.session`` (here, a shopping cart). Logging in rotates the session ID -against session fixation and attaches the user, keeping the cart; logging out -deletes the session. The token travels in an HttpOnly cookie, as with +``request.session`` (here, a shopping cart). Logging in with ``login()`` rotates +the session ID against session fixation and attaches the user, keeping the cart; +logging out deletes the session. The token travels in an HttpOnly cookie, as with Starlette's ``SessionMiddleware``; header and ``Authorization: Bearer`` tokens work too. A login here hands out only the cookie, so page scripts never see the token; an API client gets its token from an endpoint that returns it in the @@ -22,7 +22,6 @@ from fastapi import FastAPI from fastapi import HTTPException from fastapi import Request -from fastapi import Response from pydantic import BaseModel from fastapi_cachex import BackendProxy @@ -31,10 +30,8 @@ from fastapi_cachex import SessionManager from fastapi_cachex import SessionUser from fastapi_cachex.backends import MemoryBackend -from fastapi_cachex.session import rotate_session_id +from fastapi_cachex.session import login from fastapi_cachex.session.dependencies import AuthenticatedSession -from fastapi_cachex.session.dependencies import ClientIPDep -from fastapi_cachex.session.dependencies import OptionalSession backend = MemoryBackend() BackendProxy.set(backend) @@ -84,48 +81,17 @@ async def add_to_cart(item: str, request: Request) -> dict[str, list[str]]: @app.post("/login") -async def login( - credentials: Credentials, - request: Request, - response: Response, - client_ip: ClientIPDep, - session: OptionalSession, -) -> dict[str, str]: +async def log_in(credentials: Credentials, request: Request) -> dict[str, str]: """Check the password, then attach the user under a new session ID.""" expected = DEMO_USERS.get(credentials.username) if expected is None or not secrets.compare_digest(credentials.password, expected): raise HTTPException(status_code=401, detail="Wrong username or password") user = SessionUser(user_id=credentials.username, username=credentials.username) - - if session is not None: - # The visitor already has a session (their cart). Give it a new ID so a - # token planted before login is worthless, then attach the user. The - # middleware sends the new token in place of the old one. - await rotate_session_id(request) - session.user = user - await session_manager.update_session(session) - return {"user": user.user_id} - - # No session yet, so the middleware has no token to send: create the - # session with the user and deliver the token ourselves, with the cookie - # attributes and cache headers the middleware would use. - _, token = await session_manager.create_session( - user=user, - ip_address=client_ip, - user_agent=request.headers.get("user-agent"), - ) - response.set_cookie( - config.cookie_name, - token, - max_age=config.cookie_max_age, - path=config.cookie_path, - domain=config.cookie_domain, - secure=config.cookie_https_only, - httponly=True, - samesite=config.cookie_same_site, - ) - # The token is a credential: no shared cache may store this response. - response.headers["Cache-Control"] = "private, no-store" + # A visitor with a session (their cart) keeps it under a new ID, so a token + # planted before login is worthless; a new visitor gets a new session. The + # middleware saves it and sends the token as an HttpOnly cookie on a + # response no cache may store. + await login(request, user) return {"user": user.user_id} diff --git a/fastapi_cachex/session/__init__.py b/fastapi_cachex/session/__init__.py index 26ba4d0..66fea59 100644 --- a/fastapi_cachex/session/__init__.py +++ b/fastapi_cachex/session/__init__.py @@ -5,6 +5,7 @@ from .dependencies import get_session from .dependencies import get_session_client_ip from .dependencies import get_session_manager +from .dependencies import login from .dependencies import require_session from .dependencies import require_user_session from .dependencies import rotate_session_id @@ -29,6 +30,7 @@ "get_session", "get_session_client_ip", "get_session_manager", + "login", "require_session", "require_user_session", "rotate_session_id", diff --git a/fastapi_cachex/session/dependencies.py b/fastapi_cachex/session/dependencies.py index 2649719..89e3b14 100644 --- a/fastapi_cachex/session/dependencies.py +++ b/fastapi_cachex/session/dependencies.py @@ -10,11 +10,14 @@ from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPBearer +from .middleware import _log_in +from .middleware import _RequestSession from .middleware import get_client_ip from .models import Session if TYPE_CHECKING: from .manager import SessionManager + from .models import SessionUser # HTTPBearer security scheme for OpenAPI UI @@ -153,34 +156,40 @@ async def login( async def rotate_session_id(request: Request) -> bool: """Give the request's session a new ID, as a defence against session fixation. - Call it at login, before attaching the user. 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 sends the client a token - for the new ID through the transport the request used. The session keeps - its data, user and expiry. + To log a user in under ``FastAPICacheXSessionMiddleware``, call + :func:`login` instead: it rotates the ID the same way, attaches the + ``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``. + + 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 + sends the client a token for the new ID through the transport the request + used. The session keeps its data, user and expiry. With no session loaded there is nothing to rotate: the first write to ``request.session`` (or ``create_session()``) starts a session under a - fresh ID anyway. So this works for a new visitor and a returning one alike. + fresh ID anyway. + + ``request.session["user_id"] = ...`` is application data, not a login: + ``require_user_session`` / ``AuthenticatedSession`` still answer ``401`` + for such a session. Example: ```python - from fastapi_cachex.session.dependencies import rotate_session_id + from fastapi_cachex.session import rotate_session_id - @app.post("/login") - async def login(request: Request): - ... # verify the credentials + @app.post("/sudo") + async def sudo(request: Request, session: AuthenticatedSession): + ... # re-check the password await rotate_session_id(request) - request.session["user_id"] = "123" + request.session["elevated"] = True return {"ok": True} ``` - ``request.session["user_id"]`` is application data. For - ``require_user_session`` / ``AuthenticatedSession`` to accept the - session, set ``session.user`` after the rotation and save it with - ``SessionManager.update_session()``; see the session guide. - Args: request: FastAPI request object @@ -199,6 +208,78 @@ async def login(request: Request): return True +async def login(request: Request, user: "SessionUser") -> Session: + """Log ``user`` in on the request's session, under a new session ID. + + This is the way to log in under ``FastAPICacheXSessionMiddleware`` when + routes are guarded by ``require_user_session`` / ``AuthenticatedSession``: + a later request with the token the response carries passes them. + + - A loaded session (an anonymous 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. + - With no session loaded (a new visitor, or a token that did not + resolve) a new session is created with the user, bound to the client + IP and User-Agent as configured. + + The middleware then saves the session, including anything written to + ``request.session`` before or after the call, and sends its token through + the transport the request used: the response header (``header_name``) + when the request carried a header or ``Authorization: Bearer`` token, + otherwise an HttpOnly ``Set-Cookie``. So a request that carried no token + at all gets only the cookie; an API client that needs the token in the + body can return ``SessionManager.issue_token()`` for the returned session. + The response is marked ``Cache-Control: private, no-store``, as for every + response that carries a session token. + + Within the same request, ``request.session.clear()`` after ``login()`` is + a logout: the logged-in 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. Calling ``login()`` twice rotates again and keeps the last + user. + + Example: + ```python + from fastapi_cachex.session import SessionUser, login + + + @app.post("/login") + async def log_in(credentials: Credentials, request: Request): + ... # verify the credentials + await login(request, SessionUser(user_id=credentials.username)) + return {"ok": True} + ``` + + Args: + request: FastAPI request object + user: The user to attach + + Returns: + The logged-in session, also what ``get_session`` returns for the rest + of the 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. + """ + 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 " + "so it can save the session and send its token" + ) + raise RuntimeError(msg) + return await _log_in(request, request_session, user) + + def get_session_client_ip( request: Request, manager: "SessionManager" = Depends(get_session_manager), diff --git a/fastapi_cachex/session/middleware.py b/fastapi_cachex/session/middleware.py index f521ea4..34325d4 100644 --- a/fastapi_cachex/session/middleware.py +++ b/fastapi_cachex/session/middleware.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: from .models import Session + from .models import SessionUser logger = logging.getLogger(__name__) @@ -313,6 +314,11 @@ class _RequestSession(StarletteSession): """ cleared: bool = False + # The backend session this dict belongs to: the one the middleware loaded, + # or the one ``login()`` started or rotated. Read when the response starts. + backend: "Session | None" = None + # The middleware that created this dict; ``login()`` goes through it. + middleware: "FastAPICacheXSessionMiddleware | None" = None def clear(self) -> None: self.cleared = True @@ -400,15 +406,18 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ) loaded_token = renewed_token or token_value loaded_session_id = backend_session.session_id - scope["session"] = _RequestSession(backend_session.data) except SessionError: logger.debug( "FastAPICacheXSessionMiddleware: token invalid/expired; " "starting empty session", ) - scope["session"] = _RequestSession() - else: - scope["session"] = _RequestSession() + + request_session = _RequestSession( + backend_session.data if backend_session is not None else {} + ) + request_session.backend = backend_session + request_session.middleware = self + scope["session"] = request_session scope.setdefault("state", {})["__fastapi_cachex_session"] = backend_session @@ -422,15 +431,17 @@ async def send_wrapper(message: Message) -> None: session: _RequestSession = scope["session"] headers = MutableHeaders(scope=message) + # login() may have started a session, or replaced the loaded one. + target = request_session.backend current_token, fresh_token = self._response_tokens( - backend_session, loaded_session_id, loaded_token, renewed_token + target, loaded_session_id, loaded_token, renewed_token ) sent_token = await self._persist( session, headers, connection, - backend_session, + target, current_token, fresh_token, from_header=from_header, @@ -628,3 +639,55 @@ def _build_clear_cookie_header(self) -> str: f"expires=Thu, 01 Jan 1970 00:00:00 GMT; " f"{self._security_flags}" ) + + +async def _log_in( + connection: HTTPConnection, + request_session: _RequestSession, + user: "SessionUser", +) -> "Session": + """Attach ``user`` to the request's session under a new session ID. + + The public entry point is ``fastapi_cachex.session.login()``, which + documents the behaviour and checks that ``request_session`` belongs to + ``FastAPICacheXSessionMiddleware``. + + Args: + connection: The request being handled + request_session: The middleware's ``request.session`` for it + user: The user to attach + + Returns: + The logged-in session + """ + middleware = request_session.middleware + assert middleware is not None # noqa: S101 - checked by login() + manager = middleware.session_manager + current = request_session.backend + if request_session.cleared: + # clear() already logged the loaded session out. Delete it now, since + # the flag that would have deleted it is reset below, and log in on a + # new session. + if current is not None: + await manager.delete_session(current.session_id) + current = None + request_session.cleared = False + + if current is None: + current, _ = await manager.create_session( + user, + ip_address=get_client_ip(connection, middleware.config), + user_agent=connection.headers.get("user-agent"), + ) + else: + # Attach the user before the rotation, so the record under the old ID + # never holds it. + current.user = user + await manager.regenerate_session_id(current) + + # The session is already stored with the user. Its ID differs from the one + # the middleware loaded (if any), so the middleware sends a token for it + # and saves ``request.session`` into it if the handler writes there. + request_session.backend = current + connection.scope.setdefault("state", {})["__fastapi_cachex_session"] = current + return current diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index 6604900..ebf0566 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -155,7 +155,7 @@ async def logout(session=Depends(get_session)): `UserSessionDep` 雖然名稱如此,卻不會檢查使用者;在 0.4.0 之前它是 `SessionDep` 的別名,0.4.0 預計改為要求使用者。 -只有在 `create_session()` 傳入 `user=`,或指定 `session.user` 後儲存 Session,才會設定 `session.user`。寫入 `request.session` 的鍵(`request.session["user_id"] = ...`)是應用程式資料:函式庫不會把它視為登入,因此這種 Session 仍會讓 `AuthenticatedSession` 回應 `401`。會設定使用者的登入方式,請見[登入後重新產生 Session ID](#5-regenerate-the-session-id-after-login)。 +在 `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`。 ### 3. 完整範例(Redis 後端) {#3-full-example-redis-backend} @@ -355,7 +355,7 @@ async def me(session=Depends(require_user_session)): - 修改已載入 Session 的 `request.session`,會透過 `update_session()` 將新內容儲存到後端,以 dict 的內容取代 `Session.data`。 - 在已載入的 Session 上清除它(`request.session.clear()`)即為登出:即使資料原本就是空的,也會刪除後端的 Session;Cookie 用戶端還會收到一個使 Cookie 過期的 `Set-Cookie`。同一個請求中在 `clear()` 之後寫入的鍵,會存進一個使用新 ID 的新匿名 Session。 - 以 `del` 或 `pop()` 移除最後一個鍵並不是登出。帶有使用者的 Session 會以空資料儲存;匿名 Session 已無任何內容,會和 `clear()` 一樣被刪除。 -- 以寫入 `request.session` 的方式登入時,會沿用請求帶來的 Session ID。Starlette 的中介軟體中 Cookie *就是* Session,因此登入回應會取代任何被植入的 Cookie;這裡的 Cookie 只是指向伺服器端紀錄的名稱,被植入的 Cookie 會跟著受害者一起登入。請在附加使用者之前呼叫 `await rotate_session_id(request)`(見[登入後重新產生 Session ID](#5-regenerate-the-session-id-after-login))。 +- 以寫入 `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`,就會為了尋找權杖而讀取過的每個請求標頭加入 `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,有沒有使用者都算,或不是空的 `request.session`),`@cache` 不會讀寫後端,並像 `Authorization` 一樣以 `private` 回應。`public=True` 讓路由在各 Session 間共用;`cache_authorized=True` 搭配包含 Session 使用者的 `key_builder` 則依使用者快取。見[需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 @@ -539,48 +539,48 @@ config = SessionConfig( ### 5. 登入後重新產生 Session ID {#5-regenerate-the-session-id-after-login} -防止 Session 固定攻擊(session fixation)。用戶端帶來的權杖可能是別人預先植入的(例如從同網域的其他子網域);若登入時沿用它,植入者就會拿到一個已登入的 Session。請在附加使用者之前為 Session 換一個新 ID: +防止 Session 固定攻擊(session fixation)。用戶端帶來的權杖可能是別人預先植入的(例如從同網域的其他子網域);若登入時沿用它,植入者就會拿到一個已登入的 Session。在 `FastAPICacheXSessionMiddleware` 底下,`login()` 一次就會為 Session 換一個新 ID 並附加使用者: ```python -from fastapi_cachex.session import rotate_session_id +from fastapi import Request + +from fastapi_cachex.session import SessionUser, login +# LoginRequest 是基本用法中的請求本文模型 @app.post("/login") -async def login(request: Request): - ... # 驗證帳號密碼 - await rotate_session_id(request) - request.session["user_id"] = "123" +async def log_in(credentials: LoginRequest, request: Request): + ... # 驗證 credentials.password + await login(request, SessionUser(user_id=credentials.username)) return {"ok": True} ``` -`rotate_session_id()` 會對請求的 Session 呼叫 `SessionManager.regenerate_session_id()`,刪除舊 ID 底下的後端紀錄,並以新 ID 儲存該 Session,保留其資料、使用者、`created_at` 與過期時間。任一個中介軟體都會看到新 ID,並透過該請求使用的傳輸方式送出對應的權杖:Cookie 使用 `Set-Cookie`,標頭權杖則使用回應標頭。之後舊的權杖就無法再解析出 Session。新訪客沒有可換 ID 的 Session,因此它會回傳 `False`,第一次寫入時會以全新的 ID 建立 Session。 +已載入的 Session(例如匿名訪客的購物車)會以新 ID 保留其資料並得到使用者;舊的權杖就無法再解析出 Session。沒有載入 Session 時(新訪客,或權杖無法解析),`login()` 會建立帶有使用者的 Session,並依設定綁定用戶端 IP 與 User-Agent。接著中介軟體會儲存該 Session(包括呼叫前後寫入 `request.session` 的鍵),並透過該請求使用的傳輸方式送出權杖:以標頭或 `Authorization: Bearer` 權杖送來的請求使用回應標頭,否則使用帶有所有 `cookie_*` 屬性的 HttpOnly `Set-Cookie`。和每個帶有權杖的回應一樣,它會加上 `Cache-Control: private, no-store`。之後帶著該權杖的請求會通過 `require_user_session` 與 `AuthenticatedSession`。`login()` 會回傳該 Session,在該請求剩下的處理中,`get_session` 也會回傳它。 -已經取得請求 Session 物件的 handler,也可以直接呼叫 `await manager.regenerate_session_id(session)`,效果相同。請從 `get_optional_session` 取得 Session,並在它為 `None` 時略過呼叫;`SessionDep` 會對還沒有 Session 的訪客回應 `401`。 +完全沒有帶權杖的請求只會收到 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 並回傳其權杖。 + +`request.session["user_id"] = "123"` 不是登入。它是應用程式資料,`require_user_session` 與 `AuthenticatedSession` 不會認得它,而且它會沿用請求帶來的 Session ID。 -上面的範例把使用者 ID 存成應用程式資料,`require_user_session` 與 `AuthenticatedSession` 不會認得它。要通過它們的檢查,請在換 ID 之後附加 `SessionUser`,並自行儲存 Session:指定 `session.user` 不會把 `request.session` 標記為已修改,因此中介軟體不會儲存它。 +若要在不登入的情況下更換 ID(例如權限變更之後),請呼叫 `await rotate_session_id(request)`: ```python -from fastapi_cachex.session import SessionUser -from fastapi_cachex.session.dependencies import OptionalSession +from fastapi_cachex.session import rotate_session_id +from fastapi_cachex.session.dependencies import AuthenticatedSession -@app.post("/login") -async def login(request: Request, session: OptionalSession): - ... # 驗證帳號密碼 - user = SessionUser(user_id="123") - if session is not None: - await rotate_session_id(request) - session.user = user - await session_manager.update_session(session) - else: - # 還沒有 Session:中介軟體沒有權杖可送, - # 因此建立帶有使用者的 Session,並自行交付其權杖。 - _, token = await session_manager.create_session(user=user) - ... +@app.post("/sudo") +async def sudo(request: Request, session: AuthenticatedSession): + ... # 再次檢查密碼 + await rotate_session_id(request) + request.session["elevated"] = True return {"ok": True} ``` -最後這個分支的回應要由你自己保護,因為中介軟體不會處理不是由它送出的權杖:設定 Cookie 時帶上設定中所有的 `cookie_*` 屬性(包括 `domain`,否則登出時清除的 Cookie 會對不上),並送出 `Cache-Control: private, no-store`,讓共用快取不會存下這個憑證。不要把權杖複製到瀏覽器登入回應的標頭或本文中:頁面上的指令碼會讀得到它,而這正是 HttpOnly Cookie 要防止的。API 用戶端的權杖請由另一個在本文中回傳權杖的端點發給,如 [`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()` 輔助函式已在規劃中([#293](https://github.com/allen0099/FastAPI-CacheX/issues/293))。 +`rotate_session_id()` 會對請求的 Session 呼叫 `SessionManager.regenerate_session_id()`,刪除舊 ID 底下的後端紀錄,並以新 ID 儲存該 Session,保留其資料、使用者、`created_at` 與過期時間。任一個中介軟體都會看到新 ID,並透過該請求使用的傳輸方式送出對應的權杖:Cookie 使用 `Set-Cookie`,標頭權杖則使用回應標頭。之後舊的權杖就無法再解析出 Session。新訪客沒有可換 ID 的 Session,因此它會回傳 `False`。 + +已經取得請求 Session 物件的 handler,也可以直接呼叫 `await manager.regenerate_session_id(session)`,效果相同。請從 `get_optional_session` 取得 Session,並在它為 `None` 時略過呼叫;`SessionDep` 會對還沒有 Session 的訪客回應 `401`。 在中介軟體之外,請以中介軟體會傳入的相同綁定值載入 Session,並自行將回傳的權杖交給用戶端: @@ -608,7 +608,8 @@ from fastapi_cachex.session import ( require_session, # get_session 的別名 require_user_session, # Session 沒有使用者時也回應 401 get_session_manager, # 中介軟體註冊的 SessionManager - rotate_session_id, # 不是依賴項:在登入時 await 它以取得新的 Session ID + login, # 不是依賴項:await 它以新的 Session ID 讓使用者登入 + rotate_session_id, # 不是依賴項:await 它以取得新的 Session ID ) # 型別註記 diff --git a/tests/session/test_login.py b/tests/session/test_login.py new file mode 100644 index 0000000..151d9d6 --- /dev/null +++ b/tests/session/test_login.py @@ -0,0 +1,337 @@ +"""Tests for ``login()``: attaching a user through FastAPICacheXSessionMiddleware (#293).""" + +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi.testclient import TestClient + +from fastapi_cachex.backends.memory import MemoryBackend +from fastapi_cachex.session import login +from fastapi_cachex.session.config import SessionConfig +from fastapi_cachex.session.dependencies import AuthenticatedSession +from fastapi_cachex.session.dependencies import OptionalSession +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"] + + +def _login_app(manager: SessionManager, config: SessionConfig) -> FastAPI: + app = FastAPI() + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) + + @app.post("/cart") + async def add_to_cart(request: Request) -> dict[str, bool]: + request.session["cart"] = ["book"] + return {"ok": True} + + @app.post("/login") + async def log_in(request: Request, name: str = "alice") -> dict[str, Any]: + session = await login(request, SessionUser(user_id=name)) + return {"session_id": session.session_id} + + @app.post("/login-then-write") + async def login_then_write(request: Request) -> dict[str, bool]: + request.session["before"] = 1 + await login(request, SessionUser(user_id="alice")) + request.session["after"] = 2 + return {"ok": True} + + @app.post("/login-then-clear") + async def login_then_clear(request: Request) -> dict[str, bool]: + await login(request, SessionUser(user_id="alice")) + request.session.clear() + return {"ok": True} + + @app.post("/clear-then-login") + async def clear_then_login(request: Request) -> dict[str, bool]: + request.session.clear() + await login(request, SessionUser(user_id="alice")) + return {"ok": True} + + @app.post("/login-twice") + async def login_twice(request: Request) -> dict[str, bool]: + await login(request, SessionUser(user_id="alice")) + await login(request, SessionUser(user_id="bob")) + return {"ok": True} + + @app.post("/login-and-read") + async def login_and_read(request: Request) -> dict[str, Any]: + session = await login(request, SessionUser(user_id="alice")) + # What the session dependencies see for the rest of the request. + seen = getattr(request.state, "__fastapi_cachex_session", None) + return {"same": seen is session} + + @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} + + @app.get("/whoami") + async def whoami(session: OptionalSession) -> dict[str, Any]: + return {"session_id": session.session_id if session else None} + + return app + + +def _auth(transport: str, config: SessionConfig, token: str) -> dict[str, str]: + """Request headers that carry ``token`` over a header transport.""" + if transport == "header": + return {config.header_name: token} + return {"Authorization": f"Bearer {token}"} + + +def _token_sent(response: Any, transport: str, config: SessionConfig) -> str: + """The token the login response sent, asserting it used ``transport``.""" + if transport == "cookie": + assert config.header_name.lower() not in response.headers + value = response.cookies.get(config.cookie_name) + else: + assert "set-cookie" not in response.headers + value = response.headers.get(config.header_name) + assert value + return str(value) + + +def _me(client: TestClient, transport: str, config: SessionConfig, token: str) -> Any: + """GET /me carrying ``token`` over ``transport`` and nothing else.""" + client.cookies.clear() + if transport == "cookie": + client.cookies.set(config.cookie_name, token) + return client.get("/me") + return client.get("/me", headers=_auth(transport, config, token)) + + +@pytest.mark.parametrize("transport", TRANSPORTS) +async def test_new_visitor_logs_in( + manager: SessionManager, config: SessionConfig, transport: str +) -> None: + """With no session loaded, login() starts one and the next request is a user. + + A header or Bearer client counts as new when its token no longer resolves; + one that sent no token at all is answered with the cookie. + """ + client = TestClient(_login_app(manager, config)) + headers = {} if transport == "cookie" else _auth(transport, config, "stale") + + response = client.post("/login", headers=headers) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "private, no-store" + token = _token_sent(response, transport, config) + session, _ = await manager.get_session(token) + assert session.session_id == response.json()["session_id"] + assert session.user is not None + assert session.user.user_id == "alice" + + me = _me(client, transport, config, token) + assert me.status_code == 200 + assert me.json() == {"user": "alice", "data": {}} + + +@pytest.mark.parametrize("transport", TRANSPORTS) +async def test_anonymous_session_keeps_its_data_under_a_new_id( + manager: SessionManager, config: SessionConfig, transport: str +) -> None: + """Logging in a loaded session rotates its ID; the old token stops working.""" + anonymous, old_token = await manager.create_anonymous_session(cart=["book"]) + client = TestClient(_login_app(manager, config)) + if transport == "cookie": + client.cookies.set(config.cookie_name, old_token) + response = client.post("/login") + else: + response = client.post("/login", headers=_auth(transport, config, old_token)) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "private, no-store" + token = _token_sent(response, transport, config) + assert token != old_token + assert response.json()["session_id"] != anonymous.session_id + with pytest.raises(SessionNotFoundError): + await manager.get_session(old_token) + + me = _me(client, transport, config, token) + assert me.status_code == 200 + assert me.json() == {"user": "alice", "data": {"cart": ["book"]}} + assert _me(client, transport, config, old_token).status_code == 401 + + +def test_tokenless_request_gets_only_the_cookie( + manager: SessionManager, config: SessionConfig +) -> None: + """A request with no token is answered as a cookie client, as for any new session.""" + response = TestClient(_login_app(manager, config)).post("/login") + + assert config.cookie_name in response.cookies + assert config.header_name.lower() not in response.headers + + +def test_cart_then_login_over_cookies( + manager: SessionManager, config: SessionConfig +) -> None: + """The browser flow: an anonymous cart, then login, then a user route.""" + client = TestClient(_login_app(manager, config)) + client.post("/cart") + assert client.get("/me").status_code == 401 + + assert client.post("/login").status_code == 200 + + assert client.get("/me").json() == {"user": "alice", "data": {"cart": ["book"]}} + + +async def test_writes_around_login_are_saved( + manager: SessionManager, config: SessionConfig +) -> None: + """Keys written to request.session before and after login() are both kept.""" + client = TestClient(_login_app(manager, config)) + + response = client.post("/login-then-write") + + token = _token_sent(response, "cookie", config) + session, _ = await manager.get_session(token) + assert session.data == {"before": 1, "after": 2} + assert session.user is not None + + +async def test_login_then_clear_logs_out( + manager: SessionManager, config: SessionConfig, backend: MemoryBackend +) -> None: + """clear() after login() in the same request is a logout: nothing survives.""" + client = TestClient(_login_app(manager, config)) + client.post("/cart") + old_token = client.cookies[config.cookie_name] + + response = client.post("/login-then-clear") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "private, no-store" + assert "expires=Thu, 01 Jan 1970" in response.headers["set-cookie"] + assert not client.cookies.get(config.cookie_name) + assert await backend.get_all_keys() == [] + with pytest.raises(SessionNotFoundError): + await manager.get_session(old_token) + assert client.get("/me").status_code == 401 + + +async def test_login_then_clear_over_the_header_sends_no_token( + manager: SessionManager, config: SessionConfig, backend: MemoryBackend +) -> None: + """A header client logged out in the same request gets no token at all.""" + client = TestClient(_login_app(manager, config)) + + response = client.post("/login-then-clear", headers={config.header_name: "stale"}) + + assert config.header_name.lower() not in response.headers + assert "set-cookie" not in response.headers + assert await backend.get_all_keys() == [] + + +async def test_clear_then_login_starts_a_new_session( + manager: SessionManager, config: SessionConfig, backend: MemoryBackend +) -> None: + """clear() before login() logs the loaded session out; login() starts afresh.""" + anonymous, old_token = await manager.create_anonymous_session(cart=["book"]) + client = TestClient(_login_app(manager, config)) + client.cookies.set(config.cookie_name, old_token) + + response = client.post("/clear-then-login") + + token = _token_sent(response, "cookie", config) + session, _ = await manager.get_session(token) + assert session.session_id != anonymous.session_id + assert session.user is not None + assert session.data == {} + with pytest.raises(SessionNotFoundError): + await manager.get_session(old_token) + # Only the new session is left in the backend. + assert len(await backend.get_all_keys()) == 1 + + +async def test_clear_then_login_for_a_new_visitor( + manager: SessionManager, config: SessionConfig +) -> None: + """clear() with nothing loaded has nothing to log out; login() still works.""" + client = TestClient(_login_app(manager, config)) + + response = client.post("/clear-then-login") + + token = _token_sent(response, "cookie", config) + session, _ = await manager.get_session(token) + assert session.user is not None + assert client.get("/me").status_code == 200 + + +async def test_login_twice_keeps_the_last_user( + manager: SessionManager, config: SessionConfig, backend: MemoryBackend +) -> None: + client = TestClient(_login_app(manager, config)) + + response = client.post("/login-twice") + + token = _token_sent(response, "cookie", config) + session, _ = await manager.get_session(token) + assert session.user is not None + assert session.user.user_id == "bob" + assert len(await backend.get_all_keys()) == 1 + + +def test_login_updates_the_request_session_for_dependencies( + manager: SessionManager, config: SessionConfig +) -> None: + """For the rest of the request, get_session returns the logged-in session.""" + client = TestClient(_login_app(manager, config)) + + assert client.post("/login-and-read").json() == {"same": True} + + +async def test_new_session_is_bound_like_the_middlewares( + backend: MemoryBackend, +) -> None: + """A session login() creates gets the IP and User-Agent bindings.""" + config = SessionConfig( + secret_key="a" * 32, ip_binding=True, user_agent_binding=True + ) + manager = SessionManager(backend, config) + client = TestClient(_login_app(manager, config)) + + response = client.post("/login", headers={"User-Agent": "browser/1"}) + + token = _token_sent(response, "cookie", config) + session, _ = await manager.get_session( + token, ip_address="testclient", user_agent="browser/1" + ) + assert session.ip_address == "testclient" + assert session.user_agent == "browser/1" + + +async def test_login_outside_the_middleware_raises() -> None: + """Without FastAPICacheXSessionMiddleware nobody would send the token.""" + request = Request({"type": "http", "method": "POST", "headers": []}) + + with pytest.raises(RuntimeError, match="FastAPICacheXSessionMiddleware"): + 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"}) From 25a5bd6e9ddb1e9edc7f3ecff76527d696a922cd Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 06:23:17 +0000 Subject: [PATCH 2/2] fix(session): start a clean session when login() switches users When the loaded session belongs to a different user_id, login() now deletes it and empties request.session instead of rotating it, so none of the previous user's data reaches the new one. Anonymous sessions and re-logins of the same user_id still keep their data and rotate. --- changelog.d/293.added.md | 5 +- docs/SESSION.md | 20 ++++-- fastapi_cachex/session/dependencies.py | 12 +++- fastapi_cachex/session/middleware.py | 11 +++ i18n/zh-TW/docs/SESSION.md | 9 ++- tests/session/test_login.py | 92 ++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 11 deletions(-) diff --git a/changelog.d/293.added.md b/changelog.d/293.added.md index a7b9848..f27f4f3 100644 --- a/changelog.d/293.added.md +++ b/changelog.d/293.added.md @@ -2,8 +2,9 @@ It gives the request's session a new ID against session fixation, or starts a new session for a visitor who has none, and attaches the `SessionUser`, so a later request with the new token passes `require_user_session` / -`AuthenticatedSession`. The middleware sends that token through the request's -transport (cookie, header or `Authorization: Bearer`) with +`AuthenticatedSession`. A session that belonged to a different user is +deleted and replaced by a new one, so none of its data reaches the new user. +The middleware sends that token through the request's transport (cookie, header or `Authorization: Bearer`) with `Cache-Control: private, no-store`. Before, a login needed `rotate_session_id()`, then `update_session()` to save the user, and for a new visitor a hand-built cookie; writing `request.session["user_id"]`, as the diff --git a/docs/SESSION.md b/docs/SESSION.md index bfcde56..dcfc9bb 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -694,12 +694,20 @@ async def log_in(credentials: LoginRequest, request: Request): return {"ok": True} ``` -A loaded session (an anonymous visitor's cart, say) keeps its data under a new ID and gets the -user; the old token no longer resolves. With no session loaded (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. The middleware then saves the session, keys written to -`request.session` before or after the call included, and sends its token through the transport -the request used: the response header for a header or `Authorization: Bearer` token, otherwise +What happens to the session the request arrived with depends on whose it is: + +- **Anonymous** (a visitor's cart, say): it keeps its data under a new ID and gets the user. +- **The same `user_id`** (a re-login): the same, and the `SessionUser` you pass replaces the + stored one, so changed roles or metadata take effect. +- **A different user's**: it is deleted, along with anything written to `request.session` + earlier in the request, and `login()` starts a new session. None of the previous user's data + (a cart, an `elevated` flag) reaches the new user. +- **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. + +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 an HttpOnly `Set-Cookie` with every `cookie_*` attribute. Like every response that carries a token, it gets `Cache-Control: private, no-store`. A later request with that token passes `require_user_session` and `AuthenticatedSession`. `login()` returns the session, which diff --git a/fastapi_cachex/session/dependencies.py b/fastapi_cachex/session/dependencies.py index 89e3b14..7a9e82d 100644 --- a/fastapi_cachex/session/dependencies.py +++ b/fastapi_cachex/session/dependencies.py @@ -215,16 +215,24 @@ async def login(request: Request, user: "SessionUser") -> Session: routes are guarded by ``require_user_session`` / ``AuthenticatedSession``: a later request with the token the response carries passes them. - - A loaded session (an anonymous visitor's cart, say) keeps its data and + - 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. + - 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. + - A loaded session of a different user is deleted, and so is anything + written to ``request.session`` earlier in this request: none of the + previous user's data reaches the new one. A new session is created as + below, and its old token no longer resolves. - With no session loaded (a new visitor, or a token that did not resolve) a new session is created with the user, bound to the client IP and User-Agent as configured. The middleware then saves the session, including anything written to - ``request.session`` before or after the call, and sends its token through + ``request.session`` after the call (and, unless the loaded session was a + different user's, before it), and sends its token through the transport the request used: the response header (``header_name``) when the request carried a header or ``Authorization: Bearer`` token, otherwise an HttpOnly ``Set-Cookie``. So a request that carried no token diff --git a/fastapi_cachex/session/middleware.py b/fastapi_cachex/session/middleware.py index 34325d4..930f397 100644 --- a/fastapi_cachex/session/middleware.py +++ b/fastapi_cachex/session/middleware.py @@ -672,6 +672,17 @@ async def _log_in( await manager.delete_session(current.session_id) current = None request_session.cleared = False + elif ( + current is not None + and current.user is not None + and current.user.user_id != user.user_id + ): + # Another user's session: nothing in it belongs to the new user. Delete + # it and empty the request dict (without marking a logout), so the new + # session starts with only what the handler writes after login(). + await manager.delete_session(current.session_id) + current = None + dict.clear(request_session) if current is None: current, _ = await manager.create_session( diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index ebf0566..93c1bfc 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -555,7 +555,14 @@ async def log_in(credentials: LoginRequest, request: Request): return {"ok": True} ``` -已載入的 Session(例如匿名訪客的購物車)會以新 ID 保留其資料並得到使用者;舊的權杖就無法再解析出 Session。沒有載入 Session 時(新訪客,或權杖無法解析),`login()` 會建立帶有使用者的 Session,並依設定綁定用戶端 IP 與 User-Agent。接著中介軟體會儲存該 Session(包括呼叫前後寫入 `request.session` 的鍵),並透過該請求使用的傳輸方式送出權杖:以標頭或 `Authorization: Bearer` 權杖送來的請求使用回應標頭,否則使用帶有所有 `cookie_*` 屬性的 HttpOnly `Set-Cookie`。和每個帶有權杖的回應一樣,它會加上 `Cache-Control: private, no-store`。之後帶著該權杖的請求會通過 `require_user_session` 與 `AuthenticatedSession`。`login()` 會回傳該 Session,在該請求剩下的處理中,`get_session` 也會回傳它。 +請求帶來的 Session 會如何處理,取決於它屬於誰: + +- **匿名**(例如訪客的購物車):以新 ID 保留其資料並得到使用者。 +- **相同的 `user_id`**(重新登入):同上,並以你傳入的 `SessionUser` 取代儲存的使用者,讓變更後的角色或 metadata 生效。 +- **其他使用者的**:它會被刪除,連同該請求中先前寫入 `request.session` 的內容,`login()` 會建立新的 Session。前一位使用者的資料(購物車、`elevated` 旗標)都不會帶給新使用者。 +- **沒有**(新訪客,或權杖無法解析):`login()` 會建立帶有使用者的 Session,並依設定綁定用戶端 IP 與 User-Agent。 + +無論哪種情況,舊的權杖都無法再解析出 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)。 diff --git a/tests/session/test_login.py b/tests/session/test_login.py index 151d9d6..1fd9959 100644 --- a/tests/session/test_login.py +++ b/tests/session/test_login.py @@ -37,6 +37,13 @@ async def log_in(request: Request, name: str = "alice") -> dict[str, Any]: session = await login(request, SessionUser(user_id=name)) return {"session_id": session.session_id} + @app.post("/write-then-login") + async def write_then_login(request: Request, name: str) -> dict[str, Any]: + request.session["before"] = 1 + session = await login(request, SessionUser(user_id=name, roles=["fresh"])) + request.session["after"] = 2 + return {"session_id": session.session_id} + @app.post("/login-then-write") async def login_then_write(request: Request) -> dict[str, bool]: request.session["before"] = 1 @@ -335,3 +342,88 @@ async def log_in(request: Request) -> None: 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: + """A's session and anything written before login() never reach B.""" + session_a, token_a = await manager.create_session( + SessionUser(user_id="alice"), cart=["book"], elevated=True + ) + client = TestClient(_login_app(manager, config)) + client.cookies.set(config.cookie_name, token_a) + + response = client.post("/write-then-login", params={"name": "bob"}) + + token_b = _token_sent(response, "cookie", config) + session_b, _ = await manager.get_session(token_b) + assert session_b.session_id != session_a.session_id + assert session_b.user is not None + assert session_b.user.user_id == "bob" + assert session_b.data == {"after": 2} + with pytest.raises(SessionNotFoundError): + await manager.get_session(token_a) + assert len(await backend.get_all_keys()) == 1 + assert _me(client, "cookie", config, token_b).json() == { + "user": "bob", + "data": {"after": 2}, + } + + +async def test_login_as_another_user_over_the_header( + manager: SessionManager, config: SessionConfig +) -> None: + _session_a, token_a = await manager.create_session( + SessionUser(user_id="alice"), cart=["book"] + ) + client = TestClient(_login_app(manager, config)) + + response = client.post( + "/write-then-login", + params={"name": "bob"}, + headers={config.header_name: token_a}, + ) + + token_b = _token_sent(response, "header", config) + session_b, _ = await manager.get_session(token_b) + assert session_b.data == {"after": 2} + with pytest.raises(SessionNotFoundError): + await manager.get_session(token_a) + + +async def test_relogin_keeps_the_data_and_updates_the_user( + manager: SessionManager, config: SessionConfig, backend: MemoryBackend +) -> None: + """The same user_id logging in again keeps the data; the new SessionUser wins.""" + session_a, token_a = await manager.create_session( + SessionUser(user_id="alice", roles=["stale"]), cart=["book"] + ) + client = TestClient(_login_app(manager, config)) + client.cookies.set(config.cookie_name, token_a) + + response = client.post("/write-then-login", params={"name": "alice"}) + + token = _token_sent(response, "cookie", config) + session, _ = await manager.get_session(token) + assert session.session_id != session_a.session_id + assert session.user is not None + assert session.user.roles == ["fresh"] + assert session.data == {"cart": ["book"], "before": 1, "after": 2} + with pytest.raises(SessionNotFoundError): + await manager.get_session(token_a) + assert len(await backend.get_all_keys()) == 1 + + +async def test_anonymous_login_keeps_writes_made_before_login( + manager: SessionManager, config: SessionConfig +) -> None: + """The cart case: an anonymous session's data and earlier writes are kept.""" + _anonymous, token = await manager.create_anonymous_session(cart=["book"]) + client = TestClient(_login_app(manager, config)) + client.cookies.set(config.cookie_name, token) + + response = client.post("/write-then-login", params={"name": "bob"}) + + session, _ = await manager.get_session(_token_sent(response, "cookie", config)) + assert session.data == {"cart": ["book"], "before": 1, "after": 2}