Merge branch 'totp'

Both branches added database migration 3: DNSSEC (merged first) keeps 3
and TOTP becomes migration 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-09 15:17:12 +00:00
commit a95dffb471
35 files changed
+1963 -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
@@ -453,13 +473,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.
+8 -1
View File
@@ -365,7 +365,9 @@ jarl user create alice --role dns-editor # asks for a password
jarl user create ci --no-password --grant "records:write zones=lab.adbraun.lol"
jarl user update alice --admin
jarl user update alice --password # set a new password
jarl user update bob --disabled # logs bob out, blocks his tokens
jarl user update bob --disabled # logs bob out, blocks bob's tokens
jarl user update admin --rename adam # rename (e.g. the first account, to your own name)
jarl user totp reset alice # turn off alice's two-factor login (lost phone)
jarl user delete bob -y
jarl token list [--user alice | --all] [--revoked]
@@ -377,6 +379,11 @@ jarl token rotate laptop --save # new secret, same token
jarl token rotate --current --save --grace 10m # the token this CLI uses
jarl token revoke 7
# 'jarl user list' and 'jarl user show' include two-factor login (2FA: on,
# off, or MISSING when totp.require applies and it isn't set up). Users set
# it up themselves in the web UI (Account); the CLI's tokens never need a
# code (permissions.md#two-factor-login).
jarl audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100]
jarl search 192.168.1.20
jarl status # named, adoption, drift, open changesets, backups, expiring tokens, problems
+2
View File
@@ -25,6 +25,8 @@ The CLI has its own small configuration file, described in [cli.md](cli.md).
| `session.lifetime` | `12h` | A web session ends this long after login... |
| `session.idle_timeout` | `2h` | ...or after this long without requests. |
| `session.cookie_secure` | `auto` | `auto` sets the cookie's Secure flag when the request came over HTTPS (directly or via a trusted proxy); `always` / `never` force it. |
| `totp.require` | `none` | Who must use [two-factor login](permissions.md#two-factor-login): `none` (each user decides), `admins`, or `all` (every user with a password). Someone who must and hasn't set it up can only set it up after logging in. API tokens and the local socket are not affected. |
| `totp.issuer` | `""` | The name authenticator apps show for the account. Empty means `Jarl <short hostname>`, so several servers are told apart. At most 64 characters, no `:`. Changing it doesn't affect apps already set up. |
Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`).
+5 -3
View File
@@ -241,12 +241,14 @@ tar czf /root/bind-before-jarl-$(date +%F).tgz /etc/bind /var/lib/bind /var/cach
## 5. Create the database and the first administrator
```sh
sudo -u jarl jarld init --token \
sudo -u jarl jarld init --token -u adam \
--nameservers ns1.adbraun.lol. --contact hostmaster@adbraun.lol
```
It asks for the administrator's password (user `admin`, change it with
`-u NAME`). `--token` also prints an API token for the CLI, shown only
It asks for the administrator's password. Name the account after yourself
(`-u`, default `admin`): each person should have their own account, with
their own [two-factor login](permissions.md#two-factor-login), which you
can turn on under Account once you have logged in. `--token` also prints an API token for the CLI, shown only
once. `--nameservers` and `--contact` are the defaults for zones you create
later, and you can change them in the UI or with `jarl settings set`.
+1
View File
@@ -237,6 +237,7 @@ socket, without a token.
| Forgot the admin password | `sudo jarl user update admin --password`, or (also with jarld stopped) `sudo -u jarl jarld user passwd admin` |
| Locked out by the login rate limit | wait 15 minutes, or `systemctl restart jarl` (the limiter is in memory) |
| Lost every admin account | `sudo -u jarl jarld user add --admin rescue` |
| Lost the phone with the authenticator app, and the recovery codes | another administrator: Users → Reset 2FA, or `jarl user totp reset NAME`; on the server: `sudo -u jarl jarld user totp reset NAME` |
| Need a token without the web UI | `sudo jarl token create cli --user admin`, or `sudo -u jarl jarld user token admin cli` |
## A secondary server
+90 -1
View File
@@ -4,7 +4,7 @@
| Who | How | Notes |
|-----|-----|-------|
| People in the web UI | Username + password → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. |
| People in the web UI | Username + password (+ a code from an authenticator app, with [two-factor login](#two-factor-login)) → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. |
| CLI, scripts, certbot | API token: `Authorization: Bearer jarl_...` | Shown once at creation, stored hashed. Optional expiry. Revocable. Last use time and IP are recorded. |
| lego / Traefik (`httpreq`) | HTTP Basic: the token's user and the API token | Only on `/api/v1/acme/httpreq/...`, only when `acme.httpreq.enabled` is set, and by default only over HTTPS ([acme.md](acme.md)). The token's grants apply as usual. |
| Root on the server, with the CLI | jarld's local socket (`/run/jarl/jarl.sock`): no token | `sudo jarl ...` with no server configured. The kernel tells jarld which system user is connecting; root and the `jarl` user are administrators, anyone else must send a token. Audited as the system user (e.g. `root`) via `socket`. See [The local socket](#the-local-socket). |
@@ -24,6 +24,94 @@ A user **without a password** (`--no-password`) is a service account. It
can't log in to the web UI and is used only through its tokens, which suits
certbot, CI jobs and backups.
## Two-factor login
Users can protect their web login with a second factor: a 6-digit code
from an authenticator app on their phone (TOTP: Aegis, Google
Authenticator, 1Password, Bitwarden and the like). A stolen password alone
then gets nobody in.
**Setting it up** (Account → Two-factor login): enter your password, scan
the QR code (or type the key shown under it), and enter the code the app
shows. Only then is it on, so a bad scan can't lock you out. Jarl then
shows **10 recovery codes**, once: save them in a password manager or print
them. Each logs you in once instead of a code, for when the phone is lost.
Turning it on logs out your other sessions.
**Logging in**: username and password first; the second screen asks for
the code (or, under "Lost your phone?", a recovery code). The password
step alone gives no session: it is good for 5 minutes and 5 tries. Codes
from one 30-second step either side of now are accepted, for clocks that
are a little off, and each code works only once. Wrong codes count
against the same rate limit as wrong passwords.
**Actions that ask for a code again.** Some things would let a stolen
session or password outlive the second factor, so with two-factor login
they need a code from the last 5 minutes (logging in counts): creating
or rotating API tokens, creating, changing or deleting users, and
resetting someone's two-factor login. The web UI asks for a code when
needed; API clients send it in the `X-Jarl-TOTP` header
([api.md](api.md#authentication)).
**What it doesn't cover**: API tokens and the local socket. A token is
already its own long random secret, and scripts can't answer a code
prompt, which is why making one asks for a code. Root on the server can
read the database anyway, so a code would add nothing there.
**Making it compulsory**: `totp.require` in jarl.yaml
([configuration.md](configuration.md#web-ui-and-api)):
| `totp.require` | Who must use it |
|--|--|
| `none` (default) | nobody; each user decides |
| `admins` | administrators |
| `all` | every user with a password (service accounts can't log in anyway) |
A user who must use it and hasn't set it up yet can log in with the
password, but then only sees "Set up two-factor login" until they do. They
can't turn it off while it is required (moving to a new phone is "Move to
a new phone" under Account). The setting lives in jarl.yaml rather than in
the web UI on purpose: someone who steals an administrator's login can't
switch it off. The dashboard and `jarl status` list users who must use it
but haven't set it up.
**Lost phone**:
* With a recovery code: log in with it, then Account → Move to a new
phone. New recovery codes replace the old ones (Account → New recovery
codes; Jarl warns when 3 or fewer are left).
* Without: an administrator resets it (Users → Reset 2FA, or `jarl user
totp reset NAME`). That turns it off, deletes the recovery codes and
logs the user out; they log in with the password and set it up again.
* No administrator can log in: on the server, `sudo -u jarl jarld user
totp reset NAME` (works with jarld stopped too).
Every setup, change, failed code, recovery code use and reset is in the
audit log (`user.totp_*`, `auth.totp_failed`).
The secret is stored in the database, like TSIG secrets (it can't be
hashed: the server needs it to work out the codes), so it is as safe as
the database file and its backups (mode 0600, root and `jarl` only).
Recovery codes are stored hashed.
### One account per person
`jarld init` creates the first administrator, `admin` unless you name it
(`-u NAME`). If several people use that one account, nobody can tell from
the audit log who did what, and it can't have two-factor login per person.
So:
* **Just you**: rename it to your name (`jarl user update admin --rename
adam`, or Users → Edit) and turn on two-factor login.
* **Several people**: give each their own administrator account with their
own two-factor login, then disable `admin` (`jarl user update admin
--disabled`). The dashboard and `jarl status` point this out while an
enabled `admin` exists next to other administrators.
Nothing is lost by disabling it: root on the server always gets back in
([Recovering access](#recovering-access)), and Jarl refuses to disable,
demote or delete the last enabled administrator.
## Grants
A user's permissions are a list of grants. Administrators (`admin: true`)
@@ -238,4 +326,5 @@ sudo -u jarl jarld user list
sudo -u jarl jarld user passwd admin # set a new password and re-enable the account
sudo -u jarl jarld user add --admin rescue # a new administrator
sudo -u jarl jarld user token admin cli # a new API token for a user
sudo -u jarl jarld user totp reset admin # turn off two-factor login (a lost phone)
```