diff --git a/pyproject.toml b/pyproject.toml index 0c44a80..74b3cd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/psyc/cockpit/adminauth.py b/src/psyc/cockpit/adminauth.py new file mode 100644 index 0000000..4cb6feb --- /dev/null +++ b/src/psyc/cockpit/adminauth.py @@ -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() diff --git a/src/psyc/cockpit/app.py b/src/psyc/cockpit/app.py index 292a6b6..c42d68b 100644 --- a/src/psyc/cockpit/app.py +++ b/src/psyc/cockpit/app.py @@ -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 diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index d55779f..bf3c271 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -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; } diff --git a/src/psyc/cockpit/templates/admin.html b/src/psyc/cockpit/templates/admin.html new file mode 100644 index 0000000..51ee68e --- /dev/null +++ b/src/psyc/cockpit/templates/admin.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Admin — psyc{% endblock %} +{% block content %} +
+
+

Admin Control Center

+ lock ⏻ +
+

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.

+
✓ Admin session active — expires after 60 min idle.
+ +
+
+

Docker topology

+

Live container + network map. Wiring next (stage-26b) via a read-only socket-proxy.

+
+
+
+{% endblock %} diff --git a/src/psyc/cockpit/templates/admin_gate.html b/src/psyc/cockpit/templates/admin_gate.html new file mode 100644 index 0000000..a6125d6 --- /dev/null +++ b/src/psyc/cockpit/templates/admin_gate.html @@ -0,0 +1,37 @@ + + + + + psyc · restricted + + + + + + + +
+
+ +

Restricted Zone

+

Admin control center — authenticator required.

+ + {% if not provisioned %} +
+

First time? Scan this with Google Authenticator / Authy, then enter the code below.

+ TOTP QR code +
+ {% endif %} + + {% if error %}
✗ Invalid or expired code — try the current one.
{% endif %} + +
+ + +
+

6-digit code, rotates every 30s.

+
+
+ +