Add kaya-session package for server-side HTTP session management
This commit is contained in:
@@ -25,13 +25,17 @@ jobs:
|
|||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv/bin/pip install -r requirements-dev.txt
|
.venv/bin/pip install -r requirements-dev.txt
|
||||||
- name: Check kaya-core
|
- name: Check kaya-core
|
||||||
run: |
|
|
||||||
.venv/bin/python -m mypy -p kaya.rsgi
|
|
||||||
.venv/bin/python -m unittest discover -s packages/kaya-rsgi/tests
|
|
||||||
- name: Check kaya-rsgi
|
|
||||||
run: |
|
run: |
|
||||||
.venv/bin/python -m mypy -p kaya.core
|
.venv/bin/python -m mypy -p kaya.core
|
||||||
.venv/bin/python -m unittest discover -s packages/kaya-core/tests
|
.venv/bin/python -m unittest discover -s packages/kaya-core/tests
|
||||||
|
- name: Check kaya-rsgi
|
||||||
|
run: |
|
||||||
|
.venv/bin/python -m mypy -p kaya.rsgi
|
||||||
|
.venv/bin/python -m unittest discover -s packages/kaya-rsgi/tests
|
||||||
|
- name: Check kaya-session
|
||||||
|
run: |
|
||||||
|
.venv/bin/python -m mypy -p kaya.session
|
||||||
|
.venv/bin/python -m unittest discover -s packages/kaya-session/tests
|
||||||
- name: Publish kaya-core artifacts
|
- name: Publish kaya-core artifacts
|
||||||
env:
|
env:
|
||||||
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
||||||
@@ -48,3 +52,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
.venv/bin/pyproject-build packages/kaya-rsgi
|
.venv/bin/pyproject-build packages/kaya-rsgi
|
||||||
.venv/bin/twine upload --repository gitea packages/kaya-rsgi/dist/*.whl packages/kaya-rsgi/dist/*.tar.gz
|
.venv/bin/twine upload --repository gitea packages/kaya-rsgi/dist/*.whl packages/kaya-rsgi/dist/*.tar.gz
|
||||||
|
- name: Publish kaya-session artifacts
|
||||||
|
env:
|
||||||
|
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
||||||
|
TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }}
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }}
|
||||||
|
run: |
|
||||||
|
.venv/bin/pyproject-build packages/kaya-session
|
||||||
|
.venv/bin/twine upload --repository gitea packages/kaya-session/dist/*.whl packages/kaya-session/dist/*.tar.gz
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ This repository is a monorepo for the Kaya framework. The code is split into ind
|
|||||||
|
|
||||||
- **kaya-core** — core routing, HTTP/WS abstractions, and ASGI adapter (`packages/kaya-core/`)
|
- **kaya-core** — core routing, HTTP/WS abstractions, and ASGI adapter (`packages/kaya-core/`)
|
||||||
- **kaya-rsgi** — RSGI/Granian integration (`packages/kaya-rsgi/`)
|
- **kaya-rsgi** — RSGI/Granian integration (`packages/kaya-rsgi/`)
|
||||||
|
- **kaya-session** — server-side HTTP session management (`packages/kaya-session/`)
|
||||||
|
|
||||||
Additional `kaya-*` packages can be added as new directories under `packages/`.
|
Additional `kaya-*` packages can be added as new directories under `packages/`.
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/si
|
|||||||
Install the packages in development mode:
|
Install the packages in development mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e packages/kaya-core -e packages/kaya-rsgi
|
pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the example:
|
Run the example:
|
||||||
@@ -36,6 +37,7 @@ python example/hello.py
|
|||||||
```bash
|
```bash
|
||||||
python -m unittest discover -s packages/kaya-core/tests
|
python -m unittest discover -s packages/kaya-core/tests
|
||||||
python -m unittest discover -s packages/kaya-rsgi/tests
|
python -m unittest discover -s packages/kaya-rsgi/tests
|
||||||
|
python -m unittest discover -s packages/kaya-session/tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Static analysis
|
## Static analysis
|
||||||
@@ -43,6 +45,7 @@ python -m unittest discover -s packages/kaya-rsgi/tests
|
|||||||
```bash
|
```bash
|
||||||
mypy -p kaya.core
|
mypy -p kaya.core
|
||||||
mypy -p kaya.rsgi
|
mypy -p kaya.rsgi
|
||||||
|
mypy -p kaya.session
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building packages
|
## Building packages
|
||||||
@@ -50,4 +53,5 @@ mypy -p kaya.rsgi
|
|||||||
```bash
|
```bash
|
||||||
python -m build packages/kaya-core
|
python -m build packages/kaya-core
|
||||||
python -m build packages/kaya-rsgi
|
python -m build packages/kaya-rsgi
|
||||||
|
python -m build packages/kaya-session
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from kaya.core import HttpContext, KayaApp
|
||||||
|
from kaya.session import InMemorySessionStore, SessionMiddleware
|
||||||
|
|
||||||
|
app = SessionMiddleware(KayaApp(), InMemorySessionStore())
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET('/')
|
||||||
|
async def home(ctx: HttpContext) -> None:
|
||||||
|
visits = ctx.session.get('visits', 0) + 1
|
||||||
|
ctx.session['visits'] = visits
|
||||||
|
await ctx.send_str(200, f'visits: {visits}')
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET('/read')
|
||||||
|
async def read(ctx: HttpContext) -> None:
|
||||||
|
visits = ctx.session.get('visits', 0)
|
||||||
|
await ctx.send_str(200, f'visits: {visits}')
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET('/clear')
|
||||||
|
async def clear(ctx: HttpContext) -> None:
|
||||||
|
ctx.session.invalidate()
|
||||||
|
await ctx.send_str(200, 'session cleared')
|
||||||
|
|
||||||
|
@app.websocket('/echo')
|
||||||
|
async def echo(ws: WebSocket) -> None:
|
||||||
|
await ws.accept()
|
||||||
|
async for msg in ws:
|
||||||
|
if msg.kind == 'text':
|
||||||
|
await ws.send_text(f"echo: {msg.data}")
|
||||||
|
elif msg.kind == 'binary':
|
||||||
|
data = msg.data
|
||||||
|
assert isinstance(data, bytes)
|
||||||
|
await ws.send_bytes(data)
|
||||||
|
|
||||||
|
@app.websocket('/ws/visits')
|
||||||
|
async def ws_visits(ws: WebSocket) -> None:
|
||||||
|
# WebSocket handlers can read the existing session. Most ASGI servers
|
||||||
|
# (including Granian and Daphne) do not forward the `headers` field of the
|
||||||
|
# `websocket.accept` message into the HTTP 101 response, so a new session
|
||||||
|
# cookie cannot be set during the handshake. Use the HTTP `/` endpoint to
|
||||||
|
# set or refresh the session cookie before connecting here.
|
||||||
|
await ws.accept()
|
||||||
|
visits = ws.session.get('visits', 0)
|
||||||
|
await ws.send_text(f'visits: {visits}')
|
||||||
@@ -88,6 +88,7 @@ class AsgiContext(HttpContext):
|
|||||||
self.server = scope['server']
|
self.server = scope['server']
|
||||||
self.headers = decode_headers(scope['headers'])
|
self.headers = decode_headers(scope['headers'])
|
||||||
self.request_body = request_body_iterator
|
self.request_body = request_body_iterator
|
||||||
|
self.session = (scope.get('state') or {}).get('kaya_session')
|
||||||
|
|
||||||
async def stream_body(self,
|
async def stream_body(self,
|
||||||
status: int,
|
status: int,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class HttpContext(ABC):
|
|||||||
client: Optional[Tuple[str, int]]
|
client: Optional[Tuple[str, int]]
|
||||||
server: Optional[Tuple[str, Optional[int]]]
|
server: Optional[Tuple[str, Optional[int]]]
|
||||||
request_body: AsyncIterator[bytes]
|
request_body: AsyncIterator[bytes]
|
||||||
|
session: Optional[Any] = None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def stream_body(self,
|
async def stream_body(self,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# kaya-session
|
||||||
|
|
||||||
|
Session management for the Kaya web framework.
|
||||||
|
|
||||||
|
Provides server-side, identity-agnostic HTTP sessions via a session cookie. The
|
||||||
|
session data is accessible from request handlers as `ctx.session`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kaya.core import KayaApp, HttpContext
|
||||||
|
from kaya.session import SessionMiddleware, InMemorySessionStore
|
||||||
|
|
||||||
|
app = KayaApp()
|
||||||
|
session_app = SessionMiddleware(app, InMemorySessionStore())
|
||||||
|
|
||||||
|
@session_app.GET('/')
|
||||||
|
async def home(ctx: HttpContext):
|
||||||
|
n = ctx.session.get('visits', 0) + 1
|
||||||
|
ctx.session['visits'] = n
|
||||||
|
await ctx.send_str(200, f'visits: {n}')
|
||||||
|
```
|
||||||
|
|
||||||
|
Sessions are created lazily: a cookie is only set when the handler modifies the
|
||||||
|
session.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `Session`: dict-like session object with modification tracking
|
||||||
|
- `SessionStore`: abstract store interface
|
||||||
|
- `InMemorySessionStore`: simple in-memory store for development/single-process
|
||||||
|
- `SessionMiddleware`: ASGI middleware managing session cookies and persistence
|
||||||
|
- Session ID regeneration (`session.regenerate_id()`) and invalidation
|
||||||
|
(`session.invalidate()`) for future authentication layers
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This release supports HTTP requests only; WebSocket and RSGI propagation is
|
||||||
|
planned for future releases.
|
||||||
|
- `InMemorySessionStore` does not survive process restarts and is not shared
|
||||||
|
across processes. Production deployments should use a store backed by a shared
|
||||||
|
storage system (planned).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "kaya-session"
|
||||||
|
dynamic = ["version"]
|
||||||
|
authors = [
|
||||||
|
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
|
||||||
|
]
|
||||||
|
description = "Session management for the Kaya lightweight ASGI web framework"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = "MIT"
|
||||||
|
classifiers = [
|
||||||
|
'Development Status :: 3 - Alpha',
|
||||||
|
'Topic :: Utilities',
|
||||||
|
'Intended Audience :: System Administrators',
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'Environment :: Console',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"kaya-core",
|
||||||
|
"pwo",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"build", "mypy", "ipdb", "twine", "httpx", "httpx-ws"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Homepage" = "https://github.com/woggioni/kaya"
|
||||||
|
"Bug Tracker" = "https://github.com/woggioni/kaya/issues"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
show_error_codes = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
exclude = ["scripts", "docs", "test"]
|
||||||
|
strict = true
|
||||||
|
|
||||||
|
[tool.setuptools_scm]
|
||||||
|
root = "../.."
|
||||||
|
version_file = "src/kaya/session/_version.py"
|
||||||
|
|
||||||
|
[tool.setuptools_scm.tag]
|
||||||
|
prefix = "release/"
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import unittest
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pwo import async_test
|
||||||
|
from kaya.core import KayaApp, HttpContext
|
||||||
|
|
||||||
|
from kaya.session import InMemorySessionStore, Session, SessionMiddleware, SessionStore
|
||||||
|
from kaya.session._cookie import format_set_cookie, parse_cookie_value
|
||||||
|
|
||||||
|
|
||||||
|
class SessionTest(unittest.TestCase):
|
||||||
|
app: KayaApp
|
||||||
|
store: InMemorySessionStore
|
||||||
|
session_app: SessionMiddleware
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.app = KayaApp()
|
||||||
|
self.store = InMemorySessionStore()
|
||||||
|
self.session_app = SessionMiddleware(self.app, self.store)
|
||||||
|
|
||||||
|
@self.session_app.GET('/')
|
||||||
|
async def home(ctx: HttpContext) -> None:
|
||||||
|
n = ctx.session.get('visits', 0) + 1
|
||||||
|
ctx.session['visits'] = n
|
||||||
|
await ctx.send_str(200, f'visits: {n}')
|
||||||
|
|
||||||
|
@self.session_app.GET('/read')
|
||||||
|
async def read(ctx: HttpContext) -> None:
|
||||||
|
n = ctx.session.get('visits', 0)
|
||||||
|
await ctx.send_str(200, f'visits: {n}')
|
||||||
|
|
||||||
|
@self.session_app.GET('/write')
|
||||||
|
async def write(ctx: HttpContext) -> None:
|
||||||
|
ctx.session['foo'] = 'bar'
|
||||||
|
await ctx.send_str(200, 'ok')
|
||||||
|
|
||||||
|
@self.session_app.GET('/clear')
|
||||||
|
async def clear(ctx: HttpContext) -> None:
|
||||||
|
ctx.session.invalidate()
|
||||||
|
await ctx.send_str(200, 'cleared')
|
||||||
|
|
||||||
|
@self.session_app.GET('/rotate')
|
||||||
|
async def rotate(ctx: HttpContext) -> None:
|
||||||
|
ctx.session.regenerate_id()
|
||||||
|
await ctx.send_str(200, 'rotated')
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_session_persists_across_requests(self) -> None:
|
||||||
|
transport = httpx.ASGITransport(app=self.session_app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||||
|
r = await client.get('/')
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('visits: 1', r.text)
|
||||||
|
self.assertIn('Set-Cookie', r.headers)
|
||||||
|
|
||||||
|
r = await client.get('/')
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('visits: 2', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_no_cookie_when_session_not_modified(self) -> None:
|
||||||
|
transport = httpx.ASGITransport(app=self.session_app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||||
|
r = await client.get('/read')
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('visits: 0', r.text)
|
||||||
|
self.assertNotIn('Set-Cookie', r.headers)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_existing_session_refreshes_cookie(self) -> None:
|
||||||
|
transport = httpx.ASGITransport(app=self.session_app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||||
|
await client.get('/')
|
||||||
|
r = await client.get('/read')
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertIn('Set-Cookie', r.headers)
|
||||||
|
self.assertEqual('visits: 1', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_cookie_attributes(self) -> None:
|
||||||
|
transport = httpx.ASGITransport(app=self.session_app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||||
|
r = await client.get('/')
|
||||||
|
set_cookie = r.headers['Set-Cookie']
|
||||||
|
self.assertIn('HttpOnly', set_cookie)
|
||||||
|
self.assertIn('SameSite=Lax', set_cookie)
|
||||||
|
self.assertIn('Path=/', set_cookie)
|
||||||
|
self.assertIn('Max-Age=', set_cookie)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_sessions_are_isolated(self) -> None:
|
||||||
|
transport = httpx.ASGITransport(app=self.session_app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||||
|
r1 = await client.get('/')
|
||||||
|
r2 = await client.get('/')
|
||||||
|
self.assertEqual('visits: 1', r1.text)
|
||||||
|
self.assertEqual('visits: 2', r2.text)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||||
|
r = await client.get('/')
|
||||||
|
self.assertEqual('visits: 1', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_invalidate(self) -> None:
|
||||||
|
transport = httpx.ASGITransport(app=self.session_app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||||
|
await client.get('/')
|
||||||
|
r = await client.get('/clear')
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('cleared', r.text)
|
||||||
|
set_cookie = r.headers['Set-Cookie']
|
||||||
|
self.assertIn('Max-Age=0', set_cookie)
|
||||||
|
|
||||||
|
r = await client.get('/')
|
||||||
|
self.assertEqual('visits: 1', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_regenerate_id(self) -> None:
|
||||||
|
transport = httpx.ASGITransport(app=self.session_app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||||
|
await client.get('/')
|
||||||
|
cookies_before = {c.name: c.value for c in client.cookies.jar}
|
||||||
|
old_id = cookies_before.get('session_id')
|
||||||
|
self.assertIsNotNone(old_id)
|
||||||
|
self.assertIn(old_id, self.store._sessions)
|
||||||
|
|
||||||
|
r = await client.get('/rotate')
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('rotated', r.text)
|
||||||
|
|
||||||
|
cookies_after = {c.name: c.value for c in client.cookies.jar}
|
||||||
|
new_id = cookies_after.get('session_id')
|
||||||
|
self.assertIsNotNone(new_id)
|
||||||
|
self.assertNotEqual(old_id, new_id)
|
||||||
|
self.assertNotIn(old_id, self.store._sessions)
|
||||||
|
self.assertIn(new_id, self.store._sessions)
|
||||||
|
|
||||||
|
r = await client.get('/read')
|
||||||
|
self.assertEqual('visits: 1', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_invalid_cookie_creates_fresh_session(self) -> None:
|
||||||
|
transport = httpx.ASGITransport(app=self.session_app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||||
|
client.cookies.set('session_id', 'not-a-real-id')
|
||||||
|
r = await client.get('/')
|
||||||
|
self.assertEqual('visits: 1', r.text)
|
||||||
|
self.assertIn('Set-Cookie', r.headers)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionUnitTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_session_is_dict_like(self) -> None:
|
||||||
|
session = Session()
|
||||||
|
session['a'] = 1
|
||||||
|
self.assertEqual(1, session['a'])
|
||||||
|
self.assertTrue('a' in session)
|
||||||
|
self.assertEqual({'a': 1}, dict(session))
|
||||||
|
self.assertTrue(session.modified)
|
||||||
|
|
||||||
|
def test_session_modification_tracking(self) -> None:
|
||||||
|
session = Session(data={'a': 1})
|
||||||
|
self.assertFalse(session.modified)
|
||||||
|
session['a'] = 2
|
||||||
|
self.assertTrue(session.modified)
|
||||||
|
|
||||||
|
def test_session_read_does_not_mark_modified(self) -> None:
|
||||||
|
session = Session(data={'a': 1})
|
||||||
|
self.assertFalse(session.modified)
|
||||||
|
_ = session['a']
|
||||||
|
self.assertFalse(session.modified)
|
||||||
|
|
||||||
|
def test_session_clear_marks_modified(self) -> None:
|
||||||
|
session = Session(data={'a': 1})
|
||||||
|
self.assertFalse(session.modified)
|
||||||
|
session.clear()
|
||||||
|
self.assertTrue(session.modified)
|
||||||
|
self.assertEqual(0, len(session))
|
||||||
|
|
||||||
|
def test_session_invalidate(self) -> None:
|
||||||
|
session = Session(session_id='abc', data={'a': 1})
|
||||||
|
self.assertFalse(session.modified)
|
||||||
|
session.invalidate()
|
||||||
|
self.assertTrue(session.invalidated)
|
||||||
|
self.assertTrue(session.modified)
|
||||||
|
self.assertEqual(0, len(session))
|
||||||
|
|
||||||
|
def test_session_regenerate_id(self) -> None:
|
||||||
|
session = Session(session_id='abc', data={'a': 1})
|
||||||
|
session.regenerate_id()
|
||||||
|
self.assertTrue(session.regenerate)
|
||||||
|
self.assertIsNone(session.id)
|
||||||
|
self.assertEqual('abc', session._old_id)
|
||||||
|
self.assertTrue(session.modified)
|
||||||
|
|
||||||
|
def test_session_store_is_abstract(self) -> None:
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
SessionStore() # type: ignore[abstract]
|
||||||
|
|
||||||
|
def test_in_memory_store_round_trip(self) -> None:
|
||||||
|
store = InMemorySessionStore()
|
||||||
|
session_id = store.new_session_id()
|
||||||
|
session = Session(session_id, {'a': 1})
|
||||||
|
self.assertIsNone(store._sessions.get(session_id))
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
asyncio.run(store.save(session_id, session))
|
||||||
|
|
||||||
|
loaded = asyncio.run(store.load(session_id))
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
assert loaded is not None
|
||||||
|
self.assertEqual(1, loaded['a'])
|
||||||
|
|
||||||
|
asyncio.run(store.delete(session_id))
|
||||||
|
self.assertIsNone(asyncio.run(store.load(session_id)))
|
||||||
|
|
||||||
|
|
||||||
|
class CookieUtilTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_parse_cookie_value(self) -> None:
|
||||||
|
self.assertEqual('bar', parse_cookie_value('foo=bar; baz=qux', 'foo'))
|
||||||
|
self.assertEqual('qux', parse_cookie_value('foo=bar; baz=qux', 'baz'))
|
||||||
|
self.assertIsNone(parse_cookie_value('foo=bar', 'missing'))
|
||||||
|
self.assertIsNone(parse_cookie_value('', 'foo'))
|
||||||
|
|
||||||
|
def test_format_set_cookie(self) -> None:
|
||||||
|
value = format_set_cookie('sid', 'abc123', path='/', max_age=3600, httponly=True, secure=True, samesite='Strict')
|
||||||
|
self.assertIn('sid=abc123', value)
|
||||||
|
self.assertIn('Path=/', value)
|
||||||
|
self.assertIn('Max-Age=3600', value)
|
||||||
|
self.assertIn('HttpOnly', value)
|
||||||
|
self.assertIn('Secure', value)
|
||||||
|
self.assertIn('SameSite=Strict', value)
|
||||||
|
|
||||||
|
def test_format_set_cookie_without_secure(self) -> None:
|
||||||
|
value = format_set_cookie('sid', 'abc123', max_age=3600)
|
||||||
|
self.assertIn('sid=abc123', value)
|
||||||
|
self.assertIn('HttpOnly', value)
|
||||||
|
self.assertNotIn('Secure', value)
|
||||||
|
self.assertIn('SameSite=Lax', value)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
kaya-core @ file:./packages/kaya-core
|
kaya-core @ file:./packages/kaya-core
|
||||||
kaya-rsgi @ file:./packages/kaya-rsgi
|
kaya-rsgi @ file:./packages/kaya-rsgi
|
||||||
|
kaya-session @ file:./packages/kaya-session
|
||||||
build
|
build
|
||||||
mypy
|
mypy
|
||||||
ipdb
|
ipdb
|
||||||
|
|||||||
@@ -83,8 +83,11 @@ file:./packages/kaya-core
|
|||||||
# via
|
# via
|
||||||
# -r requirements-dev.in
|
# -r requirements-dev.in
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
|
# kaya-session
|
||||||
file:./packages/kaya-rsgi
|
file:./packages/kaya-rsgi
|
||||||
# via -r requirements-dev.in
|
# via -r requirements-dev.in
|
||||||
|
file:./packages/kaya-session
|
||||||
|
# via -r requirements-dev.in
|
||||||
keyring==25.7.0
|
keyring==25.7.0
|
||||||
# via twine
|
# via twine
|
||||||
librt==0.13.0
|
librt==0.13.0
|
||||||
|
|||||||
Reference in New Issue
Block a user