Add two-factor login (TOTP) with recovery codes

Users can set up an authenticator app from their account page; login
then asks for a code after the password, with one-time recovery codes
as a fallback. Sensitive actions (tokens, user changes, 2FA resets)
need a code from the last 5 minutes. totp.require (none/admins/all)
forces setup, and admins can reset a user's 2FA from the UI, the jarl
CLI or jarld on the server. The dashboard and jarl status flag users
missing 2FA and a shared "admin" account still in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-09 14:12:24 +00:00
1 parent b22ffee1bb
commit 8f9cdd0a17
35 files changed
+1965 -43

No files matched your search

+28 -2
View File
@@ -26,7 +26,27 @@ change something (anything but GET/HEAD) must send it as `X-CSRF-Token`.
`POST /auth/logout` ends the session.
`GET /auth/whoami` returns the user, the token (if any), the effective
permissions and whether the caller is an administrator.
permissions and whether the caller is an administrator. For sessions it
also has `totp` (the user's two-factor login: `enabled`, `required`,
`recovery_codes_left`) and `totp_setup_required`.
**Two-factor login** ([permissions.md](permissions.md#two-factor-login)):
for a user with it, `POST /auth/login` answers a correct password with
`{"totp_required": true, "challenge": "..."}` and no cookie. Send
`POST /auth/login/totp` with `{"challenge": "...", "code": "123456"}` (or
a recovery code) within 5 minutes to get the session. Wrong codes answer
401 `totp_invalid`; after 5, or 5 minutes, `login_expired` (start again).
Creating or rotating tokens, changing users and resetting someone's
two-factor login need, in a session of a user with two-factor login, a
code proved in the last 5 minutes. Otherwise they answer 403
`totp_step_up`: send the request again with the current code in
`X-Jarl-TOTP: 123456` (a wrong one gives 403 `totp_invalid`). Tokens and
the local socket never need one.
When `totp.require` applies to a user who hasn't set it up, a session can
only call `/auth/whoami`, `/auth/logout`, `/me/password` and `/me/totp...`;
everything else answers 403 `totp_setup_required`.
**The local socket**: on jarld's unix socket (`/run/jarl/jarl.sock`),
requests from root or jarld's own user need no `Authorization` header and
@@ -354,13 +374,19 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|--------|------|------|
| GET | `/users` | admin |
| POST | `/users` | `{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""}` (`password: ""` = service account) |
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled` |
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
| GET / POST | `/users/{id}/tokens` | admin (or yourself) |
| GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once |
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
| DELETE | `/tokens/{id}` | revoke |
| POST | `/tokens/{id}/rotate` | New secret, same token. Optional body `{"grace": "1h", "expires_in": "90d", "expires_at": "...", "no_expiry": false}`. The response's `token` field is the new secret, shown once; `previous_secret_valid_until` says when the old one stops. A restricted token may rotate itself and tokens no wider than itself |
| POST | `/me/password` | `{"current_password": "...", "password": "..."}` (sessions only) |
| GET | `/me/totp` | Your two-factor login: `enabled`, `enabled_at`, `required`, `recovery_codes_left`, `policy` |
| POST | `/me/totp/setup` | `{"password": "..."}` (sessions only). A new secret: `secret`, `uri` (`otpauth://`), `qr` (an SVG `data:` URI), `issuer`, `account`. Not active until confirmed. With two-factor login already on (a new phone), it needs a recent code |
| POST | `/me/totp/enable` | `{"code": "123456"}`: confirms the secret from setup. Returns `{"recovery_codes": [...]}`, shown once, and logs out your other sessions |
| POST | `/me/totp/disable` | `{"password": "...", "code": "..."}` (a code or a recovery code). Refused (409) while `totp.require` applies to you |
| POST | `/me/totp/recovery-codes` | New recovery codes, replacing the old ones (needs a recent code) |
| DELETE | `/users/{id}/totp` | admin: turn off someone's two-factor login, delete their recovery codes and end their sessions |
User and token changes take effect immediately. They are not changesets,
because they don't touch named, but they are audited.