Files
dashboards/.agents/phases/todo/11_admin_login/01_auth_primitives.md
T
ducoterra 046dc57b26 chore(agent): add phase 11 — single-admin login for the dashboards
Eight tasks to put a session gate in front of every page and API route: the auth primitives
(PBKDF2 verifier, HMAC-signed stateless session, session-bound CSRF token, login throttle,
origin check) in a new `auth.py`, the verifier in the store plus fail-closed startup, the
default-deny middleware with an explicit public list, `POST /api/auth/password` with session
rotation, a login page, a `/settings` page, and the dev-config/docs/browser verification pass.

Stdlib hashlib/hmac only — no new dependency, so no PLAN.md anchor moves. `/api/health`
stays public on purpose (the Containerfile HEALTHCHECK and dev-server.sh depend on it), and
one test enumerates every route and demands a session.

LLM-Generated: true
2026-10-17 12:27:40 -04:00

7.2 KiB

Task 01 — Auth primitives: hashes, session tokens, CSRF tokens, throttle

Phase: 11_admin_login · Source: operator request 2026-10-17 — "password protect this dashboard for a single admin user" Story: n/a

Objective

src/dashboards/auth.py: every authentication decision as a pure, unit-testable function with no HTTP and no I/O — password hash/verify, session token mint/read, CSRF token mint/verify, login throttle, origin check. Nothing here knows about FastAPI; task 03 wires it.

Work

  1. New src/dashboards/auth.py, module docstring stating the design and why it is hand-rolled (stdlib only — the same reasoning as prom.py and the NUT wire client; no itsdangerous, no bcrypt, no session store to operate). Constants at the top:

    ADMIN_USER = "admin"            # single admin; there is no user table and none is coming
    SESSION_COOKIE = "dash_session"
    CSRF_HEADER = "X-CSRF-Token"
    SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
    PBKDF2_ITERATIONS = 600_000     # OWASP's PBKDF2-HMAC-SHA256 figure; DASH_PBKDF2_ITERATIONS overrides
    PBKDF2_ALGO = "pbkdf2_sha256"
    
  2. Password verifier — self-describing so the cost can rise later without a migration:

    • hash_password(password: str, *, iterations: int = PBKDF2_ITERATIONS) -> str → pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>, salt secrets.token_bytes(16), hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations).
    • verify_password(password: str, encoded: str) -> bool — parses defensively and returns False for "", a wrong field count, a non-numeric iteration count, an unknown algo name, odd-length hex, or a length mismatch. It must never raise: a corrupt meta row has to read as "wrong password", not a 500 on the login page. Compare with hmac.compare_digest.
    • needs_rehash(encoded: str, *, iterations: int) -> bool — true when the stored cost (or algo) is below the configured one, so a login can transparently upgrade the hash.
    • No password, hash, salt or session token may ever appear in a log line, an exception message, or a return value used for display. Name this in the docstring: AGENTS.md invariant 6 now covers the admin's own credential.
  3. Sessions — stateless and signed, so there is no session table and no cleanup job:

    • new_secret() -> str → secrets.token_hex(32). Stored by task 02 under the meta key auth.session_secret; rotating it invalidates every issued cookie, which is how changing the password signs everyone else out.
    • @dataclass(frozen=True) Session: username: str; expires_at: float; token: str
    • mint_session(secret: str, *, username: str = ADMIN_USER, hours: float = 720.0, now: float | None = None) -> str → b64url(f"{username}|{expires_at:.0f}") + "." + b64url(hmac_sha256(secret, payload)). Use base64.urlsafe_b64encode and strip padding; hmac.new(secret.encode(), payload.encode(), hashlib.sha256).
    • read_session(secret: str, token: str, *, now: float | None = None) -> Session | None — signature first (constant-time), then expires_at > now. Returns None for: "", no dot, non-base64 payload, payload without |, non-numeric expiry, an expired session, a token signed by a rotated secret, and a payload whose username was tampered with.
    • mint_csrf(secret: str, session_token: str) -> str → hmac_sha256(secret, "csrf|" + session_token).hexdigest() — bound to the session, so a token stolen from one session cannot authorise another's write, and it needs no expiry of its own because the session has one.
    • verify_csrf(secret: str, session_token: str, provided: str | None) -> bool — compare_digest, False on None/empty.
  4. Login throttle — in-process memory, because there is one admin and one uvicorn process:

    • class LoginThrottle: def __init__(self, *, max_attempts: int = 5, base_seconds: float = 60.0, max_seconds: float = 3600.0)
    • retry_after(key: str, *, now: float | None = None) -> float → seconds still blocked, 0.0 when allowed.
    • failure(key: str, *, now: float | None = None) -> float → the backoff just applied. Exponential from base_seconds per key, capped at max_seconds; a key that gets a successful login is cleared.
    • success(key: str) -> None, and prune(now) (or an inline length guard) so a spoofed swarm of source addresses cannot grow the dict without bound.
    • Take now as an injectable seconds value on every method (time.monotonic at the call site in task 03) — a test must not sleep 60 s to prove the backoff works.
    • ASSUMPTION: the key is the socket peer address. Behind nginx every login shares one bucket, so a determined attacker can lock the operator out of the dashboard (not of the UPS, not of the data) for at most max_seconds. Accepted for a single-admin box; it goes in AGENTS.md → Known gaps rather than becoming an X-Forwarded-For knob that is trivially spoofed when someone forgets to trust the proxy.
  5. Origin / Sec-Fetch gate as pure string functions (unit-testable without a Request):

    • origin_matches(origin: str, own_origin: str, allowed: Sequence[str] = ()) -> bool
    • sec_fetch_site_allowed(value: str | None) -> bool — allow None, "same-origin", "none"; reject "cross-site", "cross-origin", "same-site"(unknown → reject). Anything unrecognised is rejected: a browser that invents a new value is a browser doing something we did not reason about.

Testing & Quality

  • tests/test_auth.py (new) — the exhaustive negative table above. Minimum: round-trip hash/verify, wrong password, unicode password ("pässwörd" — proves the utf-8 encode), five malformed encodings all False with no exception, needs_rehash on a 1_000-iteration hash against the default cost, session round-trip, tampered username, flipped signature byte, truncated token, expired session (via now), cookie from a rotated secret, CSRF ok/wrong-session/missing, throttle allows 5, blocks the 6th with retry_after > 0, backoff doubles and caps at max_seconds, success() clears, origin_matches accepts its own origin and the allow-list and rejects http://evil.example, sec_fetch_site_allowed("cross-site") is False.
  • Cover auth.py to ~100% — every branch here is a security decision, and a branch no test reaches is a decision nobody checked.
  • Coverage: >90% on this task's new code; overall gate (94%) maintained.

Completion Criteria

  • uv run pytest tests/test_auth.py -v green
  • python -c "from dashboards import auth; h=auth.hash_password('testpass'); print(h)" prints a pbkdf2_sha256$600000$… string and auth.verify_password('testpass', h) is True while verify_password('wrong', h) and verify_password('testpass', 'junk') are False
  • grep -n "print(\|logger\|logging" src/dashboards/auth.py shows nothing that could echo a credential
  • no new dependency in pyproject.toml / uv.lock (uv pip list --outdated stays empty)
  • uv run pytest green; uv run ruff check src tests agent && uv run pyright clean