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
6 changes: 6 additions & 0 deletions changelog.d/256.added.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 6 additions & 0 deletions changelog.d/256.changed.2.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 3 additions & 3 deletions docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 22 additions & 5 deletions docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions examples/session_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)}
2 changes: 2 additions & 0 deletions fastapi_cachex/session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .dependencies import get_session_client_ip
from .dependencies import get_session_manager
from .dependencies import login
from .dependencies import logout
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_client_ip",
"get_session_manager",
"login",
"logout",
"require_session",
"require_user_session",
"rotate_session_id",
Expand Down
84 changes: 79 additions & 5 deletions fastapi_cachex/session/dependencies.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down
16 changes: 15 additions & 1 deletion fastapi_cachex/session/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from .proxy import SessionManagerProxy

if TYPE_CHECKING:
from collections.abc import Collection

from .models import Session
from .models import SessionUser

Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
31 changes: 30 additions & 1 deletion fastapi_cachex/session/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading
Loading