fix(auth+users): /auth/status → /preflight (avoid route collision); engine TOTP window parameter; postinst chmod for writes (ref #120)

- Rename GET /auth/status → /auth/preflight (public) to stop shadowing the
  JWT-protected legacy /auth/status (dashboard voucher/session counts)
- Engine.verify_totp_for_user gains window parameter; _verify_totp_ntp_aware
  now delegates to the engine (atomic write via _save + audit hook)
- postinst: /etc/secubox 0750→0770, users.json 640→660 (group-write for atomic tempfile+rename)
- Add replay-refusal assertion to test_totp_widened_window_accepts_drifted_code
- Update login.html: loadStatus→loadPreflight, /api/v1/auth/status→/preflight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-05-13 10:55:21 +02:00
parent ec14cb9dce
commit d0a63d49b5
5 changed files with 43 additions and 45 deletions

View File

@ -175,32 +175,9 @@ _users_engine.set_audit_callback(lambda evt, user, d: _append_audit(evt, user, d
def _verify_totp_ntp_aware(username: str, code: str) -> bool:
"""Verify TOTP with a window scaled by NTP health (offline-mode resilient)."""
u = user_store.get_user(username) or {}
block = (u.get("totp") or {})
if not block.get("enabled"):
return False
secret = block.get("secret")
last_step = block.get("last_step")
"""Verify TOTP via the engine with a window scaled by NTP health."""
window = ntp_health.recommended_totp_window()
step_size = 30
now = int(time.time())
current = now // step_size
import pyotp
for delta in range(-window, window + 1):
step = current + delta
if pyotp.TOTP(secret).at(step * step_size) == code:
if last_step is not None and step <= last_step:
return False
# Persist the consumed step
doc = json.loads(_USERS_FILE.read_text())
for entry in doc["users"]:
if entry["username"] == username and entry.get("totp"):
entry["totp"]["last_step"] = step
break
_USERS_FILE.write_text(json.dumps(doc, indent=2, sort_keys=True))
return True
return False
return _users_engine.verify_totp_for_user(username, code, window=window)
# ─── Branching login router ────────────────────────────────────────────
@ -362,15 +339,15 @@ async def _set_password(req: _SetPasswordIn, request: _Request):
raise HTTPException(status_code=403, detail="Token hors scope")
@_login_router.get("/status")
async def _auth_status():
"""Public-readable status surface for the UI banner.
@_login_router.get("/preflight")
async def _auth_preflight():
"""Public-readable preflight surface for the UI banner.
Returns NTP-health + identity-store source. UI uses this to render warnings
like "Identity store in fallback mode" or "Clock not synced — TOTP may fail".
Distinct from the legacy `/health` liveness check (sidebar consumer):
`/health` answers "is the service up?", `/status` answers "is auth healthy?".
`/health` answers "is the service up?", `/preflight` answers "is auth healthy?".
"""
src = user_store.load_with_fallback()
return {

View File

@ -120,13 +120,13 @@ def test_full_totp_enrollment_then_login(client):
assert r5.json()["mfa_required"] is True
def test_auth_health_endpoint(client, monkeypatch):
def test_auth_preflight_endpoint(client, monkeypatch):
c, *_ = client
# Force unsynced
from api import ntp_health
monkeypatch.setattr(ntp_health, "probe", lambda: {"synced": False, "error": "no chronyc"})
monkeypatch.setattr(ntp_health, "recommended_totp_window", lambda: 3)
r = c.get("/auth/status")
r = c.get("/auth/preflight")
assert r.status_code == 200
body = r.json()
assert body["ntp"]["synced"] is False
@ -159,3 +159,11 @@ def test_totp_widened_window_accepts_drifted_code(client, monkeypatch):
headers={"Authorization": f"Bearer {mfa_tok}"})
assert r4.status_code == 200
assert "access_token" in r4.json()
# Same drifted code resubmitted should be refused (replay protection).
# Re-login to get a fresh mfa_token (the previous one may be consumed).
r_replay_pw = c.post("/auth/login", json={"username": "admin", "password": "GoodPass!42xyz"})
mfa_tok2 = r_replay_pw.json()["mfa_token"]
r5 = c.post("/auth/login/mfa", json={"code": drifted_code},
headers={"Authorization": f"Bearer {mfa_tok2}"})
assert r5.status_code == 401

View File

@ -398,10 +398,10 @@
$('totpCode').focus();
}
// ── Operational status banner ────────────────────────────────────
async function loadStatus() {
// ── Operational preflight banner ─────────────────────────────────
async function loadPreflight() {
try {
const { res, data } = await api('/api/v1/auth/status');
const { res, data } = await api('/api/v1/auth/preflight');
if (!res.ok) return;
const banner = $('statusBanner');
const msgs = [];
@ -415,9 +415,9 @@
banner.innerHTML = msgs.join('<br>');
banner.style.display = 'block';
}
} catch (_) { /* status endpoint optional */ }
} catch (_) { /* preflight endpoint optional */ }
}
loadStatus();
loadPreflight();
$('confirmForm').addEventListener('submit', async (e) => {
e.preventDefault();

View File

@ -209,18 +209,31 @@ class Engine:
self._audit("totp_enrolled", username, {})
return plain
def verify_totp_for_user(self, username: str, code: str) -> bool:
from . import totp as _totp
def verify_totp_for_user(self, username: str, code: str, window: int = 1) -> bool:
import pyotp
import time
doc = self._load()
u = self._find(doc, username)
if not u or not u.get("enabled") or not (u.get("totp") and u["totp"].get("enabled")):
return False
ok, step = _totp.verify(u["totp"]["secret"], code, u["totp"].get("last_step"))
if ok and step is not None:
u["totp"]["last_step"] = step
self._save(doc)
return ok
secret = u["totp"]["secret"]
last_step = u["totp"].get("last_step")
step_size = 30
now = int(time.time())
current = now // step_size
for delta in range(-window, window + 1):
step = current + delta
if pyotp.TOTP(secret).at(step * step_size) == code:
if last_step is not None and step <= last_step:
return False
u["totp"]["last_step"] = step
self._save(doc)
self._audit("totp_verified", username, {"step": step, "window": window})
return True
return False
def consume_backup_code(self, username: str, code: str) -> bool:
from . import totp as _totp

View File

@ -7,7 +7,7 @@ case "$1" in
getent group secubox >/dev/null || groupadd --system secubox
getent passwd secubox >/dev/null || useradd --system --gid secubox \
--home /var/lib/secubox --no-create-home --shell /usr/sbin/nologin secubox
install -d -m 0750 -o root -g secubox /etc/secubox
install -d -m 0770 -o root -g secubox /etc/secubox
# Run v1 → v2 migration (idempotent)
python3 - <<'PYEOF' || echo 'WARN: migration step failed — investigate /etc/secubox/users.json'
@ -44,7 +44,7 @@ PYEOF
# Ownership + permissions on users.json
if [ -f /etc/secubox/users.json ]; then
chmod 640 /etc/secubox/users.json
chmod 660 /etc/secubox/users.json
chown root:secubox /etc/secubox/users.json
fi