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
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# 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:
|
||||
|
||||
```python
|
||||
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
|
||||
Reference in New Issue
Block a user