stage-26: hidden /admin gated by TOTP (authenticator-app 2FA)

A hidden /admin path (not in nav) protected by a TOTP secret you enroll
by scanning a QR into Google Authenticator / Authy, then entering the
rotating 6-digit code. adminauth.py persists the secret + session key
under DATA_DIR (gitignored); the QR only renders until first successful
verification so the provisioning secret isn't perpetually exposed.
SessionMiddleware carries a 60-min admin session. This becomes the
secured control center the rest of the system gets built into.

Verified end-to-end: gate renders QR, the live code authenticates and
sets the session, the dashboard renders only with the session cookie,
a wrong code is rejected, and an unauthenticated request never leaks
the dashboard. Deps: pyotp, qrcode[pil], itsdangerous.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-23 00:35:02 +02:00
parent 73a932d8be
commit abdf5e7747
6 changed files with 211 additions and 1 deletions

View File

@@ -19,6 +19,9 @@ dependencies = [
"structlog>=24.1",
"sqlalchemy>=2.0",
"python-dotenv>=1.0",
"pyotp>=2.9",
"qrcode[pil]>=7.4",
"itsdangerous>=2.1",
]
[project.optional-dependencies]

View File

@@ -0,0 +1,88 @@
"""Admin gate — TOTP (authenticator-app) auth for the hidden /admin zone.
A single TOTP secret is provisioned by scanning a QR code into an authenticator
app (Google Authenticator, Authy, …). After that, entry to /admin requires the
current rotating 6-digit code. Secret + session key persist under DATA_DIR so
they survive restarts; the file is gitignored with the rest of data/.
"""
from __future__ import annotations
import base64
import io
import json
import secrets
from pathlib import Path
from typing import Tuple
import pyotp
import qrcode
from psyc import DATA_DIR, log
_log = log.get(__name__)
_STATE_PATH = DATA_DIR / "admin_auth.json"
_ISSUER = "psyc"
_ACCOUNT = "admin"
def _load() -> dict:
if _STATE_PATH.exists():
return json.loads(_STATE_PATH.read_text())
state = {
"totp_secret": pyotp.random_base32(),
"session_secret": secrets.token_urlsafe(32),
"provisioned": False,
}
_save(state)
_log.info("adminauth.initialized", path=str(_STATE_PATH))
return state
def _save(state: dict) -> None:
_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
_STATE_PATH.write_text(json.dumps(state, indent=2))
def session_secret() -> str:
return _load()["session_secret"]
def is_provisioned() -> bool:
return bool(_load().get("provisioned"))
def mark_provisioned() -> None:
state = _load()
if not state.get("provisioned"):
state["provisioned"] = True
_save(state)
_log.info("adminauth.provisioned")
def verify(code: str) -> bool:
"""Check a 6-digit TOTP code (±1 window for clock skew)."""
secret = _load()["totp_secret"]
ok = pyotp.TOTP(secret).verify(code.strip(), valid_window=1)
if ok:
mark_provisioned()
_log.info("adminauth.verify", ok=ok)
return ok
def provisioning_qr_data_uri() -> Tuple[str, str]:
"""Return (otpauth_uri, data:image/png;base64 QR) for authenticator setup."""
secret = _load()["totp_secret"]
uri = pyotp.TOTP(secret).provisioning_uri(name=_ACCOUNT, issuer_name=_ISSUER)
img = qrcode.make(uri)
buf = io.BytesIO()
img.save(buf, format="PNG")
data_uri = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
return uri, data_uri
def current_code() -> str:
"""The code right now — used only by tests / local verification, never shown."""
return pyotp.TOTP(_load()["totp_secret"]).now()

View File

@@ -9,9 +9,10 @@ from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from psyc import db, log
from psyc.cockpit import inference, journey as journey_view
from psyc.cockpit import adminauth, inference, journey as journey_view
from psyc.lines import courier as courier_line
from psyc.lines import ledger as ledger_line
from psyc.lines import lookup as lookup_line
@@ -30,9 +31,14 @@ log.configure()
_log = log.get(__name__)
app = FastAPI(title="psyc Operations Cockpit", version="0.1.0")
app.add_middleware(SessionMiddleware, secret_key=adminauth.session_secret(), max_age=3600)
app.mount("/static", StaticFiles(directory=str(HERE / "static")), name="static")
def _admin_ok(request: Request) -> bool:
return bool(request.session.get("admin_ok"))
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
cases = db.list_cases(limit=200)
@@ -174,6 +180,33 @@ def response_reject(action_id: int, approver: str = Form("operator"), reason: st
return RedirectResponse("/response", status_code=303)
# ---------- hidden admin zone (TOTP-gated) -------------------------------
@app.get("/admin", response_class=HTMLResponse)
def admin_home(request: Request) -> HTMLResponse:
if _admin_ok(request):
return TEMPLATES.TemplateResponse(request, "admin.html", {})
# Not authenticated — show the gate. QR only until first provisioned.
ctx = {"provisioned": adminauth.is_provisioned(), "error": request.query_params.get("error", "")}
if not ctx["provisioned"]:
_, ctx["qr"] = adminauth.provisioning_qr_data_uri()
return TEMPLATES.TemplateResponse(request, "admin_gate.html", ctx)
@app.post("/admin/verify")
def admin_verify(request: Request, code: str = Form(...)) -> RedirectResponse:
if adminauth.verify(code):
request.session["admin_ok"] = True
return RedirectResponse("/admin", status_code=303)
return RedirectResponse("/admin?error=1", status_code=303)
@app.get("/admin/logout")
def admin_logout(request: Request) -> RedirectResponse:
request.session.pop("admin_ok", None)
return RedirectResponse("/admin", status_code=303)
@app.get("/queue", response_class=HTMLResponse)
def queue_view(request: Request, status: str = "pending") -> HTMLResponse:
from psyc.models import ApprovalStatus

View File

@@ -375,3 +375,33 @@ tr.sev-low .sev-badge { color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
.disco-strobe, .disco-bolt, .ioc-fly { animation: none; }
}
/* ── admin gate (TOTP) ──────────────────────────────────────── */
.gate-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.gate-card {
width: 360px; max-width: 100%; text-align: center; padding: 32px 28px;
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 0 40px rgba(0,0,0,0.5); position: relative;
}
.gate-card::before, .gate-card::after { content: ""; position: absolute; width: 16px; height: 16px; border: 2px solid var(--accent); opacity: 0.6; }
.gate-card::before { top: 8px; left: 8px; border-right: none; border-bottom: none; }
.gate-card::after { bottom: 8px; right: 8px; border-left: none; border-top: none; }
.gate-logo { width: 56px; height: 56px; filter: drop-shadow(0 0 10px var(--accent-glow)); }
.gate-title { font-family: var(--font-display); font-size: 22px; margin: 12px 0 4px; letter-spacing: 0.08em; text-shadow: 0 0 16px var(--accent-glow); }
.gate-sub { color: var(--muted); font-size: 13px; margin: 0 0 18px; }
.gate-setup { margin: 16px 0; padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; }
.gate-step { font-size: 12px; color: var(--text); margin: 0 0 12px; }
.gate-qr { width: 168px; height: 168px; border-radius: 6px; background: #fff; padding: 6px; }
.gate-error { color: var(--red); font-size: 13px; margin: 12px 0; }
.gate-form { display: flex; gap: 8px; margin-top: 14px; }
.gate-input {
flex: 1; background: var(--bg); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 10px; font: inherit; font-size: 22px; letter-spacing: 0.4em; text-align: center;
}
.gate-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.gate-btn { padding: 10px 16px; }
.gate-hint { color: var(--muted); font-size: 11px; margin-top: 12px; }
.admin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; margin-top: 18px; }
.admin-tile { padding: 16px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; }
.admin-tile h2 { font-size: 15px; margin: 0 0 6px; }
.admin-tile p { font-size: 13px; color: var(--muted); margin: 0; }

View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Admin — psyc{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Admin Control Center</h1>
<span class="count"><a href="/admin/logout" class="lg-sub">lock ⏻</a></span>
</div>
<p class="page-intro">Authenticated. This is the secured zone — TOTP-gated, hidden from the nav. Infrastructure visibility and the rest of the control system get built in here.</p>
<div class="verdict verdict-clean">✓ Admin session active — expires after 60 min idle.</div>
<div class="admin-grid">
<div class="admin-tile admin-tile-pending">
<h2>Docker topology</h2>
<p>Live container + network map. <em>Wiring next (stage-26b) via a read-only socket-proxy.</em></p>
</div>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>psyc · restricted</title>
<meta name="robots" content="noindex,nofollow">
<link rel="icon" type="image/png" href="/static/psyc-logo.png">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/cockpit.css">
<link rel="stylesheet" href="/static/psyc-tokens.css">
</head>
<body>
<div class="gate-wrap">
<div class="gate-card">
<img class="gate-logo" src="/static/psyc-logo.png" alt="psyc">
<h1 class="gate-title">Restricted Zone</h1>
<p class="gate-sub">Admin control center — authenticator required.</p>
{% if not provisioned %}
<div class="gate-setup">
<p class="gate-step">First time? Scan this with Google Authenticator / Authy, then enter the code below.</p>
<img class="gate-qr" src="{{ qr }}" alt="TOTP QR code">
</div>
{% endif %}
{% if error %}<div class="gate-error">✗ Invalid or expired code — try the current one.</div>{% endif %}
<form method="post" action="/admin/verify" class="gate-form">
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code"
maxlength="6" placeholder="000000" class="gate-input" autofocus>
<button type="submit" class="btn btn-enforce gate-btn">Verify</button>
</form>
<p class="gate-hint">6-digit code, rotates every 30s.</p>
</div>
</div>
</body>
</html>