feat(dashboard): drop token auth, trust oauth2-proxy X-Forwarded-User
This commit is contained in:
+35
-73
@@ -9,13 +9,13 @@ ThreadingHTTPServer shape) that renders three views off the feedback SQLite:
|
||||
GET /r/<owner>/<name>/<index> one PR's findings
|
||||
GET /r/<owner>/<name>/<index>/raw raw Markdown body (via Gitea)
|
||||
GET /static/style.css CSS
|
||||
GET /login login form
|
||||
POST /login check token, set cookie
|
||||
POST /r/<owner>/<name>/edit mutate .pr-review.json (Tasks C+D)
|
||||
|
||||
Auth: when `PRAGENT_DASHBOARD_TOKEN` is set, every route except `/login`,
|
||||
`/static/*`, and `POST /login` requires `Cookie: pragent_dash=<token>`.
|
||||
Unset → tailnet-only mode, no auth.
|
||||
Auth: oauth2-proxy fronts this service in-cluster. Every route except
|
||||
`/static/*` requires the `X-Forwarded-User` header (set by oauth2-proxy
|
||||
once the user has logged in via Logto). Missing header → 401 +
|
||||
`WWW-Authenticate: Basic realm="pragent-dashboard"` so oauth2-proxy
|
||||
intercepts the response.
|
||||
|
||||
DB: `PRAGENT_FEEDBACK_DB` points at the SQLite file the webhook server
|
||||
also writes. Per-request open (SQLite is cheap, no concurrency hazard,
|
||||
@@ -33,6 +33,7 @@ import datetime
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@@ -47,13 +48,15 @@ from pilot import dashboard_data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FEEDBACK_DB = "" # legacy; readers should call _feedback_db()
|
||||
DASHBOARD_TOKEN = "" # legacy; readers should call _dashboard_token()
|
||||
PORT = int(os.environ.get("DASHBOARD_PORT", "8081"))
|
||||
|
||||
GITEA_API = "" # legacy; readers should call _gitea_api()
|
||||
BOT_TOKEN = "" # legacy; readers should call _bot_token()
|
||||
|
||||
COOKIE_NAME = "pragent_dash"
|
||||
# CSRF secret for the edit form. Regenerated per process (each Python
|
||||
# interpreter launch). Behind oauth2-proxy this is enough — only an
|
||||
# already-authenticated same-tab request can read this and echo it back.
|
||||
_CSRF_SECRET: str = secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -67,10 +70,6 @@ def _feedback_db() -> str:
|
||||
return os.environ.get("PRAGENT_FEEDBACK_DB", "")
|
||||
|
||||
|
||||
def _dashboard_token() -> str:
|
||||
return os.environ.get("PRAGENT_DASHBOARD_TOKEN", "")
|
||||
|
||||
|
||||
def _bot_token() -> str:
|
||||
return os.environ.get("PRAGENT_BOT_TOKEN", "")
|
||||
|
||||
@@ -231,18 +230,6 @@ ${findings_table}
|
||||
""")
|
||||
|
||||
|
||||
_LOGIN = string.Template("""<h2>Login</h2>
|
||||
${flash}
|
||||
<form method="post" action="/login">
|
||||
<label for="token">Dashboard token</label>
|
||||
<input type="text" id="token" name="token" autofocus>
|
||||
<div class="row">
|
||||
<button type="submit">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
""")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -418,10 +405,6 @@ def _pr_body(data: dict, *, repo_url: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _login_body(flash: str = "") -> str:
|
||||
return _LOGIN.substitute(flash=_esc(flash))
|
||||
|
||||
|
||||
def _page(title: str, body: str, *, repos_first: str = "") -> str:
|
||||
db_status = _feedback_db() or "(no DB configured)"
|
||||
return _BASE.substitute(
|
||||
@@ -467,16 +450,14 @@ def _http(method: str, url: str, *, token: str = "", body: dict | None = None,
|
||||
|
||||
|
||||
def _is_authed(headers) -> bool:
|
||||
"""True when token matches; false when token unset OR cookie absent/wrong."""
|
||||
tok = _dashboard_token()
|
||||
if not tok:
|
||||
return True # no token → tailnet-only mode
|
||||
cookie = headers.get("Cookie") or ""
|
||||
for part in cookie.split(";"):
|
||||
k, _, v = part.strip().partition("=")
|
||||
if k == COOKIE_NAME and v == tok:
|
||||
return True
|
||||
return False
|
||||
"""True when oauth2-proxy forwarded a verified user.
|
||||
|
||||
oauth2-proxy sets `X-Forwarded-User` (and friends) only after a
|
||||
successful Logto login + email allowlist check. Unauthenticated
|
||||
requests never see the header, so the dashboard never has to know
|
||||
about cookies, secrets, or Logto's token shape.
|
||||
"""
|
||||
return bool((headers.get("X-Forwarded-User") or "").strip())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -505,11 +486,10 @@ def _route_repo(owner: str, name: str) -> bytes:
|
||||
current_model = cfg.get("model", "")
|
||||
elif err and err != "404":
|
||||
flash = f"could not read .pr-review.json: {err}"
|
||||
csrf = _dashboard_token() or "noauth"
|
||||
body = _repo_body(
|
||||
data,
|
||||
repo_url=repo_url,
|
||||
csrf=csrf,
|
||||
csrf=_CSRF_SECRET,
|
||||
current_model=current_model,
|
||||
current_static_message=current_static_message,
|
||||
flash=flash,
|
||||
@@ -549,25 +529,11 @@ def _route_static_css() -> bytes:
|
||||
return STYLE_CSS.encode()
|
||||
|
||||
|
||||
def _route_login_get() -> bytes:
|
||||
return _page("login", _login_body()).encode()
|
||||
|
||||
|
||||
def _route_login_post(token: str) -> tuple[int, dict, bytes]:
|
||||
"""Compare token to env. Sets cookie on match, 401 otherwise."""
|
||||
expected = _dashboard_token()
|
||||
if not expected or token != expected:
|
||||
return 401, {}, b"invalid token"
|
||||
cookie = f"{COOKIE_NAME}={expected}; HttpOnly; SameSite=Strict; Path=/"
|
||||
return 302, {"Set-Cookie": cookie, "Location": "/"}, b""
|
||||
|
||||
|
||||
def _route_edit(owner: str, name: str, form: dict) -> tuple[int, dict, bytes]:
|
||||
"""Mutate .pr-review.json via the Gitea contents API (Tasks C+D)."""
|
||||
repo_url = f"{owner}/{name}"
|
||||
csrf = form.get("_csrf", "")
|
||||
expected_csrf = _dashboard_token() or "noauth"
|
||||
if csrf != expected_csrf:
|
||||
if csrf != _CSRF_SECRET:
|
||||
return 302, {"Location": f"/r/{repo_url}"}, b""
|
||||
static_message = (form.get("static_message") or "").strip()[:400]
|
||||
model = (form.get("model") or "").strip()
|
||||
@@ -682,19 +648,27 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _unauthorized(self) -> None:
|
||||
"""401 + Basic challenge so oauth2-proxy intercepts and redirects to Logto."""
|
||||
body = b"unauthorized\n"
|
||||
self.send_response(401)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("WWW-Authenticate", 'Basic realm="pragent-dashboard"')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
# --- GET -----------------------------------------------------------------
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path
|
||||
# Static is exempt from auth so the login page can load its CSS.
|
||||
# Static is exempt from auth (also unauthenticated browser fingerprinting
|
||||
# noise, but it's the same CSS regardless of viewer).
|
||||
if path == "/static/style.css":
|
||||
self._send(200, _route_static_css(), content_type="text/css; charset=utf-8")
|
||||
return
|
||||
if path == "/login":
|
||||
self._send(200, _route_login_get())
|
||||
return
|
||||
if not _is_authed(self.headers):
|
||||
self._redirect("/login")
|
||||
self._unauthorized()
|
||||
return
|
||||
|
||||
if path == "/" or path == "":
|
||||
@@ -724,17 +698,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path
|
||||
# Login POST is exempt from auth (obviously).
|
||||
if path == "/login":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
form = urllib.parse.parse_qs(raw.decode("utf-8", errors="replace"))
|
||||
token = (form.get("token") or [""])[0]
|
||||
status, extra, body = _route_login_post(token)
|
||||
self._send(status, body, content_type="text/plain", extra_headers=extra)
|
||||
return
|
||||
if not _is_authed(self.headers):
|
||||
self._redirect("/login")
|
||||
self._unauthorized()
|
||||
return
|
||||
# /r/<owner>/<name>/edit
|
||||
m = _EDIT_RE.match(path)
|
||||
@@ -775,10 +740,7 @@ def main() -> int:
|
||||
if not _feedback_db():
|
||||
print("pragent-dashboard: WARNING: PRAGENT_FEEDBACK_DB not set; dashboard will be empty",
|
||||
flush=True)
|
||||
if _dashboard_token():
|
||||
print("pragent-dashboard: auth ON (PRAGENT_DASHBOARD_TOKEN set)", flush=True)
|
||||
else:
|
||||
print("pragent-dashboard: auth OFF (tailnet-only mode)", flush=True)
|
||||
print("pragent-dashboard: auth via oauth2-proxy (X-Forwarded-User required)", flush=True)
|
||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
||||
print(f"pragent-dashboard: listening on :{PORT}", flush=True)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user