mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
feat(gui): add auth core, setup gate, and first-run operator creation
- Add migrations 007-010 for system config, operators, sessions, audit_log - Implement argon2id password hashing via argon2-cffi - Implement session-based authentication with database-stored tokens - Add SetupGateMiddleware to redirect to /setup until first operator created - Add SessionMiddleware to load session from cookie and attach operator - Create /setup, /login, /logout, /change-password routes with CSRF protection - Add periodic session cleanup task (hourly) - Add audit logging for auth events - Update systemd unit with EnvironmentFile for /etc/central/central.env - Add comprehensive tests for auth, middleware, and audit modules Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
afde118d35
commit
f059f982bc
25 changed files with 1758 additions and 16 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,3 +13,4 @@ db.env
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.ssh/
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,34 @@ journalctl -u central-archive -f
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Environment variables are stored in `/etc/central/central.env` and loaded by
|
||||||
|
systemd services via `EnvironmentFile=`.
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `CENTRAL_CSRF_SECRET` | Yes (for GUI) | Secret key for CSRF token signing. Generate with `python3 -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
||||||
|
|
||||||
|
### Generating CSRF Secret
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the generated value to `/etc/central/central.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CENTRAL_CSRF_SECRET=<generated-secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure the file has restricted permissions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chmod 640 /etc/central/central.env
|
||||||
|
sudo chown central:central /etc/central/central.env
|
||||||
|
```
|
||||||
|
|
||||||
PostgreSQL 16 with TimescaleDB runs on CT104:
|
PostgreSQL 16 with TimescaleDB runs on CT104:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@ license = {text = "MIT"}
|
||||||
authors = [{name = "Matt Johnson"}]
|
authors = [{name = "Matt Johnson"}]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp>=3.13.5",
|
"aiohttp>=3.13.5",
|
||||||
|
"argon2-cffi>=25.1.0",
|
||||||
"asyncpg>=0.31.0",
|
"asyncpg>=0.31.0",
|
||||||
"cloudevents>=2.0.0",
|
"cloudevents>=2.0.0",
|
||||||
"cryptography>=44.0.0",
|
"cryptography>=44.0.0",
|
||||||
|
"fastapi-csrf-protect>=0.4.0",
|
||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
"nats-py>=2.14.0",
|
"nats-py>=2.14.0",
|
||||||
|
|
|
||||||
21
sql/migrations/007_add_config_system.sql
Normal file
21
sql/migrations/007_add_config_system.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- Migration 007: Add config.system table for global settings
|
||||||
|
-- Idempotent per docs/migrations.md
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config.system (
|
||||||
|
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id = true),
|
||||||
|
setup_complete BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
session_lifetime_days INTEGER NOT NULL DEFAULT 90,
|
||||||
|
map_tile_url TEXT NOT NULL DEFAULT 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
map_attribution TEXT NOT NULL DEFAULT '© OpenStreetMap contributors',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reuse existing set_updated_at trigger function
|
||||||
|
DROP TRIGGER IF EXISTS system_set_updated_at ON config.system;
|
||||||
|
CREATE TRIGGER system_set_updated_at
|
||||||
|
BEFORE UPDATE ON config.system
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION config.set_updated_at();
|
||||||
|
|
||||||
|
-- Seed single row
|
||||||
|
INSERT INTO config.system (id) VALUES (true) ON CONFLICT DO NOTHING;
|
||||||
10
sql/migrations/008_add_operators.sql
Normal file
10
sql/migrations/008_add_operators.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Migration 008: Add config.operators table for user accounts
|
||||||
|
-- Idempotent per docs/migrations.md
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config.operators (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
password_changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
11
sql/migrations/009_add_sessions.sql
Normal file
11
sql/migrations/009_add_sessions.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Migration 009: Add config.sessions table for auth tokens
|
||||||
|
-- Idempotent per docs/migrations.md
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config.sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
operator_id BIGINT NOT NULL REFERENCES config.operators(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON config.sessions(expires_at);
|
||||||
15
sql/migrations/010_add_audit_log.sql
Normal file
15
sql/migrations/010_add_audit_log.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- Migration 010: Add config.audit_log table
|
||||||
|
-- Idempotent per docs/migrations.md
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config.audit_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
operator_id BIGINT REFERENCES config.operators(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT,
|
||||||
|
before JSONB,
|
||||||
|
after JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_log_ts_idx ON config.audit_log(ts DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_log_action_idx ON config.audit_log(action);
|
||||||
|
|
@ -33,6 +33,9 @@ class Settings(BaseSettings):
|
||||||
default="INFO",
|
default="INFO",
|
||||||
description="Logging level",
|
description="Logging level",
|
||||||
)
|
)
|
||||||
|
csrf_secret: str = Field(
|
||||||
|
description="Secret key for CSRF token signing (generate with: python -c \"import secrets; print(secrets.token_urlsafe(32))\")",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
"""Central GUI — FastAPI + Jinja2 + HTMX."""
|
"""Central GUI — FastAPI + Jinja2 + HTMX."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from central.gui.routes import router
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Template and static directories
|
# Template and static directories
|
||||||
GUI_DIR = Path(__file__).parent
|
GUI_DIR = Path(__file__).parent
|
||||||
|
|
@ -17,17 +21,108 @@ STATIC_DIR = GUI_DIR / "static"
|
||||||
# Jinja2 templates instance (shared with routes)
|
# Jinja2 templates instance (shared with routes)
|
||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
||||||
|
# Shutdown event and cleanup task
|
||||||
|
_shutdown_event: asyncio.Event | None = None
|
||||||
|
_cleanup_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
# Lazy app singleton
|
||||||
|
_app: FastAPI | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_csrf() -> None:
|
||||||
|
"""Configure CSRF protection. Must be called before app starts."""
|
||||||
|
from fastapi_csrf_protect import CsrfProtect
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from central.bootstrap_config import get_settings
|
||||||
|
|
||||||
|
class CsrfSettings(BaseModel):
|
||||||
|
secret_key: str
|
||||||
|
|
||||||
|
@CsrfProtect.load_config
|
||||||
|
def get_csrf_config():
|
||||||
|
settings = get_settings()
|
||||||
|
return CsrfSettings(secret_key=settings.csrf_secret)
|
||||||
|
|
||||||
|
|
||||||
|
async def _session_cleanup_loop() -> None:
|
||||||
|
"""Periodically clean up expired sessions."""
|
||||||
|
global _shutdown_event
|
||||||
|
|
||||||
|
from central.gui.db import get_pool
|
||||||
|
|
||||||
|
if _shutdown_event is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
while not _shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_shutdown_event.wait(), timeout=3600)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
try:
|
||||||
|
pool = get_pool()
|
||||||
|
if pool:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
"DELETE FROM config.sessions WHERE expires_at < now()"
|
||||||
|
)
|
||||||
|
deleted = result.split()[-1] if result else "0"
|
||||||
|
if int(deleted) > 0:
|
||||||
|
logger.info("Session cleanup", extra={"deleted": deleted})
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Session cleanup failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application lifespan handler."""
|
||||||
|
global _shutdown_event, _cleanup_task
|
||||||
|
|
||||||
|
from central.bootstrap_config import get_settings
|
||||||
|
from central.gui.db import close_pool, init_pool
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Initialize database pool
|
||||||
|
await init_pool(settings.db_dsn)
|
||||||
|
|
||||||
|
# Start session cleanup task
|
||||||
|
_shutdown_event = asyncio.Event()
|
||||||
|
_cleanup_task = asyncio.create_task(_session_cleanup_loop())
|
||||||
|
|
||||||
|
logger.info("Central GUI started")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
if _shutdown_event:
|
||||||
|
_shutdown_event.set()
|
||||||
|
if _cleanup_task:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_cleanup_task, timeout=5.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_cleanup_task.cancel()
|
||||||
|
|
||||||
|
await close_pool()
|
||||||
|
logger.info("Central GUI stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_app() -> FastAPI:
|
||||||
|
"""Create the FastAPI application."""
|
||||||
|
from central.gui.middleware import SessionMiddleware, SetupGateMiddleware
|
||||||
|
from central.gui.routes import router
|
||||||
|
|
||||||
|
# Configure CSRF before creating app
|
||||||
|
_configure_csrf()
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
|
||||||
"""Create and configure the FastAPI application."""
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Central",
|
title="Central GUI",
|
||||||
description="Central Data Hub GUI",
|
lifespan=lifespan,
|
||||||
docs_url=None, # Disable Swagger UI for now
|
|
||||||
redoc_url=None, # Disable ReDoc for now
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mount static files if directory exists and has content
|
# Add middleware (order matters - first added runs last)
|
||||||
|
app.add_middleware(SessionMiddleware)
|
||||||
|
app.add_middleware(SetupGateMiddleware)
|
||||||
|
|
||||||
|
# Mount static files
|
||||||
if STATIC_DIR.exists():
|
if STATIC_DIR.exists():
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
|
@ -37,10 +132,47 @@ def create_app() -> FastAPI:
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
# Application instance
|
def __getattr__(name: str) -> Any:
|
||||||
app = create_app()
|
"""Lazy attribute access for app singleton."""
|
||||||
|
global _app
|
||||||
|
if name == "app":
|
||||||
|
if _app is None:
|
||||||
|
_app = _create_app()
|
||||||
|
return _app
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Entry point for central-gui console script."""
|
"""Entry point for central-gui command."""
|
||||||
uvicorn.run(app, host="127.0.0.1", port=8000)
|
import logging.config
|
||||||
|
|
||||||
|
logging.config.dictConfig({
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"format": "%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"level": "INFO",
|
||||||
|
"handlers": ["console"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"central.gui:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8088,
|
||||||
|
reload=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
37
src/central/gui/audit.py
Normal file
37
src/central/gui/audit.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Audit logging for Central GUI."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Audit action constants
|
||||||
|
AUTH_LOGIN = "auth.login"
|
||||||
|
AUTH_LOGIN_FAILED = "auth.login_failed"
|
||||||
|
AUTH_LOGOUT = "auth.logout"
|
||||||
|
AUTH_PASSWORD_CHANGE = "auth.password_change"
|
||||||
|
OPERATOR_CREATE = "operator.create"
|
||||||
|
|
||||||
|
|
||||||
|
async def write_audit(
|
||||||
|
conn: Any, # asyncpg.Connection
|
||||||
|
action: str,
|
||||||
|
operator_id: int | None = None,
|
||||||
|
target: str | None = None,
|
||||||
|
before: dict[str, Any] | None = None,
|
||||||
|
after: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write an audit log entry."""
|
||||||
|
# Serialize before/after as JSON strings if provided
|
||||||
|
before_json = json.dumps(before) if before else None
|
||||||
|
after_json = json.dumps(after) if after else None
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO config.audit_log (operator_id, action, target, before, after)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
|
||||||
|
""",
|
||||||
|
operator_id,
|
||||||
|
action,
|
||||||
|
target,
|
||||||
|
before_json,
|
||||||
|
after_json,
|
||||||
|
)
|
||||||
138
src/central/gui/auth.py
Normal file
138
src/central/gui/auth.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Authentication utilities for Central GUI."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
|
||||||
|
# Use argon2-cffi defaults (argon2id)
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Operator:
|
||||||
|
"""Operator account."""
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
created_at: datetime
|
||||||
|
password_changed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
"""Hash a password using argon2id."""
|
||||||
|
return _hasher.hash(plain)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
"""Verify a password against its hash."""
|
||||||
|
try:
|
||||||
|
_hasher.verify(hashed, plain)
|
||||||
|
return True
|
||||||
|
except VerifyMismatchError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password(plain: str) -> None:
|
||||||
|
"""Validate password meets requirements. Raises ValueError if invalid."""
|
||||||
|
if len(plain) < 8:
|
||||||
|
raise ValueError("Password must be at least 8 characters")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token() -> str:
|
||||||
|
"""Generate a cryptographically secure session token."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_session(
|
||||||
|
conn: Any, # asyncpg.Connection
|
||||||
|
operator_id: int,
|
||||||
|
lifetime_days: int,
|
||||||
|
) -> tuple[str, datetime]:
|
||||||
|
"""Create a new session for an operator.
|
||||||
|
|
||||||
|
Returns (token, expires_at).
|
||||||
|
"""
|
||||||
|
token = generate_token()
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(days=lifetime_days)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO config.sessions (token, operator_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
""",
|
||||||
|
token,
|
||||||
|
operator_id,
|
||||||
|
expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
return token, expires_at
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session(conn: Any, token: str) -> Operator | None:
|
||||||
|
"""Look up a session and return the associated operator.
|
||||||
|
|
||||||
|
Returns None if token is invalid or expired.
|
||||||
|
"""
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT o.id, o.username, o.created_at, o.password_changed_at
|
||||||
|
FROM config.sessions s
|
||||||
|
JOIN config.operators o ON s.operator_id = o.id
|
||||||
|
WHERE s.token = $1 AND s.expires_at > now()
|
||||||
|
""",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Operator(
|
||||||
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
password_changed_at=row.get("password_changed_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_session(conn: Any, token: str) -> None:
|
||||||
|
"""Delete a session."""
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM config.sessions WHERE token = $1",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_operator_by_username(conn: Any, username: str) -> dict | None:
|
||||||
|
"""Get an operator by username.
|
||||||
|
|
||||||
|
Returns the row dict or None if not found.
|
||||||
|
"""
|
||||||
|
return await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT id, username, password_hash, created_at, password_changed_at
|
||||||
|
FROM config.operators
|
||||||
|
WHERE username = $1
|
||||||
|
""",
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_operator(conn: Any, username: str, password: str) -> int:
|
||||||
|
"""Create a new operator.
|
||||||
|
|
||||||
|
Returns the new operator ID.
|
||||||
|
"""
|
||||||
|
password_hash = hash_password(password)
|
||||||
|
row = await conn.fetchval(
|
||||||
|
"""
|
||||||
|
INSERT INTO config.operators (username, password_hash)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
)
|
||||||
|
return row
|
||||||
48
src/central/gui/db.py
Normal file
48
src/central/gui/db.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Database connection pool for GUI."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
# Module-level pool instance
|
||||||
|
_pool: asyncpg.Pool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Deduplicate with central.config_store._setup_json_codec
|
||||||
|
async def _setup_json_codec(conn: asyncpg.Connection) -> None:
|
||||||
|
"""Set up JSON codec for asyncpg connection."""
|
||||||
|
await conn.set_type_codec(
|
||||||
|
"jsonb",
|
||||||
|
encoder=json.dumps,
|
||||||
|
decoder=json.loads,
|
||||||
|
schema="pg_catalog",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def init_pool(dsn: str) -> asyncpg.Pool:
|
||||||
|
"""Initialize the connection pool."""
|
||||||
|
global _pool
|
||||||
|
if _pool is None:
|
||||||
|
_pool = await asyncpg.create_pool(
|
||||||
|
dsn,
|
||||||
|
min_size=1,
|
||||||
|
max_size=5,
|
||||||
|
init=_setup_json_codec,
|
||||||
|
)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
def get_pool() -> asyncpg.Pool:
|
||||||
|
"""Get the connection pool. Must call init_pool first."""
|
||||||
|
if _pool is None:
|
||||||
|
raise RuntimeError("Database pool not initialized. Call init_pool first.")
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def close_pool() -> None:
|
||||||
|
"""Close the connection pool."""
|
||||||
|
global _pool
|
||||||
|
if _pool is not None:
|
||||||
|
await _pool.close()
|
||||||
|
_pool = None
|
||||||
96
src/central/gui/middleware.py
Normal file
96
src/central/gui/middleware.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Middleware for Central GUI."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import RedirectResponse, Response
|
||||||
|
|
||||||
|
from central.gui.auth import get_session
|
||||||
|
from central.gui.db import get_pool
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Paths that don't require setup to be complete
|
||||||
|
SETUP_EXEMPT_PATHS = {"/setup", "/health"}
|
||||||
|
SETUP_EXEMPT_PREFIXES = ("/static/",)
|
||||||
|
|
||||||
|
# Paths that don't require authentication
|
||||||
|
AUTH_EXEMPT_PATHS = {"/setup", "/login", "/health"}
|
||||||
|
AUTH_EXEMPT_PREFIXES = ("/static/",)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_exempt(path: str, exempt_paths: set, exempt_prefixes: tuple) -> bool:
|
||||||
|
"""Check if a path is exempt from a check."""
|
||||||
|
if path in exempt_paths:
|
||||||
|
return True
|
||||||
|
for prefix in exempt_prefixes:
|
||||||
|
if path.startswith(prefix):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SetupGateMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Redirect to /setup if setup is not complete."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Check setup status from database
|
||||||
|
pool = get_pool()
|
||||||
|
if pool is None:
|
||||||
|
# Pool not initialized yet
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
setup_complete = False
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT setup_complete FROM config.system WHERE id = true"
|
||||||
|
)
|
||||||
|
setup_complete = row["setup_complete"] if row else False
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to check setup status", exc_info=True)
|
||||||
|
# On error, allow the request through
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if not setup_complete:
|
||||||
|
# Setup not complete - only allow exempt paths
|
||||||
|
if not _is_exempt(path, SETUP_EXEMPT_PATHS, SETUP_EXEMPT_PREFIXES):
|
||||||
|
return RedirectResponse(url="/setup", status_code=307)
|
||||||
|
else:
|
||||||
|
# Setup complete - redirect /setup to /
|
||||||
|
if path == "/setup":
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Load session from cookie and attach operator to request.state."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Initialize operator to None
|
||||||
|
request.state.operator = None
|
||||||
|
|
||||||
|
# Try to load session from cookie
|
||||||
|
session_token = request.cookies.get("central_session")
|
||||||
|
if session_token:
|
||||||
|
pool = get_pool()
|
||||||
|
if pool is not None:
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
operator = await get_session(conn, session_token)
|
||||||
|
request.state.operator = operator
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to load session", exc_info=True)
|
||||||
|
request.state.operator = None
|
||||||
|
|
||||||
|
# Check if auth is required
|
||||||
|
if not _is_exempt(path, AUTH_EXEMPT_PATHS, AUTH_EXEMPT_PREFIXES):
|
||||||
|
if request.state.operator is None:
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
@ -1,11 +1,60 @@
|
||||||
"""Route handlers for Central GUI."""
|
"""Route handlers for Central GUI."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||||
|
from fastapi_csrf_protect import CsrfProtect
|
||||||
|
|
||||||
|
from central.gui.auth import (
|
||||||
|
create_session,
|
||||||
|
delete_session,
|
||||||
|
hash_password,
|
||||||
|
validate_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from central.gui.audit import (
|
||||||
|
AUTH_LOGIN,
|
||||||
|
AUTH_LOGIN_FAILED,
|
||||||
|
AUTH_LOGOUT,
|
||||||
|
AUTH_PASSWORD_CHANGE,
|
||||||
|
OPERATOR_CREATE,
|
||||||
|
write_audit,
|
||||||
|
)
|
||||||
|
from central.gui.db import get_pool
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_templates():
|
||||||
|
"""Get templates instance (deferred import to avoid circular)."""
|
||||||
|
from central.gui import templates
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(
|
||||||
|
response: Response,
|
||||||
|
token: str,
|
||||||
|
max_age: int,
|
||||||
|
) -> None:
|
||||||
|
"""Set the session cookie on a response."""
|
||||||
|
response.set_cookie(
|
||||||
|
key="central_session",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=False,
|
||||||
|
max_age=max_age,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_session_cookie(response: Response) -> None:
|
||||||
|
"""Clear the session cookie."""
|
||||||
|
response.delete_cookie(
|
||||||
|
key="central_session",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
|
|
@ -15,9 +64,304 @@ async def health() -> dict:
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request) -> HTMLResponse:
|
async def index(request: Request) -> HTMLResponse:
|
||||||
"""Render the index page."""
|
"""Render the index page."""
|
||||||
from central.gui import templates
|
templates = _get_templates()
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="index.html",
|
name="index.html",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/setup", response_class=HTMLResponse)
|
||||||
|
async def setup_form(
|
||||||
|
request: Request,
|
||||||
|
csrf_protect: CsrfProtect = Depends(),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Render the setup form."""
|
||||||
|
templates = _get_templates()
|
||||||
|
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
|
||||||
|
response = templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="setup.html",
|
||||||
|
context={"csrf_token": signed_token, "error": None},
|
||||||
|
)
|
||||||
|
csrf_protect.set_csrf_cookie(signed_token, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/setup")
|
||||||
|
async def setup_submit(
|
||||||
|
request: Request,
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
confirm_password: str = Form(...),
|
||||||
|
csrf_protect: CsrfProtect = Depends(),
|
||||||
|
) -> Response:
|
||||||
|
"""Process the setup form."""
|
||||||
|
templates = _get_templates()
|
||||||
|
pool = get_pool()
|
||||||
|
|
||||||
|
# Validate CSRF
|
||||||
|
await csrf_protect.validate_csrf(request)
|
||||||
|
|
||||||
|
# Validate input
|
||||||
|
error = None
|
||||||
|
if password != confirm_password:
|
||||||
|
error = "Passwords do not match"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
validate_password(password)
|
||||||
|
except ValueError as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
|
||||||
|
response = templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="setup.html",
|
||||||
|
context={"csrf_token": signed_token, "error": error},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
csrf_protect.set_csrf_cookie(signed_token, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Create operator
|
||||||
|
password_hash = hash_password(password)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO config.operators (username, password_hash)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
)
|
||||||
|
operator_id = row["id"]
|
||||||
|
|
||||||
|
# Write audit log
|
||||||
|
await write_audit(
|
||||||
|
conn,
|
||||||
|
OPERATOR_CREATE,
|
||||||
|
operator_id=operator_id,
|
||||||
|
target=username,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get session lifetime
|
||||||
|
sysrow = await conn.fetchrow(
|
||||||
|
"SELECT session_lifetime_days FROM config.system WHERE id = true"
|
||||||
|
)
|
||||||
|
lifetime_days = sysrow["session_lifetime_days"] if sysrow else 90
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
token, expires_at = await create_session(conn, operator_id, lifetime_days)
|
||||||
|
|
||||||
|
# Mark setup complete
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE config.system SET setup_complete = true WHERE id = true"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect with session cookie
|
||||||
|
response = RedirectResponse(url="/", status_code=302)
|
||||||
|
_set_session_cookie(response, token, lifetime_days * 86400)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_form(
|
||||||
|
request: Request,
|
||||||
|
csrf_protect: CsrfProtect = Depends(),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Render the login form."""
|
||||||
|
templates = _get_templates()
|
||||||
|
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
|
||||||
|
response = templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="login.html",
|
||||||
|
context={"csrf_token": signed_token, "error": None},
|
||||||
|
)
|
||||||
|
csrf_protect.set_csrf_cookie(signed_token, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login_submit(
|
||||||
|
request: Request,
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
csrf_protect: CsrfProtect = Depends(),
|
||||||
|
) -> Response:
|
||||||
|
"""Process the login form."""
|
||||||
|
templates = _get_templates()
|
||||||
|
pool = get_pool()
|
||||||
|
|
||||||
|
# Validate CSRF
|
||||||
|
await csrf_protect.validate_csrf(request)
|
||||||
|
|
||||||
|
# Look up operator
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT id, username, password_hash, created_at, password_changed_at
|
||||||
|
FROM config.operators
|
||||||
|
WHERE username = $1
|
||||||
|
""",
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
# Unknown user - still audit the attempt
|
||||||
|
await write_audit(conn, AUTH_LOGIN_FAILED, target=username)
|
||||||
|
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
|
||||||
|
response = templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="login.html",
|
||||||
|
context={"csrf_token": signed_token, "error": "Invalid username or password"},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
csrf_protect.set_csrf_cookie(signed_token, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Verify password
|
||||||
|
if not verify_password(password, row["password_hash"]):
|
||||||
|
await write_audit(conn, AUTH_LOGIN_FAILED, operator_id=row["id"], target=username)
|
||||||
|
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
|
||||||
|
response = templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="login.html",
|
||||||
|
context={"csrf_token": signed_token, "error": "Invalid username or password"},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
csrf_protect.set_csrf_cookie(signed_token, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Get session lifetime
|
||||||
|
sysrow = await conn.fetchrow(
|
||||||
|
"SELECT session_lifetime_days FROM config.system WHERE id = true"
|
||||||
|
)
|
||||||
|
lifetime_days = sysrow["session_lifetime_days"] if sysrow else 90
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
token, expires_at = await create_session(conn, row["id"], lifetime_days)
|
||||||
|
|
||||||
|
# Audit login
|
||||||
|
await write_audit(conn, AUTH_LOGIN, operator_id=row["id"], target=username)
|
||||||
|
|
||||||
|
# Redirect with session cookie
|
||||||
|
response = RedirectResponse(url="/", status_code=302)
|
||||||
|
_set_session_cookie(response, token, lifetime_days * 86400)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
csrf_protect: CsrfProtect = Depends(),
|
||||||
|
) -> Response:
|
||||||
|
"""Log out the current user."""
|
||||||
|
pool = get_pool()
|
||||||
|
|
||||||
|
# Validate CSRF
|
||||||
|
await csrf_protect.validate_csrf(request)
|
||||||
|
|
||||||
|
# Get current session
|
||||||
|
session_token = request.cookies.get("central_session")
|
||||||
|
operator = getattr(request.state, "operator", None)
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if session_token:
|
||||||
|
await delete_session(conn, session_token)
|
||||||
|
|
||||||
|
if operator:
|
||||||
|
await write_audit(conn, AUTH_LOGOUT, operator_id=operator.id, target=operator.username)
|
||||||
|
|
||||||
|
response = RedirectResponse(url="/login", status_code=302)
|
||||||
|
_clear_session_cookie(response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/change-password", response_class=HTMLResponse)
|
||||||
|
async def change_password_form(
|
||||||
|
request: Request,
|
||||||
|
csrf_protect: CsrfProtect = Depends(),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Render the change password form."""
|
||||||
|
templates = _get_templates()
|
||||||
|
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
|
||||||
|
response = templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="change_password.html",
|
||||||
|
context={"csrf_token": signed_token, "error": None, "success": False},
|
||||||
|
)
|
||||||
|
csrf_protect.set_csrf_cookie(signed_token, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
async def change_password_submit(
|
||||||
|
request: Request,
|
||||||
|
current_password: str = Form(...),
|
||||||
|
new_password: str = Form(...),
|
||||||
|
confirm_password: str = Form(...),
|
||||||
|
csrf_protect: CsrfProtect = Depends(),
|
||||||
|
) -> Response:
|
||||||
|
"""Process the change password form."""
|
||||||
|
templates = _get_templates()
|
||||||
|
pool = get_pool()
|
||||||
|
operator = request.state.operator
|
||||||
|
|
||||||
|
# Validate CSRF
|
||||||
|
await csrf_protect.validate_csrf(request)
|
||||||
|
|
||||||
|
# Get current password hash
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT password_hash FROM config.operators WHERE id = $1",
|
||||||
|
operator.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
error = None
|
||||||
|
|
||||||
|
# Verify current password
|
||||||
|
if not verify_password(current_password, row["password_hash"]):
|
||||||
|
error = "Current password is incorrect"
|
||||||
|
elif new_password != confirm_password:
|
||||||
|
error = "New passwords do not match"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
validate_password(new_password)
|
||||||
|
except ValueError as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
|
||||||
|
response = templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="change_password.html",
|
||||||
|
context={"csrf_token": signed_token, "error": error, "success": False},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
csrf_protect.set_csrf_cookie(signed_token, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
new_hash = hash_password(new_password)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE config.operators
|
||||||
|
SET password_hash = $1, password_changed_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
""",
|
||||||
|
new_hash,
|
||||||
|
operator.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
await write_audit(
|
||||||
|
conn,
|
||||||
|
AUTH_PASSWORD_CHANGE,
|
||||||
|
operator_id=operator.id,
|
||||||
|
target=operator.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect to index
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,36 @@
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<nav class="container">
|
||||||
|
<ul>
|
||||||
|
<li><strong>Central</strong></li>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
{% if operator %}
|
||||||
|
<li>{{ operator.username }}</li>
|
||||||
|
<li><a href="/change-password">Change Password</a></li>
|
||||||
|
<li>
|
||||||
|
<form action="/logout" method="post" style="margin: 0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="outline">Logout</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="/login">Login</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
|
{% if error %}
|
||||||
|
<article aria-label="Error">
|
||||||
|
<p style="color: var(--pico-color-red-500);">{{ error }}</p>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
{% if success %}
|
||||||
|
<article aria-label="Success">
|
||||||
|
<p style="color: var(--pico-color-green-500);">{{ success }}</p>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
36
src/central/gui/templates/change_password.html
Normal file
36
src/central/gui/templates/change_password.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Central - Change Password{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>Change Password</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form action="/change-password" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<label for="current_password">
|
||||||
|
Current Password
|
||||||
|
<input type="password" id="current_password" name="current_password" required
|
||||||
|
autocomplete="current-password" autofocus>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="new_password">
|
||||||
|
New Password
|
||||||
|
<input type="password" id="new_password" name="new_password" required
|
||||||
|
autocomplete="new-password" minlength="8">
|
||||||
|
<small>Minimum 8 characters</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="confirm_password">
|
||||||
|
Confirm New Password
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password" required
|
||||||
|
autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Change Password</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
29
src/central/gui/templates/login.html
Normal file
29
src/central/gui/templates/login.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Central - Login{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>Login</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form action="/login" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<label for="username">
|
||||||
|
Username
|
||||||
|
<input type="text" id="username" name="username" required
|
||||||
|
autocomplete="username" autofocus>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="password">
|
||||||
|
Password
|
||||||
|
<input type="password" id="password" name="password" required
|
||||||
|
autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
37
src/central/gui/templates/setup.html
Normal file
37
src/central/gui/templates/setup.html
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Central - Setup{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>Central First-Time Setup</h1>
|
||||||
|
<p>Create the initial operator account to get started.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form action="/setup" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<label for="username">
|
||||||
|
Username
|
||||||
|
<input type="text" id="username" name="username" required
|
||||||
|
autocomplete="username" autofocus>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="password">
|
||||||
|
Password
|
||||||
|
<input type="password" id="password" name="password" required
|
||||||
|
autocomplete="new-password" minlength="8">
|
||||||
|
<small>Minimum 8 characters</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="confirm_password">
|
||||||
|
Confirm Password
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password" required
|
||||||
|
autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Create Operator</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -9,6 +9,7 @@ User=central
|
||||||
Group=central
|
Group=central
|
||||||
WorkingDirectory=/opt/central
|
WorkingDirectory=/opt/central
|
||||||
Environment=HOME=/opt/central
|
Environment=HOME=/opt/central
|
||||||
|
EnvironmentFile=/etc/central/central.env
|
||||||
ExecStart=/opt/central/.venv/bin/central-gui
|
ExecStart=/opt/central/.venv/bin/central-gui
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
@ -18,3 +19,6 @@ ProtectHome=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
|
||||||
50
tests/conftest.py
Normal file
50
tests/conftest.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""Shared fixtures for auth tests."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from central.bootstrap_config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
"""Create an event loop for the test session."""
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings():
|
||||||
|
"""Create mock settings for testing."""
|
||||||
|
return Settings(
|
||||||
|
db_dsn="postgresql://test:test@localhost/test",
|
||||||
|
nats_url="nats://localhost:4222",
|
||||||
|
csrf_secret="test-csrf-secret-for-testing-only-32chars",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_pool():
|
||||||
|
"""Create a mock database pool."""
|
||||||
|
pool = MagicMock()
|
||||||
|
pool.acquire = MagicMock()
|
||||||
|
pool.close = AsyncMock()
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_conn():
|
||||||
|
"""Create a mock database connection."""
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.fetchrow = AsyncMock()
|
||||||
|
conn.fetchval = AsyncMock()
|
||||||
|
conn.execute = AsyncMock()
|
||||||
|
return conn
|
||||||
92
tests/test_audit.py
Normal file
92
tests/test_audit.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""Tests for audit log module."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from central.gui.audit import (
|
||||||
|
write_audit,
|
||||||
|
AUTH_LOGIN,
|
||||||
|
AUTH_LOGIN_FAILED,
|
||||||
|
AUTH_LOGOUT,
|
||||||
|
AUTH_PASSWORD_CHANGE,
|
||||||
|
OPERATOR_CREATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditConstants:
|
||||||
|
"""Tests for audit action constants."""
|
||||||
|
|
||||||
|
def test_auth_login(self):
|
||||||
|
assert AUTH_LOGIN == "auth.login"
|
||||||
|
|
||||||
|
def test_auth_login_failed(self):
|
||||||
|
assert AUTH_LOGIN_FAILED == "auth.login_failed"
|
||||||
|
|
||||||
|
def test_auth_logout(self):
|
||||||
|
assert AUTH_LOGOUT == "auth.logout"
|
||||||
|
|
||||||
|
def test_auth_password_change(self):
|
||||||
|
assert AUTH_PASSWORD_CHANGE == "auth.password_change"
|
||||||
|
|
||||||
|
def test_operator_create(self):
|
||||||
|
assert OPERATOR_CREATE == "operator.create"
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteAudit:
|
||||||
|
"""Tests for write_audit function."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_audit_basic(self):
|
||||||
|
"""write_audit inserts basic audit record."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = AsyncMock()
|
||||||
|
|
||||||
|
await write_audit(mock_conn, action="auth.login", operator_id=1)
|
||||||
|
|
||||||
|
mock_conn.execute.assert_called_once()
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
assert "INSERT INTO config.audit_log" in call_args[0][0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_audit_with_target(self):
|
||||||
|
"""write_audit includes target when provided."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = AsyncMock()
|
||||||
|
|
||||||
|
await write_audit(
|
||||||
|
mock_conn,
|
||||||
|
action="operator.create",
|
||||||
|
operator_id=1,
|
||||||
|
target="newuser",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_conn.execute.assert_called_once()
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
# target is the 3rd positional arg (after operator_id and action)
|
||||||
|
assert "newuser" in call_args[0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_audit_with_before_after(self):
|
||||||
|
"""write_audit includes before/after when provided."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = AsyncMock()
|
||||||
|
|
||||||
|
await write_audit(
|
||||||
|
mock_conn,
|
||||||
|
action="config.update",
|
||||||
|
operator_id=1,
|
||||||
|
before={"value": "old"},
|
||||||
|
after={"value": "new"},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_conn.execute.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_audit_no_operator(self):
|
||||||
|
"""write_audit works with operator_id=None."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = AsyncMock()
|
||||||
|
|
||||||
|
await write_audit(mock_conn, action="auth.login_failed", operator_id=None)
|
||||||
|
|
||||||
|
mock_conn.execute.assert_called_once()
|
||||||
183
tests/test_auth.py
Normal file
183
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
"""Tests for auth module."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from central.gui.auth import (
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
validate_password,
|
||||||
|
generate_token,
|
||||||
|
create_session,
|
||||||
|
get_session,
|
||||||
|
delete_session,
|
||||||
|
get_operator_by_username,
|
||||||
|
create_operator,
|
||||||
|
Operator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordHashing:
|
||||||
|
"""Tests for password hashing functions."""
|
||||||
|
|
||||||
|
def test_hash_password_returns_string(self):
|
||||||
|
"""hash_password returns a string."""
|
||||||
|
result = hash_password("testpassword")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_hash_password_includes_argon2id(self):
|
||||||
|
"""hash_password uses argon2id algorithm."""
|
||||||
|
result = hash_password("testpassword")
|
||||||
|
assert result.startswith("$argon2id$")
|
||||||
|
|
||||||
|
def test_hash_password_different_each_time(self):
|
||||||
|
"""hash_password produces different hashes for same password."""
|
||||||
|
hash1 = hash_password("testpassword")
|
||||||
|
hash2 = hash_password("testpassword")
|
||||||
|
assert hash1 != hash2
|
||||||
|
|
||||||
|
def test_verify_password_correct(self):
|
||||||
|
"""verify_password returns True for correct password."""
|
||||||
|
password = "testpassword"
|
||||||
|
hashed = hash_password(password)
|
||||||
|
assert verify_password(password, hashed) is True
|
||||||
|
|
||||||
|
def test_verify_password_incorrect(self):
|
||||||
|
"""verify_password returns False for wrong password."""
|
||||||
|
hashed = hash_password("testpassword")
|
||||||
|
assert verify_password("wrongpassword", hashed) is False
|
||||||
|
|
||||||
|
def test_verify_password_empty(self):
|
||||||
|
"""verify_password handles empty strings."""
|
||||||
|
hashed = hash_password("testpassword")
|
||||||
|
assert verify_password("", hashed) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordValidation:
|
||||||
|
"""Tests for password validation."""
|
||||||
|
|
||||||
|
def test_valid_password(self):
|
||||||
|
"""validate_password passes for valid password."""
|
||||||
|
validate_password("password123") # No exception
|
||||||
|
|
||||||
|
def test_short_password(self):
|
||||||
|
"""validate_password raises for short password."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
validate_password("short")
|
||||||
|
assert "8 characters" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenGeneration:
|
||||||
|
"""Tests for token generation."""
|
||||||
|
|
||||||
|
def test_generate_token_length(self):
|
||||||
|
"""generate_token produces expected length."""
|
||||||
|
token = generate_token()
|
||||||
|
# URL-safe base64 of 32 bytes is 43 characters
|
||||||
|
assert len(token) == 43
|
||||||
|
|
||||||
|
def test_generate_token_unique(self):
|
||||||
|
"""generate_token produces unique tokens."""
|
||||||
|
tokens = [generate_token() for _ in range(100)]
|
||||||
|
assert len(set(tokens)) == 100
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionManagement:
|
||||||
|
"""Tests for session creation and retrieval."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_session(self):
|
||||||
|
"""create_session inserts a session record."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = AsyncMock()
|
||||||
|
|
||||||
|
token, expires_at = await create_session(mock_conn, operator_id=1, lifetime_days=90)
|
||||||
|
|
||||||
|
assert len(token) == 43
|
||||||
|
mock_conn.execute.assert_called_once()
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
assert "INSERT INTO config.sessions" in call_args[0][0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_session_found(self):
|
||||||
|
"""get_session returns Operator when session exists."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value={
|
||||||
|
"id": 1,
|
||||||
|
"username": "testuser",
|
||||||
|
"created_at": datetime.now(timezone.utc),
|
||||||
|
"password_changed_at": datetime.now(timezone.utc),
|
||||||
|
})
|
||||||
|
|
||||||
|
operator = await get_session(mock_conn, "valid-token")
|
||||||
|
|
||||||
|
assert operator is not None
|
||||||
|
assert operator.id == 1
|
||||||
|
assert operator.username == "testuser"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_session_not_found(self):
|
||||||
|
"""get_session returns None when session doesn\'t exist."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
operator = await get_session(mock_conn, "invalid-token")
|
||||||
|
|
||||||
|
assert operator is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_session(self):
|
||||||
|
"""delete_session removes the session."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = AsyncMock()
|
||||||
|
|
||||||
|
await delete_session(mock_conn, "some-token")
|
||||||
|
|
||||||
|
mock_conn.execute.assert_called_once()
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
assert "DELETE FROM config.sessions" in call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOperatorManagement:
|
||||||
|
"""Tests for operator creation and retrieval."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_operator_by_username_found(self):
|
||||||
|
"""get_operator_by_username returns operator when found."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value={
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"password_hash": "somehash",
|
||||||
|
"created_at": datetime.now(timezone.utc),
|
||||||
|
"password_changed_at": datetime.now(timezone.utc),
|
||||||
|
})
|
||||||
|
|
||||||
|
result = await get_operator_by_username(mock_conn, "admin")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["username"] == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_operator_by_username_not_found(self):
|
||||||
|
"""get_operator_by_username returns None when not found."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
result = await get_operator_by_username(mock_conn, "nonexistent")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_operator(self):
|
||||||
|
"""create_operator inserts and returns operator ID."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchval = AsyncMock(return_value=1)
|
||||||
|
|
||||||
|
operator_id = await create_operator(mock_conn, "newuser", "password123")
|
||||||
|
|
||||||
|
assert operator_id == 1
|
||||||
|
mock_conn.fetchval.assert_called_once()
|
||||||
|
call_args = mock_conn.fetchval.call_args
|
||||||
|
assert "INSERT INTO config.operators" in call_args[0][0]
|
||||||
173
tests/test_session_auth.py
Normal file
173
tests/test_session_auth.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""Tests for session authentication middleware."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
from central.gui.middleware import SessionMiddleware
|
||||||
|
from central.gui.auth import Operator
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionMiddleware:
|
||||||
|
"""Tests for SessionMiddleware."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_cookie_sets_none_on_exempt_path(self):
|
||||||
|
"""SessionMiddleware sets operator=None when no session cookie on exempt path."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire = MagicMock()
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health(request: Request):
|
||||||
|
return {"operator": getattr(request.state, "operator", "missing")}
|
||||||
|
|
||||||
|
app.add_middleware(SessionMiddleware)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["operator"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_valid_cookie_sets_operator_on_exempt_path(self):
|
||||||
|
"""SessionMiddleware sets operator when valid session cookie on exempt path."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value={
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"created_at": datetime.now(timezone.utc),
|
||||||
|
"password_changed_at": datetime.now(timezone.utc),
|
||||||
|
})
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health(request: Request):
|
||||||
|
op = getattr(request.state, "operator", None)
|
||||||
|
if op:
|
||||||
|
return {"username": op.username}
|
||||||
|
return {"operator": None}
|
||||||
|
|
||||||
|
app.add_middleware(SessionMiddleware)
|
||||||
|
client = TestClient(app, cookies={"central_session": "valid-token"})
|
||||||
|
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["username"] == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_cookie_redirects_on_protected_path(self):
|
||||||
|
"""SessionMiddleware redirects to /login when no cookie on protected path."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire = MagicMock()
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index(request: Request):
|
||||||
|
return {"message": "home"}
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def login():
|
||||||
|
return {"message": "login"}
|
||||||
|
|
||||||
|
app.add_middleware(SessionMiddleware)
|
||||||
|
client = TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/login"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_valid_cookie_allows_protected_path(self):
|
||||||
|
"""SessionMiddleware allows protected path with valid session."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value={
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"created_at": datetime.now(timezone.utc),
|
||||||
|
"password_changed_at": datetime.now(timezone.utc),
|
||||||
|
})
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index(request: Request):
|
||||||
|
op = request.state.operator
|
||||||
|
return {"message": "home", "user": op.username}
|
||||||
|
|
||||||
|
app.add_middleware(SessionMiddleware)
|
||||||
|
client = TestClient(app, cookies={"central_session": "valid-token"})
|
||||||
|
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["user"] == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_cookie_redirects_on_protected_path(self):
|
||||||
|
"""SessionMiddleware redirects when session is invalid/expired."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value=None) # No session found
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index(request: Request):
|
||||||
|
return {"operator": getattr(request.state, "operator", "missing")}
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def login():
|
||||||
|
return {"message": "login"}
|
||||||
|
|
||||||
|
app.add_middleware(SessionMiddleware)
|
||||||
|
client = TestClient(app, cookies={"central_session": "expired-token"}, follow_redirects=False)
|
||||||
|
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/login"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_middleware_handles_db_error(self):
|
||||||
|
"""SessionMiddleware handles database errors gracefully on exempt path."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(side_effect=Exception("DB error"))
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health(request: Request):
|
||||||
|
return {"operator": getattr(request.state, "operator", "missing")}
|
||||||
|
|
||||||
|
app.add_middleware(SessionMiddleware)
|
||||||
|
client = TestClient(app, cookies={"central_session": "some-token"})
|
||||||
|
|
||||||
|
response = client.get("/health")
|
||||||
|
# Should not crash, just set operator to None
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["operator"] is None
|
||||||
162
tests/test_setup_gate.py
Normal file
162
tests/test_setup_gate.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Tests for setup gate middleware."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from central.gui.middleware import SetupGateMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetupGateMiddleware:
|
||||||
|
"""Tests for SetupGateMiddleware."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allows_setup_route_when_incomplete(self):
|
||||||
|
"""SetupGateMiddleware allows /setup when setup_complete=False."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value={"setup_complete": False})
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/setup")
|
||||||
|
async def setup():
|
||||||
|
return {"message": "setup"}
|
||||||
|
|
||||||
|
app.add_middleware(SetupGateMiddleware)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/setup")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "setup"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allows_health_when_incomplete(self):
|
||||||
|
"""SetupGateMiddleware allows /health regardless of setup state."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value={"setup_complete": False})
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
app.add_middleware(SetupGateMiddleware)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redirects_other_routes_when_incomplete(self):
|
||||||
|
"""SetupGateMiddleware redirects non-setup routes when setup_complete=False."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value={"setup_complete": False})
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index():
|
||||||
|
return {"message": "home"}
|
||||||
|
|
||||||
|
@app.get("/setup")
|
||||||
|
async def setup():
|
||||||
|
return {"message": "setup"}
|
||||||
|
|
||||||
|
app.add_middleware(SetupGateMiddleware)
|
||||||
|
client = TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 307
|
||||||
|
assert response.headers["location"] == "/setup"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allows_all_routes_when_complete(self):
|
||||||
|
"""SetupGateMiddleware allows all routes when setup_complete=True."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value={"setup_complete": True})
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index():
|
||||||
|
return {"message": "home"}
|
||||||
|
|
||||||
|
app.add_middleware(SetupGateMiddleware)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "home"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allows_static_when_incomplete(self):
|
||||||
|
"""SetupGateMiddleware allows /static routes when setup_complete=False."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value={"setup_complete": False})
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/static/test.css")
|
||||||
|
async def static():
|
||||||
|
return "css"
|
||||||
|
|
||||||
|
app.add_middleware(SetupGateMiddleware)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/static/test.css")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redirects_setup_when_complete(self):
|
||||||
|
"""SetupGateMiddleware redirects /setup to / when setup_complete=True."""
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(return_value={"setup_complete": True})
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index():
|
||||||
|
return {"message": "home"}
|
||||||
|
|
||||||
|
@app.get("/setup")
|
||||||
|
async def setup():
|
||||||
|
return {"message": "setup"}
|
||||||
|
|
||||||
|
app.add_middleware(SetupGateMiddleware)
|
||||||
|
client = TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
response = client.get("/setup")
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
61
uv.lock
generated
61
uv.lock
generated
|
|
@ -89,6 +89,39 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2-cffi"
|
||||||
|
version = "25.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "argon2-cffi-bindings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2-cffi-bindings"
|
||||||
|
version = "25.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ast-serialize"
|
name = "ast-serialize"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|
@ -143,10 +176,12 @@ version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
{ name = "argon2-cffi" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
{ name = "cloudevents" },
|
{ name = "cloudevents" },
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "fastapi-csrf-protect" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "nats-py" },
|
{ name = "nats-py" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
|
|
@ -169,10 +204,12 @@ dev = [
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiohttp", specifier = ">=3.13.5" },
|
{ name = "aiohttp", specifier = ">=3.13.5" },
|
||||||
|
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||||
{ name = "cloudevents", specifier = ">=2.0.0" },
|
{ name = "cloudevents", specifier = ">=2.0.0" },
|
||||||
{ name = "cryptography", specifier = ">=44.0.0" },
|
{ name = "cryptography", specifier = ">=44.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||||
|
{ name = "fastapi-csrf-protect", specifier = ">=0.4.0" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||||
{ name = "nats-py", specifier = ">=2.14.0" },
|
{ name = "nats-py", specifier = ">=2.14.0" },
|
||||||
{ name = "pydantic", specifier = ">=2,<3" },
|
{ name = "pydantic", specifier = ">=2,<3" },
|
||||||
|
|
@ -325,6 +362,21 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi-csrf-protect"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "itsdangerous" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/1a/fedbcb4aba24ccc8abfb5d30e08112073c6a9f20b8d88adbdd3051ceedac/fastapi_csrf_protect-1.0.7.tar.gz", hash = "sha256:888b15b232625aae5b997fbcf81ef45633a7694f0312a054f1eec6d132b295fb", size = 207326, upload-time = "2025-09-16T07:06:08.586Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/10/f248aab919678444723d557da918088e5c737b44e03e3aa4a0ad7afc7dae/fastapi_csrf_protect-1.0.7-py3-none-any.whl", hash = "sha256:ca3c5b50564af932ac4ed3d06caeed61bf16eed13a31cfe2bdfc3f7c1e8612a3", size = 18412, upload-time = "2025-09-16T07:06:05.926Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "frozenlist"
|
name = "frozenlist"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
|
@ -420,6 +472,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsdangerous"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jinja2"
|
name = "jinja2"
|
||||||
version = "3.1.6"
|
version = "3.1.6"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue