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:
Matt Johnson 2026-05-17 05:30:49 +00:00
commit f059f982bc
25 changed files with 1758 additions and 16 deletions

View file

@ -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)