Skip to content

feat(admin): password reset, 2FA reset and a first-admin invite that works on the distroless image - #113

Merged
jayleaton merged 3 commits into
mainfrom
feat/admin-2fa-recovery
Oct 23, 2026
Merged

jayleaton merged 3 commits into
mainfrom
feat/admin-2fa-recovery

Conversation

@jayleaton

@jayleaton jayleaton commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

An admin who forgot their password or lost their authenticator had no way back in. On the distroless image there was also no way to create the first admin: admin migrate seeded ADMIN_BOOTSTRAP_EMAIL and then said "send its set-password link with admin invite", which needs a shell the image doesn't have. This ports the generic part of jayleaton/auto-visuals#47.

  admin migrate (pre-deploy, runs inside the image)
    Bootstrap(ADMIN_BOOTSTRAP_EMAIL)       seeds a pending admin (unchanged)
+   InviteBootstrapAdmin                   emails the set-password link once (pending + no invite on record)
  /login
+   "Forgot password?" -> /forgot-password  same answer for any address; 1 h link
+   /reset-password?token=                 same token kind as invites; other sessions end; 2FA still asked
  /admins (another admin; never yourself; never a pending invite)
+   Send password reset                    admin.password_reset
+   Reset 2FA (confirm dialog)             admin.reset_two_factor, reconcile verdict !twoFactorEnabled
  auth events
+   backup code accepted                   admin.backup_code_used (best-effort, like sign-ins)
  CLI
+   admin reset-2fa <email>                the last admin lost authenticator + backup codes (system:cli)

Also on the JSON API (POST /api/admin/v1/admins/{id}/password-reset, …/two-factor/reset), make admin-reset-2fa EMAIL=…, and a lock-out runbook in docs/admin.md (rebased onto #107's README split).

What was already there and is unchanged: packages/auth already generated 10 backup codes once at enrolment, stored them as keyed hashes under the data key, and spent each on use (TestBackupCodesAreSingleUse). It also had UserAdmin.ResetTwoFactor and password-reset tokens that CompleteSetPassword accepts. No change to packages/auth (the OAuth branch owns it). No migration.

admin reset-2fa is the static binary alone and never starts a shell, so it runs anywhere the binary does. railway ssh itself needs a shell in the container it opens, so on Railway the runbook in docs/admin.md runs the binary from admin-postgres (same private network, has sh). That is the method auto-visuals#47 verified on staging. I did not re-run it on Railway here.

Evidence

Local, this branch (bin/admin built from it), dev Postgres from make db-up, ADMIN_REQUIRE_2FA=true, driven by plain form posts plus one browser pass.

First admin without a shell. Before: admin migrate only logged "bootstrap admin created; send its set-password link with admin invite". After, run twice:

1st: msg="auth: set-password email (dev mode, not sent)" to=boot@example.com url="http://127.0.0.1:3317/set-password?token=…"
     msg="bootstrap admin" email=boot@example.com created=true invite_sent=true
2nd: (nothing sent)

Enrol, then use a backup code, then the same code again. Before: no backup-code use in the audit log. After:

POST /set-password -> 303 /two-factor/setup
enrol: setup page 10 backup codes shown; POST code -> 303 /users
5. sign in with a backup code
  POST /login -> 303 /two-factor
  POST /two-factor method=backup_code -> 303 /users
  GET /users -> 200
6. the same backup code again (then TOTP on the same pending sign-in)
  POST /login -> 303 /two-factor
  POST /two-factor method=backup_code -> 422  That code is not valid. Try again.
  GET /users -> 303 /login
  POST /two-factor method=totp -> 303 /users

admin reset-2fa on the only admin, then sign in again. Before: no such command.

$ admin reset-2fa
usage: admin [serve | migrate | invite <email> | reset-2fa <email>]          (exit 2)
$ admin reset-2fa nobody@example.com
level=ERROR msg="reset-2fa failed" err="reset-2fa nobody@example.com: Admin not found"   (exit 1)
$ admin reset-2fa boot@example.com
level=INFO msg="two-factor authentication removed; the admin enrols again at the next sign-in" email=boot@example.com   (exit 0)
$ admin reset-2fa boot@example.com
level=ERROR msg="reset-2fa failed" err="reset-2fa boot@example.com: This admin has no two-factor authentication to reset"   (exit 1)
4. sign in after reset-2fa
  POST /login -> 303 /two-factor/setup
  enrol: setup page 10 backup codes shown; POST code -> 303 /users
  GET /users -> 200

Forgot password. nobody@example.com and boot@example.com both got "If that address belongs to an admin, a reset link is on its way." Only boot's reset email was logged. Then:

POST /reset-password -> 303 /two-factor  (2FA still asked)
POST /two-factor totp -> 303 /users
same link again -> 422 This reset link is invalid or has expired. Ask for a new one.

(The live run caught that last message showing the invite wording, "Ask an admin to send a new invite". That is fixed in the second commit.)

Another admin recovers ops@ (Admins page, in a browser, through Datastar): "Send password reset" sent a link to ops@. "Reset 2FA" opened its confirm dialog and then showed "2FA removed. They enrol again at their next sign-in.", with ops' Two-factor column turning Off and the button gone. ops' next POST /login → 303 /two-factor/setup. A second reset → 422 "This admin has no two-factor authentication to reset."

Audit log (admin_audit_log ⋈ admin_audit_outcome, sign-outs omitted):

 actor            | action                  | target           | details                           | status
 bootstrap        | admin.bootstrap         | boot@example.com | {}                                | succeeded
 bootstrap        | admin.invite            | boot@example.com | {}                                | succeeded
 cli              | admin.reset_two_factor  | boot@example.com | {}                                | succeeded
 boot@example.com | admin.backup_code_used  | boot@example.com | {}                                |
 boot@example.com | admin.two_factor_failed | boot@example.com | {"reason": "INVALID_BACKUP_CODE"} |
 cli              | admin.invite            | ops@example.com  | {}                                | succeeded
 boot@example.com | admin.password_reset    | ops@example.com  | {}                                | succeeded
 boot@example.com | admin.reset_two_factor  | ops@example.com  | {}                                | succeeded

Screenshots of the login link, forgot-password confirmation, Reset 2FA dialog, Admins page after the reset and the Audit page were taken locally but not uploaded: the template repo shouldn't carry image files.

Checks. go test -cover: services 100.0 %, api 100.0 %. go vet ./apps/admin/... clean. golangci-lint run ./apps/admin/... 0 issues. scripts/check-test-boundaries.sh ok. The first CI run caught one errorlint finding in a new test, fixed in the second commit; CI on the final commit eac40d9: run 39931611905 succeeded (full run: the Makefile changed). Local make check-fast could not finish on this box: compiles were killed from outside the cgroup (oom_kill 0, load ~30), so the wider checks ran on Actions instead.

New tests: TestSendPasswordReset, TestResetTwoFactor, TestResetTwoFactorByEmail, TestInviteBootstrapAdmin, TestBackupCodeUsed. The four new methods are added to the policy table (ResetTwoFactorByEmail, InviteBootstrapAdmin are operator-only) and the four mutations to the fail-closed table. Reconcile adds reset 2fa * and password reset (undecidable) cases. API routes POST admin password reset and POST admin two-factor reset (401/403/400/404/503/200).

Code rules (third commit): no single-letter names in anything this PR adds or touches (receivers of the types it extends, params, locals, closure args, the templ components it changed, the tests it extends), and named, commented consts for the email-subcommand arg count and the new-password length hint. golangci-lint --enable-only mnd ./apps/admin/... (the linter chore/naming-lint adds) reports 14 findings, none on lines this PR changes. Kept on purpose: t *testing.T, and the UI's u *UI receiver, which spans 49 methods in four files, most of them untouched here, while feat/route-access is reworking ui.go.

Seen, not fixed (outside this PR's area): the existing uikit confirm dialog inherits the actions cell's right-aligned, no-wrap text, so long messages clip (the Remove dialog does it on main too). It's a packages/uikit fix for a separate PR.

Merge Danger

Door: two-way. Additive routes, actions and translations; no migration, no packages/auth change.

Blast Radius: admin

  • admin migrate now sends one email on the first deploy where ADMIN_BOOTSTRAP_EMAIL is set and that admin is still pending with no invite on record. A deployment that already invited its bootstrap admin sends nothing.
  • A password reset link ends the admin's other sessions (packages/auth behaviour for set-password tokens). A disabled admin can reset but still cannot sign in.
  • Sign-in UX is unchanged except the "Forgot password?" link; /forgot-password and /reset-password are new public pages, rate-limited by packages/auth (3 emails / min, 3 reset attempts / 10 s).

Source: jayleaton/auto-visuals#47 (generic part only).

🤖 Generated with Claude Code

jayleaton and others added 3 commits October 23, 2026 16:18
…works on the distroless image

- Forgot password on the sign-in page (same answer for any address) and a
  /reset-password page; another admin can send a reset (admin.password_reset).
- Reset 2FA for another admin from the Admins page (admin.reset_two_factor,
  reconcile verdict !twoFactorEnabled) and `admin reset-2fa <email>` for the
  last admin (system:cli, exact email required).
- Spent backup codes are audited (admin.backup_code_used).
- `admin migrate` emails the bootstrap admin's invite once, so the first
  admin needs no shell in the image.
- README: lock-out runbook.

Ported from jayleaton/auto-visuals#47 (generic part).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…invite

Found in the live run: /reset-password with a used token showed the
invite page's wording. Also: errorlint in a test, and the audit page
callout names used backup codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ery code

JJ's code rules, applied to everything this PR adds or touches: receivers
of the types it extends (AdminService, Admins, AdminAccounts, UIAuth,
AuthEventService, AuditReconciler), params, locals and closure args, the
templ components it changed, and the tests it extends. Named consts for
the email-subcommand arg count and the new-password length hint.

The UI's u *UI receiver stays: it spans 49 methods in four files, most
untouched here, and feat/route-access is reworking ui.go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton
jayleaton force-pushed the feat/admin-2fa-recovery branch from 209eb4d to eac40d9 Compare October 23, 2026 09:24
@jayleaton
jayleaton marked this pull request as ready for review October 23, 2026 09:26
@jayleaton
jayleaton merged commit db2867d into main Oct 23, 2026
5 checks passed
@jayleaton
jayleaton deleted the feat/admin-2fa-recovery branch October 23, 2026 09:26
jayleaton added a commit that referenced this pull request Oct 23, 2026
…nce its link expires

Rebuilt on main, where #113 already has admin migrate send the bootstrap
invite once. What this keeps from the original PR is the part main lacks:
InviteBootstrapAdmin resends once the last invite is older than the link
lifetime (inviteTTL = auth.DefaultInviteTTL, also set as the admin auth
instance's InviteTTL so the two cannot drift), so a first email that never
arrived or expired heals on a later deploy, and it never invites a disabled
pending admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
…nce its link expires

Rebuilt on main, where #113 already has admin migrate send the bootstrap
invite once. What this keeps from the original PR is the part main lacks:
InviteBootstrapAdmin resends once the last invite is older than the link
lifetime (inviteTTL = auth.DefaultInviteTTL, also set as the admin auth
instance's InviteTTL so the two cannot drift), so a first email that never
arrived or expired heals on a later deploy, and it never invites a disabled
pending admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
…nce its link expires

Rebuilt on main, where #113 already has admin migrate send the bootstrap
invite once. What this keeps from the original PR is the part main lacks:
InviteBootstrapAdmin resends once the last invite is older than the link
lifetime (inviteTTL = auth.DefaultInviteTTL, also set as the admin auth
instance's InviteTTL so the two cannot drift), so a first email that never
arrived or expired heals on a later deploy, and it never invites a disabled
pending admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
…nce its link expires (#102)

Rebuilt on main, where #113 already has admin migrate send the bootstrap
invite once. What this keeps from the original PR is the part main lacks:
InviteBootstrapAdmin resends once the last invite is older than the link
lifetime (inviteTTL = auth.DefaultInviteTTL, also set as the admin auth
instance's InviteTTL so the two cannot drift), so a first email that never
arrived or expired heals on a later deploy, and it never invites a disabled
pending admin.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant