feat(api): auth modes, login sessions and mutation audit trail - #858
feat(api): auth modes, login sessions and mutation audit trail#858Sanjin-Maker wants to merge 2 commits into
Conversation
users table (operator/viewer, argon2id PHC hashes) in state, plus go/internal/localauth: constant-time password verification at OWASP argon2id parameters and in-memory bearer sessions with expiry and per-user revocation. Sessions are memory-only on purpose — restart logs everyone out, and no session secret touches the database. API enforcement comes separately; nothing changes for existing installs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Sanjin Naidu <sanjin@sanrowconsulting.com>
api.auth.mode open|local_trust|required layered outside the existing SecureMutations gate: open is byte-identical to today, local_trust gates only non-local requests (viewer reads, operator mutations, bearer token still valid for automation), required gates everything but login/health/static. ftw user CLI bootstraps accounts on the box; startup refuses login modes with zero enabled operators. Every mutation attempt lands in audit_log with its principal, served at GET /api/audit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Sanjin Naidu <sanjin@sanrowconsulting.com>
001fa8a to
bdd43ef
Compare
|
The production handler chain currently breaks the new remote-session model:
Please define one production wrapper order, let a valid operator session authorize remote mutations, keep login/session discovery reachable through that boundary, and make account changes revoke or revalidate sessions. Add an end-to-end handler test using the same nesting as |
miravoss26
left a comment
There was a problem hiding this comment.
Reviewed the last commit (feat(api): auth modes, login sessions and mutation audit trail) per the stacked-PR note. Solid design overall (identity layer outside SecureMutations, audit-on-attempt not just success, bootstrap-safe ftw user CLI, mode open byte-identical by construction) — but the security screen found two real issues worth a look before this lands.
1. GET /api/audit is unauthenticated in the default open mode — contradicts its own comment. In auth.go, handleAuditLog's role check is if s.deps.Auth.enabled() { ... }, and RequireAuth itself no-ops entirely when !p.enabled(). Since open is the default and enabled() is false for it, any caller who can reach the API gets the full mutation/login audit trail — principals (including attempted usernames via login-failed:<name>), methods, paths, remote addresses — with zero auth. The handler's own comment says "operators only (enforced in-handler so the endpoint is protected even in open mode)," which isn't what the code does. TestOpenModeIsPassThrough confirms open-mode pass-through for /api/status and /api/mode but doesn't cover /api/audit, so this isn't caught by the new suite either. Since open is meant to be "today's behavior, byte-identical," and this is a new endpoint, the fix is just: gate /api/audit on operator role unconditionally, not only if enabled().
2. Login has a timing side-channel that undermines the "no username oracle" goal. authOK := err == nil && ok && !u.Disabled && localauth.VerifyPassword(...) short-circuits on ok — when the username doesn't exist, VerifyPassword (presumably bcrypt/argon2-cost) never runs, so a nonexistent-user login returns much faster than a real-user-wrong-password one. The response body is uniform ("invalid credentials"), but response timing isn't, which is exactly the oracle the comment says it's avoiding. Usual fix: always run the hash verification (against a fixed dummy hash when the user doesn't exist) so both paths cost the same.
Minor: the session cookie sets HttpOnly + SameSite=Strict but not Secure. Probably fine if this only ever runs behind TLS termination, but FTW looks like it can run bare on a local network — worth confirming that's intentional rather than an oversight.
Nothing here blocks the architecture (mode semantics, bootstrap CLI, layering vs SecureMutations all read right), but #1 is a real infoleak in the default mode and I'd want a human decision on both #1 and #2 before merge.
|
Thank you @Sanjin-Maker for the large test matrix and the attention to login, roles and audit. We are closing this cumulative branch pending the architecture decision described on #857. The current handler order blocks valid login/session mutations in some modes, applies auth twice in parts of the chain, keeps role and enabled state in long-lived sessions, lacks login attempt limits and leaves audit pruning unwired. The default-open audit endpoint and cookie/session behavior also need a written threat model. Please carry observations or proposed rules into a text issue first. We should agree on the boundary before writing another API implementation. |
Summary
Stacked on #857 (localauth foundation) — review the last commit until it lands. Composes with draft #744: that PR widens which reads count as protected inside
SecureMutations; this one adds an identity layer outside it — no shared hunks, whoever lands second rebases trivially.api.auth.mode(config.API.Auth):open(default) — byte-identical to today; verified by the untouched api test suite.local_trust— local clients unchanged; non-local requests need a session (viewer to read, operator to mutate).FTW_API_TOKENbearer mutations keep working for automation.required— every /api request needs a session, local included;/api/auth/login,/api/healthand static assets (the login page) stay reachable.POST /api/auth/login(uniform invalid-credentials error — no username oracle; HttpOnly SameSite=Strict cookie),logout,GET /api/auth/session,GET /api/audit.token/local), method, path, remote addr — in ALL modes including open. Failed logins audited aslogin-failed:<name>.ftw userCLI (add/list/passwd/disable/enable/delete): the bootstrap channel that needs no prior credential. Refuses to remove the last enabled operator while a login mode is active; startup refuses non-open modes with zero operators — a config typo can never lock the operator out of the box.RequireAuthwraps inside the boot-phaseSecureMutations(CSRF/content-type garbage rejected first, then identity), applied at the handler swap so the boot-phase health path is untouched.x/crypto→ direct,x/termadded (password prompt) at the already-resolved module versions — no transitive changes.Verification
go test ./internal/api/ ./internal/state/ ./internal/config/ ./cmd/ftw/ -count=1— middleware matrix: open pass-through + audit, local_trust local-unchanged/remote-401/operator-pass, viewer-403-on-mutation, required gates local + exempt paths reachable, bearer automation + forged-token rejection, principal recording; audit table round-trip; config mode validation. DCO signed; minor changeset.🤖 Generated with Claude Code