- Status: done
- Date: 2026-06-03
- Specs touched:
LOGGING.md— addedlogin.lockoutto the v1 action vocabulary table (the action is implicit inAUTH.md# Rate limiting's "all failed attempts logged" requirement but wasn't listed).CLAUDE.md— addedretry_afterto the standard structured-field list. NoDECISIONS.md/NEXT.mdchange — the design was specified, not redesigned.
Closes issue #8. The brain login endpoint went username-lookup → VerifyPassword (a deliberately-expensive PAM round-trip) → 401 with no throttling, so the PAM call — the asset AUTH.md # Rate limiting exists to protect — was reachable on every repeated guess. This adds the per-username backoff/lock and per-IP token bucket exactly as specced, gating before PAM.
New auth.LoginThrottle (internal/auth/throttle.go) — in-memory, clock-injectable (mirrors Manager.Clock), two independent gates:
- Per-username exponential backoff → lock (
AUTH.md: 3 fails → 1s, 5 → 10s, 10 → 60s, 20 → 15-minute lock).usernameBackoff(fails)maps the consecutive-failure count to the required cooldown since the last failure; at 20 the cooldown becomes the full 15-minute lock. - Per-IP token bucket (10 attempts/minute, continuous refill). The backstop against username-spray: a fresh username carries no per-username delay, so only the IP gate slows a spray from one source. It throttles, never locks — banning a LAN address is wrong (
AUTH.md: "most boxes only see LAN IPs").
Three methods: AllowAttempt(username, ip) (called before PAM — spends one IP token, then checks the username backoff/lock; returns ok=false + a best-effort retry hint, same shape for both gates so a rejection never leaks whether the username exists); RecordFailure(username) (called after a failed VerifyPassword; increments the per-username counter, returns lockedNow — true only on the failure that crosses into the lock, so the caller audits login.lockout once per lock, not per subsequent attempt); RecordSuccess(username) (resets the per-username counter — AUTH.md: "Successful login resets the per-username counter").
Login handler gated (internal/api/auth.go login): AllowAttempt runs before the GetUserByUsername/VerifyPassword round-trip. On rejection it returns 429 Too Many Requests with the retry hint in the message and an slog.Warn (the failures that built the backoff were already audited; a throttle rejection isn't re-audited to avoid a flood writing audit rows). On a failed verification it calls RecordFailure (keyed on the supplied username, existing or not, so the throttle can't enumerate accounts) and, on the lock crossing, emits the new login.lockout audit. A clean login calls RecordSuccess.
New audit action ActionLoginLockout = "login.lockout" (internal/audit/audit.go), emitted success=false with {username} + source IP (via the existing audit.WithClientIP ctx), mirroring login.failure.
Wiring (internal/api/api.go): Server gains a throttle *auth.LoginThrottle, constructed in NewServer (mirrors how streamCap is owned, not injected).
AUTH.md# Rate limiting — implements the per-username backoff schedule, the 15-minute lock at 20, the per-IP 10/min throttle (logs, never bans), and success-resets-the-counter, verbatim. The section's "login path only" scoping is honored: this lives in the login handler, distinct from the general API rate-limiting (BRAIN_UI_PROTOCOL.md# Rate limiting & abuse), which is a separate concern and untouched here.AUTH.md# Identity primitive / line 250 ("rate-limits on no") — the gate sits exactly where the spec says, in front of the PAMverify_passwordcall.LOGGING.md—login.lockoutjoins the v1 action vocabulary alongsidelogin.failure; emitted on both the success-of-the-event sense (it's a real lock) and as asuccess=falsesecurity event.CLAUDE.md# Go discipline — consumer-side ownership (the throttle lives ininternal/auth, used byinternal/api);slogstructured fields (username,host,retry_after); no new store surface and no persistence (AUTH.mdrequires none); no premature abstraction (concrete type, no interface — one consumer).
- Admin unlock is not built.
AUTH.md# Rate limiting also describes "an admin can clear a lock from the user-management UI." That's a UI + endpoint piece outside #8's scope (the issue's "Done when" doesn't include it); the limiter already exposes the reset primitive (RecordSuccess) a future unlock endpoint would call. The 15-minute lock still self-expires, so an account is never permanently wedged. - No
Retry-Afterheader. The retry hint rides the429body/message, not the header. AUTH.md scopes the429/Retry-Afterheader contract to the general-API plane (BRAIN_UI_PROTOCOL.md), not the login path; adding it here would need a header on huma's error path. Left out deliberately. - In-memory, unbounded maps. State resets on brain restart (acceptable per
AUTH.md). Theusers/ipsmaps grow one entry per distinct failing username/IP; for a LAN home box this is negligible and the per-IP gate bounds attacker-supplied username growth to 10/min. Eviction waits for a timer/scheduler seam the brain doesn't have yet (same deferral shape as the periodic image sweep). - Throttled attempts don't count as failures. A request rejected by the gate never reaches PAM, so it doesn't advance the per-username counter (only real
VerifyPasswordfailures do). This is the intended reading of "gate before PAM" and keeps a hammering attacker from inflating their own backoff into a permanent self-lock loop independent of real guesses.
internal/auth/throttle_test.go(clock-injected, fast): backoff tiers (usernameBackoffat every boundary), cooldown expiry, the lock crossing reported exactly once (and not re-reported at 21), success-reset, the per-IP bucket (10 allowed, 11th throttled, refill after ~6s), and that throttled probes don't escalate the tier.internal/api/auth_test.goTestLoginLockoutAfterRepeatedFailures(end-to-end over HTTP): 20 wrong-password logins reach the lock; a 21st with the correct password is still429(proving the gate precedesVerifyPassword); exactly onelogin.lockoutaudit row is recorded. The existingTestLoginLogoutFlow(2 failures then success) still passes — under the threshold, never throttled.go test ./internal/auth/ ./internal/api/ ./internal/audit/green;go vet+gofmtclean. (internal/hostagent/pamverifierneedslibpam0g-devheaders absent on this dev box — unrelated to this change; it builds in CI.)
- Admin unlock UI + endpoint — surface "Cindy is locked out, unlock her" in Settings → Users, calling a brain endpoint that clears the lock (
RecordSuccess-equivalent).AUTH.md# Rate limiting describes it; deferred out of #8. - Limiter eviction — prune stale
users/ipsentries once the brain grows a periodic-timer seam (shared with the deferred image-sweep timer).