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:
@@ -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]
|
||||
|
||||
88
src/psyc/cockpit/adminauth.py
Normal file
88
src/psyc/cockpit/adminauth.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
19
src/psyc/cockpit/templates/admin.html
Normal file
19
src/psyc/cockpit/templates/admin.html
Normal 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 %}
|
||||
37
src/psyc/cockpit/templates/admin_gate.html
Normal file
37
src/psyc/cockpit/templates/admin_gate.html
Normal 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>
|
||||
Reference in New Issue
Block a user