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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions changelog.d/293.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
**`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`. 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
`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.
129 changes: 76 additions & 53 deletions docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -673,17 +677,73 @@ 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}
```

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
`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}
```

Expand All @@ -693,51 +753,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:

Expand Down Expand Up @@ -780,7 +802,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
Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`) | — |
Expand Down
54 changes: 10 additions & 44 deletions examples/session_login.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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}


Expand Down
2 changes: 2 additions & 0 deletions fastapi_cachex/session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +30,7 @@
"get_session",
"get_session_client_ip",
"get_session_manager",
"login",
"require_session",
"require_user_session",
"rotate_session_id",
Expand Down
Loading
Loading