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:apiafter 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. NEVERgit 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_settingssingleton (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; seedINSERT (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:
Settingsstruct +Floorsconsts (LockoutThreshold≥3, LockoutMinutes≥1, PasswordMinLength 8..72) +Validate()(clear per-field messages) +DefaultSettings().ValidatePassword(pw string, s Settings) error→auth.password.policynaming the unmet rule (length/upper/lower/digit/special; special = any non-alphanumeric). User struct gains the three fields. - [ ] Adapters: every
usersscan 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-totpInsertPendingTOTP/ConsumePendingTOTP(tokenHash) (userID, error)(DELETE … RETURNING, expiry-checked = single-use). Ports updated. - [ ] Verify
cd go && go build ./... && go vet ./...; commitfeat(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 liveAllowSelfRegistration→ 403-kindauth.register.disabled(generic); thenValidatePassword(replaces min-8). - [ ]
UpdatePassword:ValidatePassword(replaces hardcoded 8); on success ALSO clearpassword_must_change(via SetPasswordHash path or explicit clear). - [ ]
AdminCreateUser(ctx, email, name, password, mustChange bool): dup email → 409auth.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
VerifyPasswordonly in thehash != ""branch (empty-hash dev/demo exempt): before verify, iflocked_untilin future → return the SAME genericauth.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/PasswordLoginoutcome becomes a typed result: local user + confirmed TOTP → createtotp_pending_loginsrow (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 → genericauth.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 liveRequireTOTP&& 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(permusers.admin; PUT Validate → 400auth.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) — allusers.admin, in the existing admin-users block.ListUsersAdminrows gainlocked(locked_until>now) +must_change_password;Megainsmust_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. Commitfeat(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: surfaceauth.register.disabledcleanly. - [ ] 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 entrysecurityunderadmin.nav.people(label keyadmin.tabs.security). Editors: registration Toggle, require-TOTP Toggle, lockout threshold/minutes NumberInputs (floors), password min-length NumberInput (8..72) + 4 complexity checkboxes, Save (PUT, surfacesauth.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.policyw/ 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.