Files
echo-core/dashboard/handlers/auth.py
Marius Mutu 2d8ee9b581 feat(dashboard): login-ul cu token, dezactivat implicit
Accesul la /echo trece deja prin `tailscale serve` (tailnet only), deci
formularul de token era o a doua autentificare peste una existentă.

`DASHBOARD_AUTH` nesetat => `_check_dashboard_cookie` trece mereu: paginile
.html nu mai redirectează la /echo/login, POST-urile /api/* nu mai dau 401,
iar /echo/login sare direct la destinație. Codul de login rămâne intact —
`DASHBOARD_AUTH=on` în dashboard/.env îl reactivează neschimbat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188YmDTUAzVvZDVh8JL8xSa
2026-08-31 22:11:39 +00:00

205 lines
8.2 KiB
Python

"""Cookie-based authentication for the unified dashboard.
This mixin provides:
- POST /api/auth/login — exchanges a token (form body) for a cookie.
- POST /api/auth/logout — clears the cookie.
- _check_dashboard_cookie — used by the global POST middleware (and the
SSE GET endpoint) to gate access.
Authentication is **disabled by default** — the dashboard is only reachable
over the Tailscale tailnet, which already authenticates the caller, so the
extra token prompt added nothing. Set `DASHBOARD_AUTH=on` in `dashboard/.env`
to re-enable the token login (everything below still works unchanged).
`DASHBOARD_TOKEN` is read once from `dashboard/.env` (loaded into
`os.environ` by `dashboard/constants.py` at import time). When the token is
not configured we generate a random one at startup, stash it in-process,
and warn loudly to stderr — this means the dashboard is reachable from
localhost only with a freshly-printed token (printed once at boot).
"""
from __future__ import annotations
import json
import logging
import os
import secrets
import sys
from urllib.parse import parse_qs
log = logging.getLogger(__name__)
# 30 days
_COOKIE_MAX_AGE = 60 * 60 * 24 * 30
_COOKIE_NAME = "dashboard"
_COOKIE_PATH = "/echo/"
# Module-level cache for the resolved token. Set lazily on first call so
# importing this module doesn't have a side effect at process boot.
_DASHBOARD_TOKEN: str | None = None
def _auth_enabled() -> bool:
"""True only when `DASHBOARD_AUTH` is explicitly turned on.
Off by default: access control is delegated to Tailscale. When off,
`_check_dashboard_cookie` always passes and `/echo/login` is never shown.
"""
return os.environ.get("DASHBOARD_AUTH", "").strip().lower() in {
"on", "1", "true", "yes",
}
def _get_dashboard_token() -> str:
"""Return the dashboard token (cached). Generates a random one if absent.
`dashboard/constants.py` already loads `dashboard/.env` into os.environ at
import time, so by the time this is called the value (if present) is in
`os.environ['DASHBOARD_TOKEN']`. If missing, we mint a 32-byte URL-safe
token and warn — operators must read it from the log to log in.
"""
global _DASHBOARD_TOKEN
if _DASHBOARD_TOKEN is not None:
return _DASHBOARD_TOKEN
token = os.environ.get("DASHBOARD_TOKEN", "").strip()
if not token:
token = secrets.token_urlsafe(32)
msg = (
"[auth] DASHBOARD_TOKEN not set in dashboard/.env — generated a "
f"random token for this process: {token}\n"
" Add `DASHBOARD_TOKEN=<value>` to dashboard/.env to make it "
"stable across restarts.\n"
)
print(msg, file=sys.stderr, flush=True)
log.warning("DASHBOARD_TOKEN not configured — using ephemeral token")
_DASHBOARD_TOKEN = token
return token
def _parse_cookie_header(raw: str) -> dict[str, str]:
"""Tiny RFC 6265 cookie-pair parser. Last-write-wins on duplicates."""
out: dict[str, str] = {}
if not raw:
return out
for chunk in raw.split(";"):
chunk = chunk.strip()
if not chunk or "=" not in chunk:
continue
k, v = chunk.split("=", 1)
out[k.strip()] = v.strip()
return out
class AuthHandlers:
"""Mixin: /api/auth/login, /api/auth/logout, plus _check_dashboard_cookie."""
# ── helpers ────────────────────────────────────────────────────────
def _check_dashboard_cookie(self) -> bool:
"""Return True if the request carries a valid `dashboard` cookie.
Always True when auth is disabled (the default) — see `_auth_enabled`.
"""
if not _auth_enabled():
return True
raw = self.headers.get("Cookie", "") or ""
cookies = _parse_cookie_header(raw)
provided = cookies.get(_COOKIE_NAME, "")
if not provided:
return False
expected = _get_dashboard_token()
# Constant-time compare — token guess attacks aren't realistic here
# (cookie path is /echo/, HttpOnly), but cheap defense in depth.
return secrets.compare_digest(provided, expected)
def _read_form_body(self) -> dict[str, str]:
"""Parse `application/x-www-form-urlencoded` POST body."""
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except (TypeError, ValueError):
length = 0
if length <= 0:
return {}
try:
raw = self.rfile.read(length).decode("utf-8")
except (UnicodeDecodeError, OSError):
return {}
parsed = parse_qs(raw, keep_blank_values=True)
# Flatten — single-value form fields only
return {k: v[0] for k, v in parsed.items() if v}
# ── POST /api/auth/login ───────────────────────────────────────────
def handle_login(self):
"""Validate token from form body; on success, set cookie + 302 to workspace.
On failure, return 401 JSON. The cookie is set with HttpOnly +
SameSite=Strict; Path=/echo/ so it scopes to the dashboard reverse
proxy mount.
"""
if not _auth_enabled():
# Nothing to log into — bounce straight to the dashboard.
self.send_response(302)
self.send_header("Location", "/echo/workspace.html")
self.send_header("Content-Length", "0")
self.send_header("Cache-Control", "no-store")
self.end_headers()
return
# Accept JSON body too (login.html might POST JSON in Lane B2)
ctype = (self.headers.get("Content-Type", "") or "").lower()
if "application/json" in ctype:
try:
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length).decode("utf-8") if length > 0 else ""
form = json.loads(raw) if raw else {}
if not isinstance(form, dict):
form = {}
except (ValueError, json.JSONDecodeError, UnicodeDecodeError, OSError):
form = {}
else:
form = self._read_form_body()
provided = (form.get("token") or "").strip()
expected = _get_dashboard_token()
if not provided or not secrets.compare_digest(provided, expected):
body = json.dumps({"error": "Invalid token"}).encode("utf-8")
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
cookie = (
f"{_COOKIE_NAME}={expected}; HttpOnly; SameSite=Strict; "
f"Path={_COOKIE_PATH}; Max-Age={_COOKIE_MAX_AGE}"
)
self.send_response(302)
self.send_header("Set-Cookie", cookie)
self.send_header("Location", "/echo/workspace.html")
self.send_header("Content-Length", "0")
self.send_header("Cache-Control", "no-store")
self.end_headers()
# ── POST /api/auth/logout ──────────────────────────────────────────
def handle_logout(self):
"""Clear the dashboard cookie. Returns 200 JSON `{"ok": true}`."""
cookie = (
f"{_COOKIE_NAME}=; HttpOnly; SameSite=Strict; "
f"Path={_COOKIE_PATH}; Max-Age=0"
)
body = json.dumps({"ok": True}).encode("utf-8")
self.send_response(200)
self.send_header("Set-Cookie", cookie)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass