Add kaya-session package for server-side HTTP session management
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user