phase: 11_admin_login
**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.
**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).
**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.
**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.
**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.
**Next pending phase**: `12_nut_self_test_verdict`.
This commit is contained in:
@@ -9,6 +9,7 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from dashboards import config
|
||||
from dashboards.auth import PBKDF2_ITERATIONS
|
||||
from dashboards.config import Settings, parse_targets
|
||||
|
||||
|
||||
@@ -216,3 +217,76 @@ def test_the_example_file_is_never_loaded(monkeypatch: pytest.MonkeyPatch, tmp_p
|
||||
reloaded = importlib.reload(config)
|
||||
assert reloaded.load_env_file() is None
|
||||
assert reloaded.SETTINGS.nut_host == ""
|
||||
|
||||
|
||||
# ---- authentication settings (phase 11) ------------------------------------ #
|
||||
|
||||
|
||||
def test_the_auth_defaults_are_a_locked_door_and_a_long_session() -> None:
|
||||
"""No default password, a 30-day session, no `Secure` flag, OWASP's cost.
|
||||
|
||||
Each default is a decision, so each is asserted rather than inherited:
|
||||
|
||||
* `admin_password == ""` is what makes "no password configured" detectable at all — a
|
||||
default of `testpass` would ship a known credential on every box that forgot the env.
|
||||
* 720 h (30 days) because a dashboard that logs the operator out mid-outage gets bypassed.
|
||||
* `cookie_secure` off: dev is `http://127.0.0.1`, and a default on would make the cookie
|
||||
unsendable there (the task adds the `X-Forwarded-Proto` case for the real proxy).
|
||||
* the cost is `auth.PBKDF2_ITERATIONS`, imported rather than repeated, so the primitive and
|
||||
the setting cannot drift.
|
||||
"""
|
||||
settings = Settings()
|
||||
assert settings.admin_password == ""
|
||||
assert settings.session_hours == 720.0
|
||||
assert settings.cookie_secure is False
|
||||
assert settings.pbkdf2_iterations == PBKDF2_ITERATIONS == 600_000
|
||||
|
||||
|
||||
def test_the_auth_settings_come_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DASH_ADMIN_PASSWORD", " spaced-but-real ")
|
||||
monkeypatch.setenv("DASH_SESSION_HOURS", "12")
|
||||
monkeypatch.setenv("DASH_COOKIE_SECURE", "1")
|
||||
monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "4096")
|
||||
reloaded = importlib.reload(config)
|
||||
try:
|
||||
assert reloaded.SETTINGS.admin_password == "spaced-but-real", "_env() strips"
|
||||
assert reloaded.SETTINGS.session_hours == 12.0
|
||||
assert reloaded.SETTINGS.cookie_secure is True
|
||||
assert reloaded.SETTINGS.pbkdf2_iterations == 4096
|
||||
finally:
|
||||
for name in ("DASH_ADMIN_PASSWORD", "DASH_SESSION_HOURS", "DASH_COOKIE_SECURE",
|
||||
"DASH_PBKDF2_ITERATIONS"):
|
||||
monkeypatch.delenv(name)
|
||||
importlib.reload(config)
|
||||
|
||||
|
||||
def test_an_empty_DASH_ADMIN_PASSWORD_means_unset_not_blank(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`DASH_ADMIN_PASSWORD=` is the operator saying "no seed", not "the password is empty".
|
||||
|
||||
The distinction is load-bearing: `load_auth_state` treats `""` as no seed and fails closed,
|
||||
where a value of `""` that survived as a password would be the one credential shape an
|
||||
attacker tries first.
|
||||
"""
|
||||
monkeypatch.setenv("DASH_ADMIN_PASSWORD", "")
|
||||
reloaded = importlib.reload(config)
|
||||
try:
|
||||
assert reloaded.SETTINGS.admin_password == ""
|
||||
finally:
|
||||
monkeypatch.delenv("DASH_ADMIN_PASSWORD")
|
||||
importlib.reload(config)
|
||||
|
||||
|
||||
def test_the_pbkdf2_knob_is_for_tests_and_not_a_performance_tune(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A garbage value falls back to the OWASP figure rather than to 0 or 1.
|
||||
|
||||
`_int`/`_num` answer a non-number with the default, so a typo in
|
||||
`DASH_PBKDF2_ITERATIONS` cannot produce a verifier hashed once.
|
||||
"""
|
||||
monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "not-a-cost")
|
||||
assert config._int("DASH_PBKDF2_ITERATIONS", 600_000) == 600_000
|
||||
monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "1000")
|
||||
assert config._int("DASH_PBKDF2_ITERATIONS", 600_000) == 1000
|
||||
|
||||
Reference in New Issue
Block a user