mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
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:
parent
ec14cb9dce
commit
d0a63d49b5
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user