Audit failed logins (NIST AU-2, source RFP C.5.7.39)

Only successful logins wrote an audit row. Every unsuccessful attempt now
writes `login_failed` with reason bad_credentials, unknown_user,
inactive, or throttled, plus the client IP. The attempted username is
recorded only when it matches a real account (as the actor); unknown-user
attempts store no username, since people sometimes type a password into
that field.

Found while preparing Phase 0 control evidence (milestone 0.9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris TonjesandClaude Opus 5.5 committed 2026-10-01 14:37:40 -04:00
1 parent 0c359c926a
commit 99a50dc86c
2 files changed
+64

No files matched your search

+47
View File
@@ -251,3 +251,50 @@ def test_login_lockout_after_five_failed_attempts(
)
assert r.status_code == 429
assert r.headers["Retry-After"] == "900"
# ---------------- failed-login audit (NIST AU-2, C.5.7.39) ----------------
def _failures(db: Session) -> list[tuple[str | None, int | None, dict[str, str] | None]]:
from sqlalchemy import select
from ypms.audit.models import AuditLog
rows = db.execute(select(AuditLog).where(AuditLog.action == "login_failed")).scalars()
return [(r.actor_username, r.entity_id, r.after) for r in rows]
def test_bad_password_is_audited_against_the_account(
app_client: TestClient, db: Session, staff_user: User
) -> None:
app_client.post("/auth/login", data={"username": staff_user.username, "password": "nope"})
assert _failures(db) == [(staff_user.username, staff_user.id, {"reason": "bad_credentials"})]
def test_unknown_user_is_audited_without_the_typed_username(
app_client: TestClient, db: Session
) -> None:
app_client.post("/auth/login", data={"username": "my-actual-password!", "password": "x"})
assert _failures(db) == [(None, None, {"reason": "unknown_user"})]
def test_inactive_user_login_is_audited(
app_client: TestClient, db: Session, staff_user: User
) -> None:
staff_user.is_active = False
db.flush()
r = app_client.post(
"/auth/login",
data={"username": staff_user.username, "password": "correct-horse-battery"},
)
assert r.status_code == 401
assert _failures(db) == [(staff_user.username, staff_user.id, {"reason": "inactive"})]
def test_throttled_login_is_audited(app_client: TestClient, db: Session) -> None:
for _ in range(6):
r = app_client.post("/auth/login", data={"username": "ghost", "password": "x"})
assert r.status_code == 429
reasons = [after["reason"] for _, _, after in _failures(db) if after]
assert reasons == ["unknown_user"] * 5 + ["throttled"]