Add kaya-session package for server-side HTTP session management

This commit is contained in:
2026-07-23 22:09:55 +08:00
parent 4ce95d8365
commit 97a81a9e41
16 changed files with 778 additions and 5 deletions
@@ -0,0 +1,11 @@
from ._middleware import SessionMiddleware
from ._session import Session
from ._store import InMemorySessionStore, SessionStore
__all__ = [
'InMemorySessionStore',
'Session',
'SessionMiddleware',
'SessionStore',
]
@@ -0,0 +1,37 @@
from http.cookies import SimpleCookie
from typing import Optional
def parse_cookie_value(header_value: str, cookie_name: str) -> Optional[str]:
"""Return the value of ``cookie_name`` from a ``Cookie`` header, if present."""
cookie = SimpleCookie()
cookie.load(header_value)
morsel = cookie.get(cookie_name)
if morsel is None:
return None
return morsel.value
def format_set_cookie(
name: str,
value: str,
path: str = '/',
max_age: Optional[int] = None,
httponly: bool = True,
secure: bool = False,
samesite: Optional[str] = 'Lax',
) -> str:
"""Return a ``Set-Cookie`` value string (without the header name)."""
cookie = SimpleCookie()
cookie[name] = value
morsel = cookie[name]
morsel['path'] = path
if max_age is not None:
morsel['max-age'] = max_age
if httponly:
morsel['httponly'] = True
if secure:
morsel['secure'] = True
if samesite is not None:
morsel['samesite'] = samesite
return morsel.OutputString()
@@ -0,0 +1,178 @@
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Sequence, cast
from kaya.core import HttpMethod, KayaApp
from ._cookie import format_set_cookie, parse_cookie_value
from ._session import Session
from ._store import SessionStore
type HttpHandler = Callable[[Any, Any], Awaitable[None]]
type WebSocketHandler = Callable[[Any, Any], Awaitable[None]]
type RouteDecorator = Callable[[HttpHandler], HttpHandler]
type WebSocketDecorator = Callable[[WebSocketHandler], WebSocketHandler]
type ASGIApp = Callable[
[MutableMapping[str, Any], Callable[[], Awaitable[Any]], Callable[[MutableMapping[str, Any]], Awaitable[None]]],
Awaitable[None],
]
class SessionMiddleware:
"""ASGI middleware providing server-side HTTP sessions.
The middleware reads a session cookie from each HTTP request, loads the
session data via the configured store, and makes the session available to
Kaya handlers as ``ctx.session``. After the request it persists the session
and refreshes the cookie.
Routing methods are delegated to the wrapped ``KayaApp`` so the middleware
can be used as a drop-in replacement when registering handlers.
Example::
app = KayaApp()
session_app = SessionMiddleware(app, InMemorySessionStore())
@session_app.GET('/')
async def home(ctx: HttpContext):
ctx.session['visits'] = ctx.session.get('visits', 0) + 1
await ctx.send_str(200, f"visits: {ctx.session['visits']}")
"""
def __init__(
self,
app: KayaApp,
store: SessionStore,
cookie_name: str = 'session_id',
path: str = '/',
max_age: Optional[int] = 14 * 24 * 60 * 60,
httponly: bool = True,
secure: bool = False,
samesite: Optional[str] = 'Lax',
) -> None:
self._app = app
self._store = store
self._cookie_name = cookie_name
self._path = path
self._max_age = max_age
self._httponly = httponly
self._secure = secure
self._samesite = samesite
def route(
self,
paths: str | Sequence[str],
methods: Optional[HttpMethod | Sequence[HttpMethod]] = None,
recursive: bool = False,
) -> RouteDecorator:
return self._app.route(paths, methods, recursive)
def GET(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.GET(path, recursive)
def POST(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.POST(path, recursive)
def PUT(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.PUT(path, recursive)
def DELETE(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.DELETE(path, recursive)
def OPTIONS(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.OPTIONS(path, recursive)
def HEAD(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.HEAD(path, recursive)
def PATCH(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.PATCH(path, recursive)
def websocket(self, path: str, recursive: bool = False) -> WebSocketDecorator:
return self._app.websocket(path, recursive)
async def __call__(
self,
scope: MutableMapping[str, Any],
receive: Callable[[], Awaitable[Any]],
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
) -> None:
if scope['type'] != 'http':
await cast(ASGIApp, self._app)(scope, receive, send)
return
session_id = self._extract_session_id(scope)
session: Session
if session_id is not None:
loaded = await self._store.load(session_id)
session = loaded if loaded is not None else Session()
else:
session = Session()
state = scope.get('state')
if not isinstance(state, dict):
state = {}
scope['state'] = state
state['kaya_session'] = session
async def wrapped_send(message: MutableMapping[str, Any]) -> None:
if message['type'] == 'http.response.start':
message = dict(message)
final_session_id = self._finalize_session_id(session)
if final_session_id is not None:
cookie_value = format_set_cookie(
self._cookie_name,
final_session_id,
path=self._path,
max_age=0 if session.invalidated else self._max_age,
httponly=self._httponly,
secure=self._secure,
samesite=self._samesite,
)
headers = list(message.get('headers', []))
headers.append((b'Set-Cookie', cookie_value.encode()))
message['headers'] = headers
await send(message)
try:
await cast(ASGIApp, self._app)(scope, receive, wrapped_send)
finally:
await self._persist(session)
def _extract_session_id(self, scope: MutableMapping[str, Any]) -> Optional[str]:
headers = scope.get('headers', [])
for key, value in headers:
key_bytes: bytes = key if isinstance(key, bytes) else key.encode()
if key_bytes.lower() == b'cookie':
value_str: str = value.decode() if isinstance(value, bytes) else value
return parse_cookie_value(value_str, self._cookie_name)
return None
def _finalize_session_id(self, session: Session) -> Optional[str]:
if session.invalidated:
return session.id
if session.id is None:
if session.modified or session.regenerate:
session.set_id(self._store.new_session_id())
elif session.regenerate:
session._old_id = session.id
session.set_id(self._store.new_session_id())
session._regenerate = False
return session.id
async def _persist(self, session: Session) -> None:
if session.invalidated:
old_id = session._old_id or session.id
if old_id is not None:
await self._store.delete(old_id)
return
if session._old_id is not None and session._old_id != session.id:
await self._store.delete(session._old_id)
session._old_id = None
if session.id is None and session.modified:
session.set_id(self._store.new_session_id())
if session.id is not None:
await self._store.save(session.id, session)
@@ -0,0 +1,82 @@
from typing import Any, Iterator, Mapping, MutableMapping, Optional
class Session(MutableMapping[str, Any]):
"""Dict-like session container with modification tracking.
The middleware uses the ``modified``, ``regenerate`` and ``invalidated``
flags to decide whether to persist the session, rotate its ID, or delete
it.
"""
def __init__(self, session_id: Optional[str] = None, data: Optional[Mapping[str, Any]] = None) -> None:
self._id: Optional[str] = session_id
self._old_id: Optional[str] = None
self._data: dict[str, Any] = dict(data) if data else {}
self._modified: bool = False
self._regenerate: bool = False
self._invalidated: bool = False
@property
def id(self) -> Optional[str]:
return self._id
def set_id(self, session_id: str) -> None:
self._id = session_id
@property
def modified(self) -> bool:
return self._modified
@property
def regenerate(self) -> bool:
return self._regenerate
@property
def invalidated(self) -> bool:
return self._invalidated
def regenerate_id(self) -> None:
"""Mark the session for ID rotation.
This is intended for authentication layers to defend against session
fixation: the middleware will create a new session ID, move the data to
it, and delete the old store entry.
"""
self._old_id = self._id
self._id = None
self._regenerate = True
self._modified = True
def invalidate(self) -> None:
"""Mark the session for deletion.
The middleware will clear the stored data and send an expired cookie.
"""
self._invalidated = True
self._modified = True
self._data.clear()
def mark_modified(self) -> None:
self._modified = True
def __getitem__(self, key: str) -> Any:
return self._data[key]
def __setitem__(self, key: str, value: Any) -> None:
self._data[key] = value
self._modified = True
def __delitem__(self, key: str) -> None:
del self._data[key]
self._modified = True
def __iter__(self) -> Iterator[str]:
return iter(self._data)
def __len__(self) -> int:
return len(self._data)
def clear(self) -> None:
self._data.clear()
self._modified = True
@@ -0,0 +1,54 @@
from abc import ABC, abstractmethod
from secrets import token_urlsafe
from typing import Any, Optional
from ._session import Session
class SessionStore(ABC):
"""Pluggable backend for session persistence."""
@abstractmethod
async def load(self, session_id: str) -> Optional[Session]:
"""Load an existing session, or return ``None`` if unknown/expired."""
pass
@abstractmethod
async def save(self, session_id: str, session: Session) -> None:
"""Persist the session data under ``session_id``."""
pass
@abstractmethod
async def delete(self, session_id: str) -> None:
"""Remove the session from the store."""
pass
def new_session_id(self) -> str:
"""Return a new opaque session identifier.
Subclasses may override this to use a backend-specific ID generator.
"""
return token_urlsafe(32)
class InMemorySessionStore(SessionStore):
"""Simple in-memory session store.
Suitable for development and single-process deployments. Session data is
lost when the process exits and is not shared between processes.
"""
def __init__(self) -> None:
self._sessions: dict[str, dict[str, Any]] = {}
async def load(self, session_id: str) -> Optional[Session]:
data = self._sessions.get(session_id)
if data is None:
return None
return Session(session_id, data)
async def save(self, session_id: str, session: Session) -> None:
self._sessions[session_id] = dict(session)
async def delete(self, session_id: str) -> None:
self._sessions.pop(session_id, None)