think
16px
820px

Internal Auth Hardening Implementation Plan

For agentic workers: written for INLINE execution by the session controller (subagent
harness unavailable). Checkbox (- [ ]) tracking per task; commit per task on main.
Spec: docs/superpowers/specs/2026-07-06-internal-auth-hardening-design.md

Goal: Enterprise-posture local auth: admin-gated registration, admin create-user +
password-reset, per-account lockout, TOTP actually enforced at login (+ require-TOTP
toggle), configurable password policy — all admin-configurable, all directory-managed-user
exempt, demo director untouched.

Architecture: auth_settings singleton (migration 00088, the rate-limit/backup
hot-swap pattern: atomic.Pointer, floors, Load→Defaults fallback) + three new users
columns (password_must_change, failed_logins, locked_until) + a totp_pending_logins
table for the two-step login. Session restrictions are computed at Authenticate time from
the user row + live settings and carried on kernel.Principal.AuthRestriction
("" | "password_change" | "totp_enroll"); the Authenticator middleware enforces a per-restriction
path allowlist with 403s.

Global Constraints

  • NEVER go test (test DSN == live demo Postgres). Verify: cd go && go build ./... && go vet ./....
  • Web verify: cd web && npx tsc --noEmit && npx vite build; npm run gen:api after api/openapi.yaml edits. NO new npm/Go deps.
  • Deploy ONLY repo root docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web; post-deploy assert /me enabled_modules == [ai,correspondence,esign,semantic,watermarking] (dev-login director@obscura.local :38080).
  • NEVER docker compose down -v. Commit per task on main, NO push. NEVER git add -A (tracked ELF go/obscura-server).
  • Demo director (provider='dev', hash=="", password==email) MUST keep working — never flag/lock/reset it. Empty-hash users are exempt from lockout, policy, and TOTP-require. Directory-managed users (IsDirectoryManaged) exempt from everything here.
  • Auth errors stay GENERIC (locked == wrong password == no such user). Self-registration default flips OFF (deliberate).
  • i18n en/id nested, ASCII quotes (rewrite whole i18n file with Write if Edit mangles quotes). e2e uses SCRATCH users only + resets auth_settings + cleans up.

File Map

File Task Purpose
go/migrations/00088_auth_hardening.sql (new) T1 auth_settings singleton seeded + users ALTER ×3 + totp_pending_logins
go/internal/auth/domain/auth.go T1 User gains PasswordMustChange/FailedLogins/LockedUntil; Settings + floors + Validate + Defaults + ValidatePassword
go/internal/auth/adapters/pg.go T1,T2,T3 scans for new columns; settings Load/Save; lockout counters; pending-totp CRUD
go/internal/auth/app/ports.go T2,T3 Repository additions (settings, lockout, pending, HasConfirmedTOTP)
go/internal/auth/app/service.go T2,T3 registration gate, policy, AdminCreateUser/AdminSetPassword, lockout, two-step TOTP login, restriction computation
go/internal/kernel/kernel.go T3 Principal.AuthRestriction
go/internal/httpapi/middleware.go T3 restriction allowlist enforcement in Authenticator
go/internal/httpapi/handlers_auth.go T3,T4 PasswordLogin two-step; TOTPVerify; Me flags
go/internal/httpapi/handlers_users_admin.go or existing admin file T4 CreateUserAdmin, SetUserPasswordAdmin, UnlockUserAdmin, auth-settings GET/PUT
go/internal/httpapi/server.go T4 new routes (users.admin)
go/cmd/obscura-server/wire.go T4 settings pointer boot-load
api/openapi.yaml + web/src/api/schema.ts T4 new endpoints
web/src/features/auth/LoginPage.tsx (+ new ForcedGate screens) T5 TOTP code step; forced change/enroll screens
web/src/features/admin/{SecurityTab.tsx(new), AdminPage.tsx, data.ts, i18n.ts} T6 Security section + Users create/reset/unlock

Task 1: Migration 00088 + domain (settings, policy validator, user fields) + adapters

Files: migration (new), domain/auth.go, adapters/pg.go, app/ports.go.

  • [ ] Migration 00088_auth_hardening.sql: auth_settings singleton (id=1 CHECK; allow_self_registration bool NOT NULL DEFAULT false, require_totp bool NOT NULL DEFAULT false, lockout_threshold int NOT NULL DEFAULT 10, lockout_minutes int NOT NULL DEFAULT 15, password_min_length int NOT NULL DEFAULT 8, password_require_upper/lower/digit/special bool NOT NULL DEFAULT false, updated_at; seed INSERT (id) VALUES (1)). ALTER TABLE users ADD COLUMN password_must_change boolean NOT NULL DEFAULT false, ADD COLUMN failed_logins int NOT NULL DEFAULT 0, ADD COLUMN locked_until timestamptz. CREATE TABLE totp_pending_logins (token_hash text PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at timestamptz NOT NULL). Goose down drops/reverses.
  • [ ] Domain: Settings struct + Floors consts (LockoutThreshold≥3, LockoutMinutes≥1, PasswordMinLength 8..72) + Validate() (clear per-field messages) + DefaultSettings(). ValidatePassword(pw string, s Settings) errorauth.password.policy naming the unmet rule (length/upper/lower/digit/special; special = any non-alphanumeric). User struct gains the three fields.
  • [ ] Adapters: every users scan updated for the three columns; AuthSettings(ctx)/SaveAuthSettings(ctx, s) (Load errors/no-row → Defaults); RecordLoginFailure(ctx, userID, threshold int, lockMinutes int) error (increment; at threshold set locked_until + reset counter — single UPDATE with CASE), ResetLoginFailures(ctx, userID), ClearLockout(ctx, userID); pending-totp InsertPendingTOTP/ConsumePendingTOTP(tokenHash) (userID, error) (DELETE … RETURNING, expiry-checked = single-use). Ports updated.
  • [ ] Verify cd go && go build ./... && go vet ./...; commit feat(auth): migration 00088 + settings/policy domain + adapters (hardening foundation).

Task 2 (SECURITY-REVIEW): service — registration gate, policy, admin create/reset, lockout

Files: app/service.go (+ports), using T1.

  • [ ] Service holds settings atomic.Pointer[domain.Settings] + ReloadAuthSettings(ctx) (boot + after save) + AuthSettings() accessor.
  • [ ] RegisterUser: first check live AllowSelfRegistration → 403-kind auth.register.disabled (generic); then ValidatePassword (replaces min-8).
  • [ ] UpdatePassword: ValidatePassword (replaces hardcoded 8); on success ALSO clear password_must_change (via SetPasswordHash path or explicit clear).
  • [ ] AdminCreateUser(ctx, email, name, password, mustChange bool): dup email → 409 auth.register.email_taken; policy-validate; ProvisionLocal(idp local) + SetPasswordHash + set must_change flag. Returns user.
  • [ ] AdminSetPassword(ctx, userID, password): target IsDirectoryManaged → auth.password.managed_externally; policy-validate; SetPasswordHash + set must_change + LogoutAll.
  • [ ] Lockout inside VerifyPassword only in the hash != "" branch (empty-hash dev/demo exempt): before verify, if locked_until in future → return the SAME generic auth.login.invalid; on wrong password → RecordLoginFailure(threshold, minutes from live settings) then generic; on success → ResetLoginFailures when counter>0. Directory-managed users never reach this branch (routed earlier).
  • [ ] Verify build/vet; self-review as adversary: no enumeration (locked==invalid), demo convention untouched, must_change only set by admin paths, LogoutAll on reset. Commit feat(auth): registration gate + password policy + admin create/reset + account lockout.

Task 3 (SECURITY-REVIEW): TOTP at login + restriction enforcement

Files: service.go, ports.go, kernel.go, middleware.go, handlers_auth.go, adapters (HasConfirmedTOTP).

  • [ ] HasConfirmedTOTP(ctx, userID) (bool, error) on the TOTP port/adapter (confirmed enrollment exists).
  • [ ] LoginWithDirectory/PasswordLogin outcome becomes a typed result: local user + confirmed TOTP → create totp_pending_logins row (32B crypto/rand, sha256 stored, 5min TTL) and return {TOTPRequired:true, PendingToken} — NO session. Handler → 200 {totp_required:true, pending_token}. Directory/OIDC/dev-login paths unchanged (dev-login issues sessions directly; the director has no TOTP).
  • [ ] POST /auth/totp/verify (authRL-limited) {pending_token, code} → ConsumePendingTOTP (single-use, expiry) + VerifyTOTP → CreateSession (+cookie) → same login response; bad/expired token or bad code → generic auth.login.invalid (consumed=consumed; bad CODE does not consume so the user may retry until TTL — decide: retry-friendly = look up WITHOUT delete, delete only on success; implement that, still single-success).
  • [ ] kernel.Principal.AuthRestriction string (zero = unrestricted). Authenticate (session path only, not API keys): after loading session+user, set "password_change" when user.PasswordMustChange; else "totp_enroll" when live RequireTOTP && provider local-eligible && hash!="" && !HasConfirmedTOTP. (Empty-hash dev users exempt.)
  • [ ] Authenticator middleware: restriction != "" and path NOT in allowlist → 403 (auth.password.change_required / auth.totp.enrollment_required). Allowlists: password_change → GET /me, POST /me/password, POST /auth/logout; totp_enroll → GET /me, POST /auth/totp/enroll, POST /auth/totp/confirm, POST /auth/logout.
  • [ ] Verify build/vet; adversarial self-review (pending single-success, generic errors, API-keys unaffected, dev-login unaffected). Commit feat(auth): TOTP enforced at login (pending two-step) + restricted-session enforcement.

Task 4: HTTP admin/settings layer + OpenAPI

  • [ ] GET/PUT /api/v1/admin/auth-settings (perm users.admin; PUT Validate → 400 auth.settings.floor, Save + ReloadAuthSettings). POST /api/v1/admin/users (create), PUT /api/v1/admin/users/{id}/password (reset), DELETE /api/v1/admin/users/{id}/lockout (unlock) — all users.admin, in the existing admin-users block. ListUsersAdmin rows gain locked (locked_until>now) + must_change_password; Me gains must_change_password + totp_enroll_required (from Principal.AuthRestriction) so the SPA can route.
  • [ ] OpenAPI (block descriptions) + npm run gen:api + tsc. Verify build/vet + tsc. Commit feat(api): auth-settings + admin user create/reset/unlock + me flags.

Task 5: web auth flows (login TOTP step, forced screens)

  • [ ] LoginPage: response totp_required → show a code input step (keeps pending_token in state; POST /auth/totp/verify; generic error copy). RegisterPage: surface auth.register.disabled cleanly.
  • [ ] Forced gates: on Me/403 flags — must_change_password → a blocking change-password screen (old+new, POSTs /me/password, then reloads); totp_enroll_required → blocking enroll screen (reuse the existing TOTP enroll UI flow: enroll → QR/otpauth URL → confirm code). Both offer logout. i18n en/id.
  • [ ] Verify tsc + vite build. Commit feat(web): login TOTP step + forced change-password / TOTP-enrollment gates.

Task 6: web admin — Security section + Users actions

  • [ ] New SecurityTab.tsx + NAV_GROUPS entry security under admin.nav.people (label key admin.tabs.security). Editors: registration Toggle, require-TOTP Toggle, lockout threshold/minutes NumberInputs (floors), password min-length NumberInput (8..72) + 4 complexity checkboxes, Save (PUT, surfaces auth.settings.floor). Pattern: ObservabilityTab.
  • [ ] Users section: "Create user" button + modal (email/name/password/must-change checkbox, policy errors surfaced); row/detail actions: Reset password (modal, shown-once confirmation), Unlock (only when locked); locked indicator in the status column. data.ts hooks; i18n en/id.
  • [ ] Verify tsc + vite build. Commit feat(web): Admin Security section (auth settings) + user create/reset/unlock.

Task 7 (CONTROLLER-DRIVEN e2e — inline)

  • [ ] Deploy; assert 5 modules (director dev-login). SCRATCH users only.
  • [ ] Register: 403 by default → PUT allow → weak pw rejected (auth.password.policy w/ complexity toggles on) → strong registers → toggle back off.
  • [ ] Create: POST admin/users (must_change) → login → non-allowlisted call 403 auth.password.change_required → change password → cleared, works. Reset: PUT password → old session 401/dead → login → must-change wall again.
  • [ ] Lockout: threshold wrong passwords → CORRECT password now also generic-fails → admin unlock → login OK. Assert generic error bodies identical.
  • [ ] TOTP: enroll+confirm scratch user (otpauth secret → compute codes in python) → login → totp_required+pending → wrong code generic → right code → session. require_totp on → fresh scratch user login → restricted (403 enrollment_required on /documents; enroll allowlist works) → confirm → unrestricted. Toggle off.
  • [ ] Floors: PUT lockout_threshold=1 / min_length=4 → 400 auth.settings.floor.
  • [ ] Cleanup: delete scratch users (admin delete), reset auth_settings to defaults, assert director dev-login + password==email login + 5 modules + LDAP/SCIM cards untouched. Mark plan done; commit docs(plan): auth hardening T7 e2e GREEN.

Self-review

Spec coverage: §settings→T1/T4/T6, §1 registration→T2/T5/T7, §2 create+must-change→T2/T3/T4/T6/T7, §3 reset→T2/T4/T6/T7, §4 lockout→T1/T2/T4/T6/T7, §5 TOTP→T3/T5/T7, §6 policy→T1/T2/T6/T7. Placeholders: none (behavior-complete; code authored at execution by the controller). Types: Settings/ValidatePassword/AuthRestriction names consistent across tasks.