mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-22 10:34:43 +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
|
|
@ -33,6 +33,9 @@ class Settings(BaseSettings):
|
|||
default="INFO",
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
"""Central GUI — FastAPI + Jinja2 + HTMX."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from central.gui.routes import router
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Template and static directories
|
||||
GUI_DIR = Path(__file__).parent
|
||||
|
|
@ -17,17 +21,108 @@ STATIC_DIR = GUI_DIR / "static"
|
|||
# Jinja2 templates instance (shared with routes)
|
||||
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(
|
||||
title="Central",
|
||||
description="Central Data Hub GUI",
|
||||
docs_url=None, # Disable Swagger UI for now
|
||||
redoc_url=None, # Disable ReDoc for now
|
||||
title="Central GUI",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# 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():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
|
@ -37,10 +132,47 @@ def create_app() -> FastAPI:
|
|||
return app
|
||||
|
||||
|
||||
# Application instance
|
||||
app = create_app()
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""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:
|
||||
"""Entry point for central-gui console script."""
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000)
|
||||
"""Entry point for central-gui command."""
|
||||
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."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
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()
|
||||
|
||||
|
||||
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")
|
||||
async def health() -> dict:
|
||||
"""Health check endpoint."""
|
||||
|
|
@ -15,9 +64,304 @@ async def health() -> dict:
|
|||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
"""Render the index page."""
|
||||
from central.gui import templates
|
||||
|
||||
templates = _get_templates()
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
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 %}
|
||||
</head>
|
||||
<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">
|
||||
{% 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 %}
|
||||
</main>
|
||||
</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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue