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

+26
View File
@@ -8,6 +8,29 @@ All notable changes to Jarl are documented here. The format follows
### Added ### Added
- Two-factor login to the web UI with an authenticator app (TOTP: SHA-1,
6 digits, 30 s, as every app supports). Users turn it on under Account
(password, QR code, a code to confirm) and get 10 one-time recovery
codes. Logging in then asks for a code (or a recovery code) after the
password; each code works once. In a session with two-factor login,
creating or rotating API tokens, changing users and resetting someone's
two-factor login need a code from the last 5 minutes: the web UI asks
for one, API clients send `X-Jarl-TOTP`. API tokens and the local socket
are not affected.
- `totp.require` in jarl.yaml (`none`, `admins`, `all`) makes two-factor
login compulsory; such a user who hasn't set it up can only set it up
after logging in. `totp.issuer` names the server in authenticator apps
(default `Jarl <hostname>`).
- Resetting a user's two-factor login (lost phone): Users → Reset 2FA,
`jarl user totp reset NAME`, or on the server
`jarld user totp reset NAME` (also with jarld stopped).
- The dashboard and `jarl status` list users who must use two-factor login
but haven't set it up, and suggest disabling the first `admin` account
when it is still active next to personal administrator accounts.
`jarl user list`/`show` and the Users page show who uses it.
- API: `POST /auth/login/totp`, `/me/totp` (`setup`, `enable`, `disable`,
`recovery-codes`), `DELETE /users/{id}/totp`; database migration 3.
- `scripts/testenv.sh`: turns a fresh Debian/Ubuntu machine into a test bed. - `scripts/testenv.sh`: turns a fresh Debian/Ubuntu machine into a test bed.
It installs BIND and the build tools (Go from go.dev), writes a hand-made It installs BIND and the build tools (Go from go.dev), writes a hand-made
style configuration with internal and external views, a split-horizon style configuration with internal and external views, a split-horizon
@@ -17,6 +40,9 @@ All notable changes to Jarl are documented here. The format follows
### Fixed ### Fixed
- `jarld init -u` and the install guide recommend naming the first
administrator after yourself (one account per person).
- `jarld import` no longer warns that a zone with `allow-update { none; }` - `jarld import` no longer warns that a zone with `allow-update { none; }`
uses dynamic updates (validation already allowed it). uses dynamic updates (validation already allowed it).
- docs/cli.md gives the CLI configuration file's location on macOS - docs/cli.md gives the CLI configuration file's location on macOS
+6 -2
View File
@@ -43,6 +43,10 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
using it). using it).
Passwords use argon2id, sessions have CSRF protection, logins are rate Passwords use argon2id, sessions have CSRF protection, logins are rate
limited. limited.
* **Two-factor login** with any authenticator app (TOTP), with recovery
codes; optional per user, or required for administrators or everyone
(`totp.require`). With it on, creating API tokens and changing users
asks for a fresh code, so a stolen password can't be turned into a token.
* **Audit log** of every login, change and token, with user, token, IP and * **Audit log** of every login, change and token, with user, token, IP and
outcome. outcome.
* **Daily backups** of the database by a systemd timer, checked as they are * **Daily backups** of the database by a systemd timer, checked as they are
@@ -99,7 +103,7 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
| [docs/configuration.md](docs/configuration.md) | `/etc/jarl/jarl.yaml` reference | | [docs/configuration.md](docs/configuration.md) | `/etc/jarl/jarl.yaml` reference |
| [docs/cli.md](docs/cli.md) | The `jarl` command line client | | [docs/cli.md](docs/cli.md) | The `jarl` command line client |
| [docs/api.md](docs/api.md) | REST API reference | | [docs/api.md](docs/api.md) | REST API reference |
| [docs/permissions.md](docs/permissions.md) | Users, tokens, grants and roles | | [docs/permissions.md](docs/permissions.md) | Users, tokens, grants and roles, two-factor login |
| [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none | | [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none |
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik | | [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention | | [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
@@ -133,7 +137,7 @@ these pages:
* **Changes**: pending edits with the diff, named-checkconf output and * **Changes**: pending edits with the diff, named-checkconf output and
problems, applied as one changeset. History lists applied changesets with problems, applied as one changeset. History lists applied changesets with
their diffs and a revert button. their diffs and a revert button.
* **Audit, Users, Account** (password, API tokens) and **Search**. * **Audit, Users, Account** (password, two-factor login, API tokens) and **Search**.
By default, edits are collected as pending changes. Pending rows are shown By default, edits are collected as pending changes. Pending rows are shown
inline (green = new, yellow = changed, struck out = deleted) until you inline (green = new, yellow = changed, struck out = deleted) until you
+70 -3
View File
@@ -200,6 +200,14 @@ func statusCmd() *cobra.Command {
for _, t := range resp.ExpiringTokens { for _, t := range resp.ExpiringTokens {
fmt.Fprintf(w, "Token:\t%s/%s (id %d) expires %s\n", t.Username, t.Name, t.ID, fmtExpiry(t.ExpiresAt, nil)) fmt.Fprintf(w, "Token:\t%s/%s (id %d) expires %s\n", t.Username, t.Name, t.ID, fmtExpiry(t.ExpiresAt, nil))
} }
if o := resp.TOTP; o != nil {
if len(o.Missing) > 0 {
fmt.Fprintf(w, "2FA:\trequired (%s) but not set up by %s\n", o.Policy, strings.Join(o.Missing, ", "))
}
if o.SharedAdmin != "" {
fmt.Fprintf(w, "Accounts:\tthe first account %q is still active next to personal administrator accounts; disable it if it is shared ('jarl user update %s --disabled')\n", o.SharedAdmin, o.SharedAdmin)
}
}
if len(st.Problems) > 0 { if len(st.Problems) > 0 {
w.(interface{ Flush() error }).Flush() w.(interface{ Flush() error }).Flush()
fmt.Println("Problems:") fmt.Println("Problems:")
@@ -333,6 +341,19 @@ func userID(c *client.Client, name string) (int64, error) {
return 0, fmt.Errorf("no user %q", name) return 0, fmt.Errorf("no user %q", name)
} }
// twoFA describes a user's two-factor login.
func twoFA(u api.User) string {
switch {
case u.TOTPEnabledAt != nil:
return "on since " + fmtTime(u.TOTPEnabledAt)
case u.TOTPRequired:
return "REQUIRED, not set up (they can only set it up until they do)"
case !u.HasPassword:
return "-"
}
return "off"
}
func printUser(w io.Writer, u api.User) { func printUser(w io.Writer, u api.User) {
fmt.Fprintf(w, "User:\t%s (id %d)\n", u.Username, u.ID) fmt.Fprintf(w, "User:\t%s (id %d)\n", u.Username, u.ID)
role := "user" role := "user"
@@ -346,6 +367,7 @@ func printUser(w io.Writer, u api.User) {
role += ", no password (tokens only)" role += ", no password (tokens only)"
} }
fmt.Fprintf(w, "Role:\t%s\n", role) fmt.Fprintf(w, "Role:\t%s\n", role)
fmt.Fprintf(w, "2FA:\t%s\n", twoFA(u))
for i, g := range u.GrantStrings { for i, g := range u.GrantStrings {
label := "" label := ""
if i == 0 { if i == 0 {
@@ -384,9 +406,18 @@ func userCmd() *cobra.Command {
return err return err
} }
return output(users, func(w io.Writer) { return output(users, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tADMIN\tDISABLED\tGRANTS\tLAST LOGIN") fmt.Fprintln(w, "ID\tUSER\tADMIN\tDISABLED\t2FA\tGRANTS\tLAST LOGIN")
for _, u := range users { for _, u := range users {
fmt.Fprintf(w, "%d\t%s\t%v\t%v\t%s\t%s\n", u.ID, u.Username, u.Admin, u.Disabled, strings.Join(u.GrantStrings, "; "), fmtTime(u.LastLoginAt)) tfa := "off"
switch {
case u.TOTPEnabledAt != nil:
tfa = "on"
case u.TOTPRequired:
tfa = "MISSING"
case !u.HasPassword:
tfa = "-"
}
fmt.Fprintf(w, "%d\t%s\t%v\t%v\t%s\t%s\t%s\n", u.ID, u.Username, u.Admin, u.Disabled, tfa, strings.Join(u.GrantStrings, "; "), fmtTime(u.LastLoginAt))
} }
}) })
}, },
@@ -548,7 +579,43 @@ func userCmd() *cobra.Command {
}, },
} }
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation") del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
cmd.AddCommand(list, show, create, update, del) totpCmd := &cobra.Command{Use: "totp", Short: "Two-factor login of a user"}
totpReset := &cobra.Command{
Use: "reset USER",
Short: "Turn off a user's two-factor login (a lost phone without recovery codes)",
Long: `Turns off a user's two-factor login, deletes their recovery codes and logs
them out. They log in with the password alone next time and can set it up
again under Account (they must, if totp.require applies to them).
Users set up two-factor login themselves, in the web UI. Without any
administrator able to log in, use 'sudo -u jarl jarld user totp reset USER'
on the server.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Turn off two-factor login of "+args[0]+"?", yes) {
return fmt.Errorf("canceled")
}
c, err := newClient()
if err != nil {
return err
}
id, err := userID(c, args[0])
if err != nil {
return err
}
var u api.User
if err := c.Do(ctx(), "DELETE", fmt.Sprintf("/users/%d/totp", id), nil, nil, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("two-factor login of %s is off\n", u.Username)
}
return nil
},
}
totpReset.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
totpCmd.AddCommand(totpReset)
cmd.AddCommand(list, show, create, update, del, totpCmd)
return cmd return cmd
} }
+40 -2
View File
@@ -297,6 +297,7 @@ func serveCmd() *cobra.Command {
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure, SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure,
Version: version.Version, Stats: collector, Socket: a.cfg.Socket, Version: version.Version, Stats: collector, Socket: a.cfg.Socket,
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter), BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
HTTPReq: server.HTTPReq{ HTTPReq: server.HTTPReq{
Enabled: a.cfg.ACME.HTTPReq.Enabled, Enabled: a.cfg.ACME.HTTPReq.Enabled,
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS, AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
@@ -464,7 +465,7 @@ Jarl starts empty. Either import your existing BIND configuration with
return nil return nil
}, },
} }
cmd.Flags().StringVarP(&username, "user", "u", "admin", "administrator username") cmd.Flags().StringVarP(&username, "user", "u", "admin", "the first administrator's username: best your own name, as each person should have their own account")
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input") cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.Flags().BoolVar(&withToken, "token", false, "also create an API token for the CLI") cmd.Flags().BoolVar(&withToken, "token", false, "also create an API token for the CLI")
cmd.Flags().StringSliceVar(&nameservers, "nameservers", nil, "default name servers for new zones (e.g. ns1.example.com.)") cmd.Flags().StringSliceVar(&nameservers, "nameservers", nil, "default name servers for new zones (e.g. ns1.example.com.)")
@@ -610,6 +611,9 @@ func userCmd() *cobra.Command {
if !u.HasPassword { if !u.HasPassword {
flags = append(flags, "no password") flags = append(flags, "no password")
} }
if u.HasTOTP() {
flags = append(flags, "2fa")
}
var gs []string var gs []string
for _, g := range u.Grants { for _, g := range u.Grants {
gs = append(gs, auth.FormatGrant(g)) gs = append(gs, auth.FormatGrant(g))
@@ -619,6 +623,40 @@ func userCmd() *cobra.Command {
return nil return nil
}, },
} }
cmd.AddCommand(add, passwd, token, list) totpCmd := &cobra.Command{Use: "totp", Short: "Two-factor login of a user"}
totpReset := &cobra.Command{
Use: "reset NAME",
Short: "Turn off a user's two-factor login (a lost phone without recovery codes)",
Long: `Turns off a user's two-factor login and deletes their recovery codes, and
logs them out. They log in with the password alone next time; if
totp.require applies to them, they must then set it up again before doing
anything else. Works with jarld stopped too.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.close()
ctx := context.Background()
u, err := a.store.GetUserByName(ctx, args[0])
if err != nil {
return fmt.Errorf("user %s: %w", args[0], err)
}
if !u.HasTOTP() && u.TOTPPending == "" {
fmt.Printf("%s doesn't use two-factor login.\n", u.Username)
return nil
}
if err := a.store.DisableTOTP(ctx, u.ID); err != nil {
return err
}
a.store.DeleteUserSessions(ctx, u.ID, "")
a.eng.Audit(ctx, &auth.Principal{User: &model.User{Username: "local"}, Method: "local"}, "user.totp_reset", "user", u.ID, u.Username, "success", "jarld user totp reset")
fmt.Printf("Two-factor login of %s is off; they log in with the password and can set it up again under Account.\n", u.Username)
return nil
},
}
totpCmd.AddCommand(totpReset)
cmd.AddCommand(add, passwd, token, list, totpCmd)
return cmd return cmd
} }
+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. `POST /auth/logout` ends the session.
`GET /auth/whoami` returns the user, the token (if any), the effective `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`), **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 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 | | 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) | | 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 | `/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 / 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 | | GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
| DELETE | `/tokens/{id}` | revoke | | 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 | `/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) | | 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, User and token changes take effect immediately. They are not changesets,
because they don't touch named, but they are audited. because they don't touch named, but they are audited.
+8 -1
View File
@@ -334,7 +334,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 create ci --no-password --grant "records:write zones=lab.adbraun.lol"
jarl user update alice --admin jarl user update alice --admin
jarl user update alice --password # set a new password 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 user delete bob -y
jarl token list [--user alice | --all] [--revoked] jarl token list [--user alice | --all] [--revoked]
@@ -346,6 +348,11 @@ jarl token rotate laptop --save # new secret, same token
jarl token rotate --current --save --grace 10m # the token this CLI uses jarl token rotate --current --save --grace 10m # the token this CLI uses
jarl token revoke 7 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 audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100]
jarl search 192.168.1.20 jarl search 192.168.1.20
jarl status # named, adoption, drift, open changesets, backups, expiring tokens, problems 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.lifetime` | `12h` | A web session ends this long after login... |
| `session.idle_timeout` | `2h` | ...or after this long without requests. | | `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. | | `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`). Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`).
+5 -3
View File
@@ -230,12 +230,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 ## 5. Create the database and the first administrator
```sh ```sh
sudo -u jarl jarld init --token \ sudo -u jarl jarld init --token -u adam \
--nameservers ns1.adbraun.lol. --contact hostmaster@adbraun.lol --nameservers ns1.adbraun.lol. --contact hostmaster@adbraun.lol
``` ```
It asks for the administrator's password (user `admin`, change it with It asks for the administrator's password. Name the account after yourself
`-u NAME`). `--token` also prints an API token for the CLI, shown only (`-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 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`. later, and you can change them in the UI or with `jarl settings set`.
+1
View File
@@ -218,6 +218,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` | | 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) | | 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 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` | | 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 ## A secondary server
+90 -1
View File
@@ -4,7 +4,7 @@
| Who | How | Notes | | 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. | | 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. | | 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). | | 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 can't log in to the web UI and is used only through its tokens, which suits
certbot, CI jobs and backups. 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 ## Grants
A user's permissions are a list of grants. Administrators (`admin: true`) A user's permissions are a list of grants. Administrators (`admin: true`)
@@ -237,4 +325,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 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 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 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)
``` ```
+1
View File
@@ -11,6 +11,7 @@ require (
golang.org/x/net v0.58.0 golang.org/x/net v0.58.0
golang.org/x/term v0.46.0 golang.org/x/term v0.46.0
modernc.org/sqlite v1.60.1 modernc.org/sqlite v1.60.1
rsc.io/qr v0.2.0
) )
require ( require (
+2
View File
@@ -72,3 +72,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+79
View File
@@ -68,6 +68,9 @@ type Option struct {
type User struct { type User struct {
model.User model.User
GrantStrings []string `json:"grant_strings"` GrantStrings []string `json:"grant_strings"`
// TOTPRequired says that totp.require in jarl.yaml makes two-factor
// login compulsory for the user.
TOTPRequired bool `json:"totp_required,omitempty"`
} }
// Token is an API token. Secret is only set when the token is created. // Token is an API token. Secret is only set when the token is created.
@@ -86,6 +89,67 @@ type Whoami struct {
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
Admin bool `json:"admin"` Admin bool `json:"admin"`
Version string `json:"version"` Version string `json:"version"`
// TOTP is the user's two-factor login (sessions only).
TOTP *TOTPStatus `json:"totp,omitempty"`
// TOTPSetupRequired: the user must set up two-factor login before
// doing anything else (totp.require).
TOTPSetupRequired bool `json:"totp_setup_required,omitempty"`
}
// LoginChallenge answers a correct password of a user with two-factor
// login: send a code with the challenge to POST /auth/login/totp.
type LoginChallenge struct {
TOTPRequired bool `json:"totp_required"`
Challenge string `json:"challenge"`
}
// LoginTOTP is the second login step.
type LoginTOTP struct {
Challenge string `json:"challenge"`
// Code is the current code from the authenticator app, or a recovery
// code.
Code string `json:"code"`
}
// TOTPStatus is a user's two-factor login (GET /me/totp).
type TOTPStatus struct {
Enabled bool `json:"enabled"`
EnabledAt *time.Time `json:"enabled_at,omitempty"`
Required bool `json:"required"`
RecoveryCodesLeft int `json:"recovery_codes_left"`
// Policy is totp.require: none, admins or all.
Policy string `json:"policy"`
}
// TOTPSetupRequest starts setting up two-factor login.
type TOTPSetupRequest struct {
Password string `json:"password"`
}
// TOTPSetup is a new secret to put into an authenticator app: QR is the
// QR code of URI, as an image data: URI.
type TOTPSetup struct {
Secret string `json:"secret"`
URI string `json:"uri"`
QR string `json:"qr"`
Issuer string `json:"issuer"`
Account string `json:"account"`
}
// TOTPCode confirms a setup.
type TOTPCode struct {
Code string `json:"code"`
}
// TOTPDisable turns two-factor login off.
type TOTPDisable struct {
Password string `json:"password"`
Code string `json:"code"`
}
// RecoveryCodes are new recovery codes, shown once.
type RecoveryCodes struct {
Codes []string `json:"recovery_codes"`
} }
// LoginRequest logs in. // LoginRequest logs in.
@@ -158,6 +222,21 @@ type StatusResponse struct {
ExpiringTokens []Token `json:"expiring_tokens"` ExpiringTokens []Token `json:"expiring_tokens"`
// Backup is the state of database backups (administrators only). // Backup is the state of database backups (administrators only).
Backup *BackupStatus `json:"backup,omitempty"` Backup *BackupStatus `json:"backup,omitempty"`
// TOTP is about two-factor login across users (administrators only).
TOTP *TOTPOverview `json:"totp,omitempty"`
}
// TOTPOverview is what the dashboard reports about two-factor login.
type TOTPOverview struct {
// Policy is totp.require: none, admins or all.
Policy string `json:"policy"`
// Missing are enabled users who must use two-factor login and
// haven't set it up (they can do nothing else until they do).
Missing []string `json:"missing"`
// SharedAdmin is set when an enabled administrator called "admin"
// (the usual first account) exists next to other enabled
// administrators: it is probably shared, and worth disabling.
SharedAdmin string `json:"shared_admin,omitempty"`
} }
// TokenExpiresHeader is sent with responses to a token that expires // TokenExpiresHeader is sent with responses to a token that expires
+41
View File
@@ -29,6 +29,7 @@ type Config struct {
StagingDir string `yaml:"staging_dir"` StagingDir string `yaml:"staging_dir"`
Log Log `yaml:"log"` Log Log `yaml:"log"`
Session Session `yaml:"session"` Session Session `yaml:"session"`
TOTP TOTP `yaml:"totp"`
BIND BIND `yaml:"bind"` BIND BIND `yaml:"bind"`
Audit Audit `yaml:"audit"` Audit Audit `yaml:"audit"`
Backup Backup `yaml:"backup"` Backup Backup `yaml:"backup"`
@@ -93,6 +94,28 @@ type Session struct {
} }
// BIND says where BIND's files and tools are. // BIND says where BIND's files and tools are.
// TOTP configures two-factor login to the web UI (docs/permissions.md).
type TOTP struct {
// Require is who must use it: none (each user decides), admins or
// all (every user who logs in with a password).
Require string `yaml:"require"`
// Issuer is the name authenticator apps show for the account ("" is
// "Jarl <hostname>").
Issuer string `yaml:"issuer"`
}
// IssuerName is Issuer, or "Jarl <short hostname>".
func (t TOTP) IssuerName() string {
if t.Issuer != "" {
return t.Issuer
}
h, _ := os.Hostname()
if h, _, _ = strings.Cut(h, "."); h == "" {
return "Jarl"
}
return "Jarl " + h
}
type BIND struct { type BIND struct {
MainConfig string `yaml:"main_config"` MainConfig string `yaml:"main_config"`
ConfigDir string `yaml:"config_dir"` ConfigDir string `yaml:"config_dir"`
@@ -194,6 +217,7 @@ func Default() *Config {
StagingDir: "/var/lib/jarl/staging", StagingDir: "/var/lib/jarl/staging",
Log: Log{Level: "info", Format: "text"}, Log: Log{Level: "info", Format: "text"},
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"}, Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
TOTP: TOTP{Require: "none"},
BIND: BIND{ BIND: BIND{
MainConfig: "/etc/bind/named.conf", MainConfig: "/etc/bind/named.conf",
ConfigDir: "/etc/bind/jarl", ConfigDir: "/etc/bind/jarl",
@@ -267,6 +291,14 @@ func (c *Config) check() error {
default: default:
return fmt.Errorf("session.cookie_secure must be auto, always or never") return fmt.Errorf("session.cookie_secure must be auto, always or never")
} }
switch c.TOTP.Require {
case "none", "admins", "all":
default:
return fmt.Errorf("totp.require must be none, admins or all")
}
if len(c.TOTP.Issuer) > 64 || strings.ContainsAny(c.TOTP.Issuer, ":") {
return fmt.Errorf("totp.issuer must be at most 64 characters, without ':'")
}
if err := c.Stats.check(); err != nil { if err := c.Stats.check(); err != nil {
return fmt.Errorf("stats: %w", err) return fmt.Errorf("stats: %w", err)
} }
@@ -404,6 +436,15 @@ session:
idle_timeout: 2h # ...or after this long without activity idle_timeout: 2h # ...or after this long without activity
cookie_secure: auto # auto, always or never cookie_secure: auto # auto, always or never
# Two-factor login to the web UI with an authenticator app (TOTP). Each
# user can turn it on under Account. require makes it compulsory: none,
# admins, or all (every user with a password). A user who must use it and
# hasn't set it up can only set it up after logging in. API tokens and
# the local socket are not affected. See docs/permissions.md.
totp:
require: none
issuer: "" # the name in the app; "" is "Jarl <hostname>"
bind: bind:
# The file named loads. 'jarld adopt' makes it include Jarl's config. # The file named loads. 'jarld adopt' makes it include Jarl's config.
main_config: /etc/bind/named.conf main_config: /etc/bind/named.conf
+12
View File
@@ -70,8 +70,17 @@ type User struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"` LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// TOTPEnabledAt is when the user turned on two-factor login (nil:
// off). The secrets never leave the server.
TOTPEnabledAt *time.Time `json:"totp_enabled_at,omitempty"`
TOTPSecret string `json:"-"`
TOTPPending string `json:"-"`
TOTPLastStep int64 `json:"-"`
} }
// HasTOTP reports whether the user logs in with a TOTP code.
func (u *User) HasTOTP() bool { return u.TOTPSecret != "" }
// Token is an API key. The secret is shown once at creation and only its // Token is an API key. The secret is shown once at creation and only its
// hash is stored. // hash is stored.
type Token struct { type Token struct {
@@ -125,6 +134,9 @@ type Session struct {
LastSeenAt time.Time `json:"last_seen_at"` LastSeenAt time.Time `json:"last_seen_at"`
IP string `json:"ip"` IP string `json:"ip"`
UserAgent string `json:"user_agent"` UserAgent string `json:"user_agent"`
// TOTPAt is when the session last proved a TOTP code (at login or
// since); nil for a session without two-factor login.
TOTPAt *time.Time `json:"totp_at,omitempty"`
} }
// AuditEvent records an action. // AuditEvent records an action.
+11 -1
View File
@@ -266,6 +266,10 @@ func (s *Server) authed(h handler) http.HandlerFunc {
// The CLI warns its user when it sees this. // The CLI warns its user when it sees this.
w.Header().Set(api.TokenExpiresHeader, p.Token.ExpiresAt.UTC().Format(time.RFC3339)) w.Header().Set(api.TokenExpiresHeader, p.Token.ExpiresAt.UTC().Format(time.RFC3339))
} }
if s.setupOnly(p) && !setupOnlyAllowed(r) {
s.writeErr(w, r, errSetupOnly, nil)
return
}
ctx := context.WithValue(r.Context(), keyPrincipal, p) ctx := context.WithValue(r.Context(), keyPrincipal, p)
if se != nil { if se != nil {
ctx = context.WithValue(ctx, keySession, se) ctx = context.WithValue(ctx, keySession, se)
@@ -327,6 +331,12 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason) s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason)
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"} return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
} }
if u.HasTOTP() {
// The password was right; the session comes with the code
// (POST /auth/login/totp).
s.loginChallenge(w, u)
return nil
}
s.limiter.reset(userKey) s.limiter.reset(userKey)
id, csrf := auth.NewSessionID() id, csrf := auth.NewSessionID()
now := time.Now() now := time.Now()
@@ -338,7 +348,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt}) http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()} p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
s.eng.Audit(r.Context(), p, "auth.login", "user", u.ID, u.Username, "success", "") s.eng.Audit(r.Context(), p, "auth.login", "user", u.ID, u.Username, "success", "")
writeJSON(w, http.StatusOK, s.whoamiOf(p, se)) writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se))
return nil return nil
} }
+22 -5
View File
@@ -18,7 +18,7 @@ import (
func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
se, _ := r.Context().Value(keySession).(*model.Session) se, _ := r.Context().Value(keySession).(*model.Session)
writeJSON(w, http.StatusOK, s.whoamiOf(p, se)) writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se))
return nil return nil
} }
@@ -136,6 +136,11 @@ func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.
if err := decodeJSON(r, &in); err != nil { if err := decodeJSON(r, &in); err != nil {
return err return err
} }
// A token outlives the session: with two-factor login, making one
// needs a recent code.
if err := s.stepUp(r, p); err != nil {
return err
}
if strings.TrimSpace(in.Name) == "" { if strings.TrimSpace(in.Name) == "" {
return badRequest("a token needs a name") return badRequest("a token needs a name")
} }
@@ -297,6 +302,9 @@ func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Pri
if t.RevokedAt != nil { if t.RevokedAt != nil {
return conflict("token %d is revoked; create a new one", id) return conflict("token %d is revoked; create a new one", id)
} }
if err := s.stepUp(r, p); err != nil {
return err
}
now := time.Now() now := time.Now()
var exp *time.Time var exp *time.Time
switch { switch {
@@ -365,7 +373,7 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Princ
} }
out := []api.User{} out := []api.User{}
for _, u := range us { for _, u := range us {
out = append(out, presentUser(u)) out = append(out, s.userOut(u))
} }
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return nil return nil
@@ -401,6 +409,9 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
if err := applyUserInput(u, &in); err != nil { if err := applyUserInput(u, &in); err != nil {
return err return err
} }
if err := s.stepUp(r, p); err != nil {
return err
}
if err := s.store.CreateUser(r.Context(), u); err != nil { if err := s.store.CreateUser(r.Context(), u); err != nil {
if errors.Is(err, store.ErrConflict) { if errors.Is(err, store.ErrConflict) {
return conflict("user %s already exists", u.Username) return conflict("user %s already exists", u.Username)
@@ -408,7 +419,7 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
return err return err
} }
s.eng.Audit(r.Context(), p, "user.create", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; ")) s.eng.Audit(r.Context(), p, "user.create", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; "))
writeJSON(w, http.StatusCreated, presentUser(u)) writeJSON(w, http.StatusCreated, s.userOut(u))
return nil return nil
} }
@@ -467,7 +478,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request, p *auth.Princip
if u.ID != p.User.ID && !p.IsAdmin() { if u.ID != p.User.ID && !p.IsAdmin() {
return forbidden("only administrators can see other users") return forbidden("only administrators can see other users")
} }
writeJSON(w, http.StatusOK, presentUser(u)) writeJSON(w, http.StatusOK, s.userOut(u))
return nil return nil
} }
@@ -493,6 +504,9 @@ func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
if err := applyUserInput(u, &in); err != nil { if err := applyUserInput(u, &in); err != nil {
return err return err
} }
if err := s.stepUp(r, p); err != nil {
return err
}
if wasAdmin && (!u.Admin || u.Disabled) { if wasAdmin && (!u.Admin || u.Disabled) {
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 { if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
return conflict("%s is the last administrator; make someone else an administrator first", u.Username) return conflict("%s is the last administrator; make someone else an administrator first", u.Username)
@@ -508,7 +522,7 @@ func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
s.store.DeleteUserSessions(r.Context(), u.ID, "") s.store.DeleteUserSessions(r.Context(), u.ID, "")
} }
s.eng.Audit(r.Context(), p, "user.update", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; ")) s.eng.Audit(r.Context(), p, "user.update", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; "))
writeJSON(w, http.StatusOK, presentUser(u)) writeJSON(w, http.StatusOK, s.userOut(u))
return nil return nil
} }
@@ -523,6 +537,9 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
if u.ID == p.User.ID { if u.ID == p.User.ID {
return conflict("you can't delete yourself") return conflict("you can't delete yourself")
} }
if err := s.stepUp(r, p); err != nil {
return err
}
if u.Admin && !u.Disabled { if u.Admin && !u.Disabled {
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 { if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
return conflict("%s is the last administrator", u.Username) return conflict("%s is the last administrator", u.Username)
+1
View File
@@ -276,6 +276,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
resp := api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)} resp := api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
if p.IsAdmin() { if p.IsAdmin() {
resp.Backup = s.backupStatus(r.Context(), now) resp.Backup = s.backupStatus(r.Context(), now)
resp.TOTP = s.totpOverview(r.Context())
} }
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
return nil return nil
+507
View File
@@ -0,0 +1,507 @@
package server
// Two-factor login with TOTP (docs/permissions.md#two-factor-login).
//
// Logging in with a password is the first step for a user with TOTP: it
// returns a short-lived challenge instead of a session, and
// POST /auth/login/totp with the challenge and a code (or a recovery code)
// creates the session. A session remembers when it last proved a code;
// actions that would let a stolen password outlive the second factor
// (creating or rotating API tokens, changing users, resetting someone's
// TOTP) need one from the last stepUpWindow, or a code in the X-Jarl-TOTP
// header. API tokens and the local socket are not affected: a token is
// its own secret, and can't answer a prompt.
//
// totp.require in jarl.yaml makes TOTP compulsory for administrators or
// for everyone. A user who must use it but hasn't set it up gets a session
// that can only set it up (setupOnly).
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/model"
"jarl/internal/totp"
)
const (
// challengeLifetime is how long the second login step may take.
challengeLifetime = 5 * time.Minute
// challengeTries is how many wrong codes one challenge allows.
challengeTries = 5
// stepUpWindow is how long a proved code counts for actions that
// ask for a recent one.
stepUpWindow = 5 * time.Minute
// TOTPHeader carries a code for an action that asks for a recent one.
TOTPHeader = "X-Jarl-TOTP"
)
// pendingLogins are logins waiting for their second step, by the hash of
// the challenge.
type pendingLogins struct {
mu sync.Mutex
m map[string]*pendingLogin
}
type pendingLogin struct {
userID int64
expires time.Time
tries int
}
func (c *pendingLogins) add(userID int64, now time.Time) string {
id, _ := auth.NewSessionID()
c.mu.Lock()
defer c.mu.Unlock()
if c.m == nil {
c.m = map[string]*pendingLogin{}
}
for k, v := range c.m {
if now.After(v.expires) {
delete(c.m, k)
}
}
c.m[auth.HashSecret(id)] = &pendingLogin{userID: userID, expires: now.Add(challengeLifetime)}
return id
}
func (c *pendingLogins) get(id string, now time.Time) *pendingLogin {
c.mu.Lock()
defer c.mu.Unlock()
ch := c.m[auth.HashSecret(id)]
if ch == nil || now.After(ch.expires) {
return nil
}
return ch
}
// fail counts a wrong code; the challenge ends after challengeTries.
func (c *pendingLogins) fail(id string) (left int) {
c.mu.Lock()
defer c.mu.Unlock()
h := auth.HashSecret(id)
ch := c.m[h]
if ch == nil {
return 0
}
ch.tries++
if ch.tries >= challengeTries {
delete(c.m, h)
return 0
}
return challengeTries - ch.tries
}
func (c *pendingLogins) done(id string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.m, auth.HashSecret(id))
}
// totpRequired reports whether totp.require makes TOTP compulsory for u.
// Service accounts (no password) can't log in, so it never applies to
// them.
func (s *Server) totpRequired(u *model.User) bool {
if u == nil || u.ID == 0 || u.PasswordHash == "" {
return false
}
switch s.cfg.TOTPRequire {
case "all":
return true
case "admins":
return u.Admin
}
return false
}
// setupOnly reports whether a session may only set up TOTP: the user
// must use it and hasn't set it up yet.
func (s *Server) setupOnly(p *auth.Principal) bool {
return p.Method == "session" && s.totpRequired(p.User) && !p.User.HasTOTP()
}
// setupOnlyAllowed are the requests a setup-only session may make.
func setupOnlyAllowed(r *http.Request) bool {
path := strings.TrimPrefix(r.URL.Path, api.Prefix)
switch {
case path == "/auth/whoami", path == "/auth/logout", path == "/me/password":
return true
case path == "/me/totp" || strings.HasPrefix(path, "/me/totp/"):
return true
}
return false
}
var errSetupOnly = &httpError{http.StatusForbidden, "totp_setup_required", "this server requires two-factor login for your account: set it up under Account first"}
// checkCode checks a TOTP code, or with recovery set also a recovery
// code, for u, and uses it up. It returns how it was proved ("totp" or
// "recovery code").
func (s *Server) checkCode(ctx context.Context, u *model.User, code string, recovery bool) (string, error) {
if !u.HasTOTP() {
return "", errors.New("two-factor login is not on")
}
if totp.IsCode(code) {
step, ok := totp.Verify(u.TOTPSecret, code, time.Now(), u.TOTPLastStep)
if ok {
if ok, err := s.store.UseTOTPStep(ctx, u.ID, step); err != nil {
return "", err
} else if ok {
u.TOTPLastStep = step
return "totp", nil
}
}
return "", errors.New("wrong or already used code")
}
if recovery && code != "" {
ok, err := s.store.UseRecoveryCode(ctx, u.ID, auth.HashSecret(totp.NormalizeRecovery(code)))
if err != nil {
return "", err
}
if ok {
return "recovery code", nil
}
return "", errors.New("wrong or already used recovery code")
}
return "", errors.New("wrong code")
}
// loginChallenge answers a correct password of a user with TOTP: the
// challenge for the second step.
func (s *Server) loginChallenge(w http.ResponseWriter, u *model.User) {
writeJSON(w, http.StatusOK, api.LoginChallenge{TOTPRequired: true, Challenge: s.pendingLogins.add(u.ID, time.Now())})
}
// loginTOTP is the second login step: POST /auth/login/totp.
func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) error {
var in api.LoginTOTP
if err := decodeJSON(r, &in); err != nil {
return err
}
ip := s.clientIP(r)
now := time.Now()
ch := s.pendingLogins.get(in.Challenge, now)
if ch == nil {
return &httpError{http.StatusUnauthorized, "login_expired", "the login took too long or had too many wrong codes; log in again"}
}
u, err := s.store.GetUser(r.Context(), ch.userID)
if err != nil || u.Disabled {
s.pendingLogins.done(in.Challenge)
return &httpError{http.StatusUnauthorized, "login_failed", "your account is not available"}
}
userKey := "user:" + strings.ToLower(u.Username)
who := &auth.Principal{User: u, IP: ip, Agent: r.UserAgent(), Method: "session"}
if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) {
s.eng.Audit(r.Context(), who, "auth.login_blocked", "user", u.ID, u.Username, "denied", "too many failed attempts")
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"}
}
how, err := s.checkCode(r.Context(), u, in.Code, true)
if err != nil {
s.limiter.fail("ip:"+ip, userKey)
left := s.pendingLogins.fail(in.Challenge)
s.eng.Audit(r.Context(), who, "auth.totp_failed", "user", u.ID, u.Username, "failure", err.Error())
msg := "wrong code: use the current code from your authenticator app, or a recovery code"
if left == 0 {
return &httpError{http.StatusUnauthorized, "login_expired", "too many wrong codes; log in again"}
}
return &httpError{http.StatusUnauthorized, "totp_invalid", msg}
}
s.pendingLogins.done(in.Challenge)
s.limiter.reset(userKey)
detail := "with an authenticator code"
if how == "recovery code" {
n, _ := s.store.RecoveryCodesLeft(r.Context(), u.ID)
detail = fmt.Sprintf("with a recovery code (%d left)", n)
}
return s.startSession(w, r, u, &now, detail)
}
// startSession creates a session for u after a successful login.
// totpAt is when it proved a code (nil without TOTP).
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *model.User, totpAt *time.Time, detail string) error {
ip := s.clientIP(r)
id, csrf := auth.NewSessionID()
now := time.Now()
se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent(), TOTPAt: totpAt}
if err := s.store.CreateSession(r.Context(), se); err != nil {
return err
}
s.store.TouchLogin(r.Context(), u.ID)
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
s.eng.Audit(r.Context(), p, "auth.login", "user", u.ID, u.Username, "success", detail)
writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se))
return nil
}
// stepUp makes sure a session of a user with TOTP proved a code within
// stepUpWindow, or proves one now from the X-Jarl-TOTP header. Tokens,
// the local socket and users without TOTP pass.
func (s *Server) stepUp(r *http.Request, p *auth.Principal) error {
if p.Method != "session" || !p.User.HasTOTP() {
return nil
}
se, _ := r.Context().Value(keySession).(*model.Session)
if se == nil {
return nil
}
now := time.Now()
if se.TOTPAt != nil && now.Sub(*se.TOTPAt) < stepUpWindow {
return nil
}
code := r.Header.Get(TOTPHeader)
if code == "" {
return &httpError{http.StatusForbidden, "totp_step_up", "this needs a current code from your authenticator app"}
}
ip := s.clientIP(r)
userKey := "user:" + strings.ToLower(p.User.Username)
if s.limiter.blocked(userKey, failsPerKey) {
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many wrong codes; wait a few minutes and try again"}
}
if _, err := s.checkCode(r.Context(), p.User, code, false); err != nil {
s.limiter.fail("ip:"+ip, userKey)
s.eng.Audit(r.Context(), p, "auth.totp_failed", "user", p.User.ID, p.User.Username, "failure", "step-up: "+err.Error())
return &httpError{http.StatusForbidden, "totp_invalid", "wrong or already used code; enter the current code from your authenticator app"}
}
s.store.SetSessionTOTP(r.Context(), se.IDHash, now)
se.TOTPAt = &now
return nil
}
func (s *Server) totpStatus(ctx context.Context, u *model.User) api.TOTPStatus {
st := api.TOTPStatus{Enabled: u.HasTOTP(), EnabledAt: u.TOTPEnabledAt, Required: s.totpRequired(u), Policy: s.cfg.TOTPRequire}
if st.Enabled {
st.RecoveryCodesLeft, _ = s.store.RecoveryCodesLeft(ctx, u.ID)
}
return st
}
// needSession refuses TOTP changes made with an API token or on the
// local socket: they belong to a person in the web UI.
func needSession(p *auth.Principal) error {
if p.Method != "session" {
return forbidden("two-factor login is set up in the web UI (Account), not with an API token")
}
return nil
}
// GET /me/totp
func (s *Server) getOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if p.User.ID == 0 {
return badRequest("%s on the local socket isn't a Jarl user", p.Name())
}
writeJSON(w, http.StatusOK, s.totpStatus(r.Context(), p.User))
return nil
}
// POST /me/totp/setup starts setting up (or moving to a new app): a new
// secret, shown as a QR code, that becomes active once a code from it is
// confirmed. It needs the password, and with TOTP already on a recent
// code too.
func (s *Server) setupOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := needSession(p); err != nil {
return err
}
var in api.TOTPSetupRequest
if err := decodeJSON(r, &in); err != nil {
return err
}
u := p.User
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Password) {
s.limiter.fail("user:" + strings.ToLower(u.Username))
s.eng.Audit(r.Context(), p, "user.totp_setup", "user", u.ID, u.Username, "failure", "wrong password")
return &httpError{http.StatusForbidden, "wrong_password", "the password is wrong"}
}
if err := s.stepUp(r, p); err != nil {
return err
}
secret := totp.NewSecret()
if err := s.store.SetTOTPPending(r.Context(), u.ID, secret); err != nil {
return err
}
issuer := s.cfg.TOTPIssuer
uri := totp.URI(issuer, u.Username, secret)
qr, err := totp.QRDataURI(uri)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, api.TOTPSetup{Secret: secret, URI: uri, QR: qr, Issuer: issuer, Account: u.Username})
return nil
}
// POST /me/totp/enable confirms the secret from setup with a code from
// the app. It returns the recovery codes (shown once) and logs out the
// user's other sessions.
func (s *Server) enableOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := needSession(p); err != nil {
return err
}
var in api.TOTPCode
if err := decodeJSON(r, &in); err != nil {
return err
}
u, err := s.store.GetUser(r.Context(), p.User.ID)
if err != nil {
return err
}
if u.TOTPPending == "" {
return conflict("start the setup first (POST /me/totp/setup)")
}
userKey := "user:" + strings.ToLower(u.Username)
if s.limiter.blocked(userKey, failsPerKey) {
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many wrong codes; wait a few minutes and try again"}
}
step, ok := totp.Verify(u.TOTPPending, in.Code, time.Now(), 0)
if !ok {
s.limiter.fail(userKey)
return &httpError{http.StatusBadRequest, "totp_invalid", "the code doesn't match: check that the app shows a 6-digit code for " + s.cfg.TOTPIssuer + " and that this device's clock is right, then try the current code"}
}
codes := totp.NewRecoveryCodes(totp.RecoveryCodes)
var hashes []string
for _, c := range codes {
hashes = append(hashes, auth.HashSecret(totp.NormalizeRecovery(c)))
}
replaced := u.HasTOTP()
if err := s.store.EnableTOTP(r.Context(), u.ID, u.TOTPPending, step, hashes); err != nil {
return err
}
se, _ := r.Context().Value(keySession).(*model.Session)
keep := ""
if se != nil {
keep = se.IDHash
s.store.SetSessionTOTP(r.Context(), se.IDHash, time.Now())
}
s.store.DeleteUserSessions(r.Context(), u.ID, keep)
detail := "turned on"
if replaced {
detail = "moved to a new authenticator"
}
s.eng.Audit(r.Context(), p, "user.totp_enable", "user", u.ID, u.Username, "success", detail)
writeJSON(w, http.StatusOK, api.RecoveryCodes{Codes: codes})
return nil
}
// POST /me/totp/disable turns TOTP off, with the password and a code (or
// a recovery code). Not when totp.require applies to the user.
func (s *Server) disableOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := needSession(p); err != nil {
return err
}
var in api.TOTPDisable
if err := decodeJSON(r, &in); err != nil {
return err
}
u := p.User
if !u.HasTOTP() {
return conflict("two-factor login is not on")
}
if s.totpRequired(u) {
return conflict("this server requires two-factor login for your account (totp.require in jarl.yaml); to move to a new phone, set it up again instead")
}
userKey := "user:" + strings.ToLower(u.Username)
if s.limiter.blocked(userKey, failsPerKey) {
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; wait a few minutes and try again"}
}
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Password) {
s.limiter.fail(userKey)
return &httpError{http.StatusForbidden, "wrong_password", "the password is wrong"}
}
if _, err := s.checkCode(r.Context(), u, in.Code, true); err != nil {
s.limiter.fail(userKey)
return &httpError{http.StatusForbidden, "totp_invalid", err.Error()}
}
if err := s.store.DisableTOTP(r.Context(), u.ID); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "user.totp_disable", "user", u.ID, u.Username, "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
// POST /me/totp/recovery-codes replaces the recovery codes (needs a
// recent code).
func (s *Server) newRecoveryCodes(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := needSession(p); err != nil {
return err
}
if !p.User.HasTOTP() {
return conflict("two-factor login is not on")
}
if err := s.stepUp(r, p); err != nil {
return err
}
codes := totp.NewRecoveryCodes(totp.RecoveryCodes)
var hashes []string
for _, c := range codes {
hashes = append(hashes, auth.HashSecret(totp.NormalizeRecovery(c)))
}
if err := s.store.ReplaceRecoveryCodes(r.Context(), p.User.ID, hashes); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "user.recovery_codes", "user", p.User.ID, p.User.Username, "success", "new recovery codes")
writeJSON(w, http.StatusOK, api.RecoveryCodes{Codes: codes})
return nil
}
// DELETE /users/{id}/totp: an administrator turns off someone's TOTP (a
// lost phone without recovery codes). Their sessions end; at the next
// login they log in with the password and, if totp.require applies, must
// set it up again.
func (s *Server) resetUserTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
u, err := s.userByPath(r)
if err != nil {
return err
}
if err := s.stepUp(r, p); err != nil {
return err
}
if !u.HasTOTP() && u.TOTPPending == "" {
return conflict("%s doesn't use two-factor login", u.Username)
}
if err := s.store.DisableTOTP(r.Context(), u.ID); err != nil {
return err
}
s.store.DeleteUserSessions(r.Context(), u.ID, "")
s.eng.Audit(r.Context(), p, "user.totp_reset", "user", u.ID, u.Username, "success", "")
u.TOTPSecret, u.TOTPEnabledAt = "", nil
writeJSON(w, http.StatusOK, s.userOut(u))
return nil
}
// totpOverview is the dashboard's view of two-factor login.
func (s *Server) totpOverview(ctx context.Context) *api.TOTPOverview {
us, err := s.store.ListUsers(ctx)
if err != nil {
return nil
}
o := &api.TOTPOverview{Policy: s.cfg.TOTPRequire, Missing: []string{}}
admins, shared := 0, ""
for _, u := range us {
if u.Disabled {
continue
}
if s.totpRequired(u) && !u.HasTOTP() {
o.Missing = append(o.Missing, u.Username)
}
if u.Admin {
admins++
if strings.EqualFold(u.Username, "admin") && u.PasswordHash != "" {
shared = u.Username
}
}
}
if shared != "" && admins > 1 {
o.SharedAdmin = shared
}
return o
}
+16 -2
View File
@@ -1,6 +1,8 @@
package server package server
import ( import (
"context"
"jarl/internal/api" "jarl/internal/api"
"jarl/internal/auth" "jarl/internal/auth"
"jarl/internal/catalog" "jarl/internal/catalog"
@@ -131,8 +133,20 @@ func presentToken(t *model.Token) api.Token {
return out return out
} }
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami { // userOut is presentUser with what depends on the server's settings.
out := api.Whoami{User: presentUser(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version} func (s *Server) userOut(u *model.User) api.User {
out := presentUser(u)
out.TOTPRequired = s.totpRequired(u)
return out
}
func (s *Server) whoamiOf(ctx context.Context, p *auth.Principal, se *model.Session) api.Whoami {
out := api.Whoami{User: s.userOut(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version}
if p.Method == "session" {
st := s.totpStatus(ctx, p.User)
out.TOTP = &st
out.TOTPSetupRequired = s.setupOnly(p)
}
if p.Token != nil { if p.Token != nil {
t := presentToken(p.Token) t := presentToken(p.Token)
out.Token = &t out.Token = &t
+7
View File
@@ -24,9 +24,15 @@ func (s *Server) routes() {
}) })
// Authentication // Authentication
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login)) m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
m.HandleFunc("POST "+p+"/auth/login/totp", s.public(s.loginTOTP))
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout)) m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami)) m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami))
m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword)) m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword))
m.HandleFunc("GET "+p+"/me/totp", s.authed(s.getOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/setup", s.authed(s.setupOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/enable", s.authed(s.enableOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/disable", s.authed(s.disableOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/recovery-codes", s.authed(s.newRecoveryCodes))
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens)) m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken)) m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken)) m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
@@ -40,6 +46,7 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser)) m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser))
m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens)) m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens))
m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken)) m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken))
m.HandleFunc("DELETE "+p+"/users/{id}/totp", s.authed(s.resetUserTOTP))
// Views // Views
m.HandleFunc("GET "+p+"/views", s.authed(s.listViews)) m.HandleFunc("GET "+p+"/views", s.authed(s.listViews))
m.HandleFunc("POST "+p+"/views", s.authed(s.createView)) m.HandleFunc("POST "+p+"/views", s.authed(s.createView))
+12
View File
@@ -47,6 +47,10 @@ type Config struct {
// reports on (backup.dir and backup.warn_after). // reports on (backup.dir and backup.warn_after).
BackupDir string BackupDir string
BackupWarnAfter time.Duration BackupWarnAfter time.Duration
// TOTPRequire is totp.require (none, admins or all) and TOTPIssuer the
// name authenticator apps show (h_totp.go).
TOTPRequire string
TOTPIssuer string
} }
// Server is the HTTP server. // Server is the HTTP server.
@@ -64,6 +68,8 @@ type Server struct {
oldSecretWarn throttle oldSecretWarn throttle
// expiryWarn limits warnings about expiring tokens to one a day each. // expiryWarn limits warnings about expiring tokens to one a day each.
expiryWarn throttle expiryWarn throttle
// pendingLogins are logins waiting for their TOTP code.
pendingLogins pendingLogins
// nsChecker, when set, replaces the default name server checker // nsChecker, when set, replaces the default name server checker
// (tests point it at local DNS servers). // (tests point it at local DNS servers).
nsChecker *nscheck.Checker nsChecker *nscheck.Checker
@@ -86,6 +92,12 @@ func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
if cfg.CookieSecure == "" { if cfg.CookieSecure == "" {
cfg.CookieSecure = "auto" cfg.CookieSecure = "auto"
} }
if cfg.TOTPRequire == "" {
cfg.TOTPRequire = "none"
}
if cfg.TOTPIssuer == "" {
cfg.TOTPIssuer = "Jarl"
}
if log == nil { if log == nil {
log = slog.Default() log = slog.Default()
} }
+273
View File
@@ -0,0 +1,273 @@
package server
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"testing"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/totp"
)
// browser is a web UI session: cookies plus the CSRF token.
type browser struct {
t *testing.T
e *apiEnv
c *http.Client
csrf string
}
func newBrowser(e *apiEnv) *browser {
jar, _ := cookiejar.New(nil)
return &browser{t: e.t, e: e, c: &http.Client{Jar: jar}}
}
// call sends a request; header is an optional X-Jarl-TOTP code.
func (b *browser) call(method, path string, body any, code string) (map[string]any, int) {
b.t.Helper()
var rd io.Reader
if body != nil {
j, _ := json.Marshal(body)
rd = bytes.NewReader(j)
}
r, _ := http.NewRequest(method, b.e.ts.URL+api.Prefix+path, rd)
r.Header.Set("Content-Type", "application/json")
if b.csrf != "" {
r.Header.Set(csrfHeader, b.csrf)
}
if code != "" {
r.Header.Set(TOTPHeader, code)
}
resp, err := b.c.Do(r)
if err != nil {
b.t.Fatal(err)
}
defer resp.Body.Close()
var m map[string]any
json.NewDecoder(resp.Body).Decode(&m)
if c, ok := m["csrf_token"].(string); ok && c != "" {
b.csrf = c
}
return m, resp.StatusCode
}
func (b *browser) must(method, path string, body any, code string, want int) map[string]any {
b.t.Helper()
m, status := b.call(method, path, body, code)
if status != want {
b.t.Fatalf("%s %s = %d, want %d: %v", method, path, status, want, m)
}
return m
}
// sessionHash is the stored hash of the browser's session cookie.
func (b *browser) sessionHash() string {
u, _ := url.Parse(b.e.ts.URL)
for _, c := range b.c.Jar.Cookies(u) {
if c.Name == sessionCookie {
return auth.HashSecret(c.Value)
}
}
b.t.Fatal("no session cookie")
return ""
}
// codeAt is secret's code for a step. Tests count steps from a base
// taken at their start, so crossing a step boundary mid-test changes
// nothing (the server accepts one step either side).
func codeAt(secret string, step int64) string {
c, _ := totp.Code(secret, step)
return c
}
func bodyErrCode(m map[string]any) string {
if e, ok := m["error"].(map[string]any); ok {
s, _ := e["code"].(string)
return s
}
return ""
}
func TestTOTPLogin(t *testing.T) {
base := totp.Step(time.Now())
e := newAPIEnv(t)
const pw = "correct horse battery"
b := newBrowser(e)
b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
if st := b.must("GET", "/me/totp", nil, "", 200); st["enabled"] != false {
t.Fatalf("status = %v", st)
}
// Setting up needs the password; the secret only counts once a code
// confirms it.
if m, status := b.call("POST", "/me/totp/setup", map[string]string{"password": "wrong"}, ""); status != 403 {
t.Errorf("setup with a wrong password = %d %v", status, m)
}
setup := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)
secret := setup["secret"].(string)
if setup["qr"] == "" || setup["account"] != "admin" {
t.Errorf("setup = %v", setup)
}
if m, status := b.call("POST", "/me/totp/enable", map[string]string{"code": "000000"}, ""); status != 400 || bodyErrCode(m) != "totp_invalid" {
t.Errorf("enable with a wrong code = %d %v", status, m)
}
// The codes of three steps are accepted (the clock may be off by one);
// each works once. Enable with the previous step's, log in with the
// current one's, and step up with the next one's.
en := b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base-1)}, "", 200)
recovery := en["recovery_codes"].([]any)
if len(recovery) != totp.RecoveryCodes {
t.Fatalf("recovery codes = %v", en)
}
// Turning it on kept this session and recorded the proved code: a
// token can be made right away.
b.must("POST", "/me/tokens", map[string]any{"name": "laptop"}, "", 201)
// The password alone is not a session any more.
b2 := newBrowser(e)
ch := b2.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
if ch["totp_required"] != true || ch["challenge"] == "" {
t.Fatalf("login = %v", ch)
}
if _, status := b2.call("GET", "/auth/whoami", nil, ""); status != 401 {
t.Errorf("whoami after the password only = %d", status)
}
// The code used to turn it on can't be used again.
if m, status := b2.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base-1)}, ""); status != 401 || bodyErrCode(m) != "totp_invalid" {
t.Errorf("reused code = %d %v", status, m)
}
who := b2.must("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base)}, "", 200)
if who["totp"].(map[string]any)["enabled"] != true {
t.Errorf("whoami = %v", who)
}
if _, status := b2.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base)}, ""); status != 401 {
t.Errorf("challenge used twice = %d", status)
}
// Step-up: once the proved code is older than the window, making a
// token needs a fresh one.
old := time.Now().Add(-stepUpWindow - time.Minute)
e.st.SetSessionTOTP(context.Background(), b2.sessionHash(), old)
if m, status := b2.call("POST", "/me/tokens", map[string]any{"name": "ci"}, ""); status != 403 || bodyErrCode(m) != "totp_step_up" {
t.Errorf("token without a recent code = %d %v", status, m)
}
if m, status := b2.call("POST", "/me/tokens", map[string]any{"name": "ci"}, "123456"); status != 403 || bodyErrCode(m) != "totp_invalid" {
t.Errorf("token with a wrong code = %d %v", status, m)
}
b2.must("POST", "/me/tokens", map[string]any{"name": "ci"}, codeAt(secret, base+1), 201)
// That proved a code again: the next action needs none.
b2.must("POST", "/me/tokens", map[string]any{"name": "ci2"}, "", 201)
// API tokens never need a code.
e.do("POST", "/me/tokens", e.token, map[string]any{"name": "script"}, 201)
// A recovery code logs in once.
rc := recovery[0].(string)
for i, want := range []int{200, 401} {
b3 := newBrowser(e)
ch := b3.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
if _, status := b3.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": " " + rc + " "}, ""); status != want {
t.Errorf("recovery code use %d = %d, want %d", i+1, status, want)
}
}
if st := b2.must("GET", "/me/totp", nil, "", 200); st["recovery_codes_left"] != float64(totp.RecoveryCodes-1) {
t.Errorf("recovery codes left = %v", st["recovery_codes_left"])
}
evs := e.list("/audit?action=user.totp", e.token)
if len(evs) == 0 {
t.Error("no user.totp audit events")
}
}
func TestTOTPRequiredAndReset(t *testing.T) {
base := totp.Step(time.Now())
e := newAPIEnvConfig(t, Config{Version: "test", TOTPRequire: "admins", TOTPIssuer: "Jarl test"})
const pw = "correct horse battery"
// An administrator without TOTP gets a session that can only set it up.
b := newBrowser(e)
who := b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
if who["totp_setup_required"] != true {
t.Fatalf("whoami = %v", who)
}
if m, status := b.call("GET", "/zones", nil, ""); status != 403 || bodyErrCode(m) != "totp_setup_required" {
t.Errorf("zones before setup = %d %v", status, m)
}
// Tokens are not affected.
e.do("GET", "/zones", e.token, nil, 200)
setup := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)
if setup["issuer"] != "Jarl test" {
t.Errorf("issuer = %v", setup["issuer"])
}
secret := setup["secret"].(string)
b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base)}, "", 200)
b.must("GET", "/zones", nil, "", 200)
// It can't be turned off while required.
if _, status := b.call("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base+1)}, ""); status != 409 {
t.Errorf("disable while required = %d", status)
}
// Users that aren't administrators may go without.
e.do("POST", "/users", e.token, map[string]any{"username": "bob", "password": "bob-password-123", "role": "readonly"}, 201)
bob := newBrowser(e)
if who := bob.must("POST", "/auth/login", map[string]string{"username": "bob", "password": "bob-password-123"}, "", 200); who["totp_setup_required"] == true {
t.Error("bob must set up TOTP")
}
bob.must("GET", "/zones", nil, "", 200)
// The dashboard: another administrator without two-factor login is
// listed, and the first "admin" account looks shared.
e.do("POST", "/users", e.token, map[string]any{"username": "carol", "password": "carol-password-123", "admin": true}, 201)
st := e.do("GET", "/status", e.token, nil, 200)["totp"].(map[string]any)
if missing := st["missing"].([]any); len(missing) != 1 || missing[0] != "carol" || st["shared_admin"] != "admin" || st["policy"] != "admins" {
t.Errorf("status totp = %v", st)
}
// An administrator resets someone's TOTP (with a token: no code).
users := e.list("/users", e.token)
var adminID string
for _, u := range users {
if u["username"] == "admin" {
adminID = itoa(int64(u["id"].(float64)))
if u["totp_required"] != true || u["totp_enabled_at"] == nil {
t.Errorf("admin = %v", u)
}
}
}
e.do("DELETE", "/users/"+adminID+"/totp", e.token, nil, 200)
if _, status := b.call("GET", "/zones", nil, ""); status != 401 {
t.Errorf("session after reset = %d", status)
}
e.do("DELETE", "/users/"+adminID+"/totp", e.token, nil, 409)
// Next login: password only, and setup again.
b = newBrowser(e)
if who := b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200); who["totp_setup_required"] != true {
t.Errorf("after reset = %v", who)
}
}
func TestTOTPDisable(t *testing.T) {
base := totp.Step(time.Now())
e := newAPIEnv(t)
const pw = "correct horse battery"
b := newBrowser(e)
b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
secret := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)["secret"].(string)
b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base)}, "", 200)
if _, status := b.call("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base)}, ""); status != 403 {
t.Errorf("disable with a used code = %d", status)
}
b.must("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base+1)}, "", 204)
// The password alone logs in again.
b2 := newBrowser(e)
if who := b2.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200); who["csrf_token"] == "" {
t.Errorf("login = %v", who)
}
// A token can't set up TOTP.
if _, status := e.req("POST", "/me/totp/setup", e.token, map[string]string{"password": pw}); status != 403 {
t.Errorf("setup with a token = %d", status)
}
}
+17
View File
@@ -238,6 +238,23 @@ CREATE TABLE deployed_files (
ALTER TABLE tokens ADD COLUMN rotated_at TEXT; ALTER TABLE tokens ADD COLUMN rotated_at TEXT;
ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT ''; ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT; ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
`,
// 3: two-factor login with TOTP. totp_pending holds a secret being set
// up until a code confirms it; totp_last_step is the newest time step
// used, so a code works only once. sessions.totp_at is when the
// session last proved a code (for actions that ask for a recent one).
`
ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN totp_pending TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN totp_enabled_at TEXT;
ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sessions ADD COLUMN totp_at TEXT;
CREATE TABLE recovery_codes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TEXT,
PRIMARY KEY (user_id, code_hash)
);
`, `,
} }
+126
View File
@@ -0,0 +1,126 @@
package store
import (
"context"
"database/sql"
"time"
)
// SetTOTPPending stores a secret being set up (or clears it with ""). It
// becomes the user's secret once a code confirms it (EnableTOTP).
func (s *Store) SetTOTPPending(ctx context.Context, userID int64, secret string) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET totp_pending = ? WHERE id = ?`, secret, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// EnableTOTP turns two-factor login on: the secret, the step of the code
// that confirmed it (so that code can't be used again), and the hashes of
// new recovery codes, replacing any old ones.
func (s *Store) EnableTOTP(ctx context.Context, userID int64, secret string, step int64, codeHashes []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := ts(time.Now())
res, err := tx.ExecContext(ctx, `UPDATE users SET totp_secret = ?, totp_pending = '', totp_enabled_at = ?, totp_last_step = ?, updated_at = ? WHERE id = ?`,
secret, now, step, now, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if err := replaceRecoveryCodes(ctx, tx, userID, codeHashes); err != nil {
return err
}
return tx.Commit()
}
// DisableTOTP turns two-factor login off and deletes the recovery codes.
func (s *Store) DisableTOTP(ctx context.Context, userID int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `UPDATE users SET totp_secret = '', totp_pending = '', totp_enabled_at = NULL, totp_last_step = 0, updated_at = ? WHERE id = ?`, ts(time.Now()), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, userID); err != nil {
return err
}
return tx.Commit()
}
// UseTOTPStep records that the code of step was used. It returns false if
// that step (or a later one) was used already: each code works once, even
// with two requests at the same moment.
func (s *Store) UseTOTPStep(ctx context.Context, userID, step int64) (bool, error) {
res, err := s.db.ExecContext(ctx, `UPDATE users SET totp_last_step = ? WHERE id = ? AND totp_last_step < ? AND totp_secret != ''`, step, userID, step)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// ReplaceRecoveryCodes gives a user new recovery codes (their hashes),
// making the old ones useless.
func (s *Store) ReplaceRecoveryCodes(ctx context.Context, userID int64, codeHashes []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err := replaceRecoveryCodes(ctx, tx, userID, codeHashes); err != nil {
return err
}
return tx.Commit()
}
func replaceRecoveryCodes(ctx context.Context, tx *sql.Tx, userID int64, hashes []string) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, userID); err != nil {
return err
}
for _, h := range hashes {
if _, err := tx.ExecContext(ctx, `INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)`, userID, h); err != nil {
return err
}
}
return nil
}
// UseRecoveryCode marks an unused recovery code (by its hash) used. It
// returns false if the user has no such unused code.
func (s *Store) UseRecoveryCode(ctx context.Context, userID int64, codeHash string) (bool, error) {
res, err := s.db.ExecContext(ctx, `UPDATE recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL`, ts(time.Now()), userID, codeHash)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// RecoveryCodesLeft counts a user's unused recovery codes.
func (s *Store) RecoveryCodesLeft(ctx context.Context, userID int64) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM recovery_codes WHERE user_id = ? AND used_at IS NULL`, userID).Scan(&n)
return n, err
}
// SetSessionTOTP records that a session proved a TOTP code at at.
func (s *Store) SetSessionTOTP(ctx context.Context, idHash string, at time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET totp_at = ? WHERE id_hash = ?`, ts(at), idHash)
return err
}
+10 -7
View File
@@ -18,14 +18,14 @@ func isUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
} }
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at` const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step`
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) { func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
u := &model.User{} u := &model.User{}
var grants, ca, ua string var grants, ca, ua string
var admin, disabled int var admin, disabled int
var ll sql.NullString var ll, te sql.NullString
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll); err != nil { if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep); err != nil {
return nil, err return nil, err
} }
u.Admin, u.Disabled = admin != 0, disabled != 0 u.Admin, u.Disabled = admin != 0, disabled != 0
@@ -37,6 +37,7 @@ func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
u.Grants = []model.Grant{} u.Grants = []model.Grant{}
} }
u.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll) u.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll)
u.TOTPEnabledAt = parseTSPtr(te)
return u, nil return u, nil
} }
@@ -263,8 +264,8 @@ func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
// CreateSession stores a session. // CreateSession stores a session.
func (s *Store) CreateSession(ctx context.Context, se *model.Session) error { func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent) VALUES (?,?,?,?,?,?,?,?)`, _, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent, totp_at) VALUES (?,?,?,?,?,?,?,?,?)`,
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent) se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent, tsPtr(se.TOTPAt))
return err return err
} }
@@ -272,8 +273,9 @@ func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, error) { func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, error) {
se := &model.Session{} se := &model.Session{}
var ca, ea, ls string var ca, ea, ls string
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent FROM sessions WHERE id_hash = ?`, idHash). var ta sql.NullString
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent) err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent, totp_at FROM sessions WHERE id_hash = ?`, idHash).
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent, &ta)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -281,6 +283,7 @@ func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session,
return nil, err return nil, err
} }
se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls) se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls)
se.TOTPAt = parseTSPtr(ta)
return se, nil return se, nil
} }
+193
View File
@@ -0,0 +1,193 @@
// Package totp implements time-based one-time passwords (RFC 6238) for
// two-factor login, with the settings every authenticator app supports:
// HMAC-SHA1, 6 digits, a 30-second step. It also makes the one-time
// recovery codes and the QR code that sets up an app.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"math/big"
"net/url"
"strings"
"time"
"rsc.io/qr"
)
const (
// Digits is the length of a code.
Digits = 6
// Period is the length of a time step.
Period = 30 * time.Second
// Skew is how many steps before and after the current one are
// accepted, for clocks that are a little off.
Skew = 1
// SecretBytes is the length of a secret (160 bits, as RFC 4226
// recommends).
SecretBytes = 20
// RecoveryCodes is how many recovery codes a user gets.
RecoveryCodes = 10
)
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
// NewSecret returns a new random secret, base32 encoded (the form
// authenticator apps take).
func NewSecret() string {
b := make([]byte, SecretBytes)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b32.EncodeToString(b)
}
func decode(secret string) ([]byte, error) {
s := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(secret), " ", ""))
k, err := b32.DecodeString(strings.TrimRight(s, "="))
if err != nil || len(k) == 0 {
return nil, errors.New("invalid TOTP secret")
}
return k, nil
}
// Step returns the time step t falls in.
func Step(t time.Time) int64 { return t.Unix() / int64(Period/time.Second) }
// Code returns the code for a step.
func Code(secret string, step int64) (string, error) {
k, err := decode(secret)
if err != nil {
return "", err
}
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], uint64(step))
m := hmac.New(sha1.New, k)
m.Write(msg[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0x0f
v := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
return fmt.Sprintf("%0*d", Digits, v%1_000_000), nil
}
// Normalize removes the spaces and dashes people type in codes.
func Normalize(code string) string {
return strings.Map(func(r rune) rune {
if r == ' ' || r == '-' || r == '\t' {
return -1
}
return r
}, strings.TrimSpace(code))
}
// IsCode reports whether s looks like a TOTP code (as opposed to a
// recovery code).
func IsCode(s string) bool {
s = Normalize(s)
if len(s) != Digits {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// Verify checks a code against the steps around now and returns the step
// it matched. Steps up to and including last (the newest step already
// used) are refused, so a code works only once.
func Verify(secret, code string, now time.Time, last int64) (int64, bool) {
code = Normalize(code)
if !IsCode(code) {
return 0, false
}
cur := Step(now)
for d := -Skew; d <= Skew; d++ {
step := cur + int64(d)
if step <= last {
continue
}
want, err := Code(secret, step)
if err != nil {
return 0, false
}
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return step, true
}
}
return 0, false
}
// URI is the otpauth:// URI that authenticator apps read from the QR code.
func URI(issuer, account, secret string) string {
label := url.PathEscape(issuer) + ":" + url.PathEscape(account)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", issuer)
q.Set("algorithm", "SHA1")
q.Set("digits", fmt.Sprint(Digits))
q.Set("period", fmt.Sprint(int(Period/time.Second)))
return "otpauth://totp/" + label + "?" + q.Encode()
}
// QRDataURI renders text as a QR code: an SVG image as a data: URI, ready
// for an <img> tag.
func QRDataURI(text string) (string, error) {
c, err := qr.Encode(text, qr.M)
if err != nil {
return "", err
}
const quiet = 4 // the white border scanners need, in modules
n := c.Size + 2*quiet
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, n, n)
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n)
for y := 0; y < c.Size; y++ {
for x := 0; x < c.Size; x++ {
if c.Black(x, y) {
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+quiet, y+quiet)
}
}
}
b.WriteString(`"/></svg>`)
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(b.String())), nil
}
// recoveryAlphabet has no 0/o, 1/l/i, so codes are easy to read and type.
const recoveryAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
// NewRecoveryCodes returns n random recovery codes like "k7dm2-xq9pw"
// (about 49 bits each).
func NewRecoveryCodes(n int) []string {
out := make([]string, n)
for i := range out {
var s strings.Builder
for j := range 10 {
if j == 5 {
s.WriteByte('-')
}
v, err := rand.Int(rand.Reader, big.NewInt(int64(len(recoveryAlphabet))))
if err != nil {
panic(err)
}
s.WriteByte(recoveryAlphabet[v.Int64()])
}
out[i] = s.String()
}
return out
}
// NormalizeRecovery puts a recovery code as typed into the form it is
// stored in (lower case, no spaces or dashes).
func NormalizeRecovery(code string) string {
return strings.ToLower(Normalize(code))
}
+82
View File
@@ -0,0 +1,82 @@
package totp
import (
"strings"
"testing"
"time"
)
// The SHA-1 test vectors of RFC 6238 appendix B, cut to 6 digits.
func TestCodeRFC6238(t *testing.T) {
secret := b32.EncodeToString([]byte("12345678901234567890"))
for _, c := range []struct {
unix int64
want string
}{
{59, "287082"},
{1111111109, "081804"},
{1111111111, "050471"},
{1234567890, "005924"},
{2000000000, "279037"},
{20000000000, "353130"},
} {
got, err := Code(secret, Step(time.Unix(c.unix, 0)))
if err != nil || got != c.want {
t.Errorf("T=%d: %q, %v; want %q", c.unix, got, err, c.want)
}
}
}
func TestVerify(t *testing.T) {
secret := NewSecret()
now := time.Unix(1_800_000_000, 0)
cur := Step(now)
code := func(step int64) string { c, _ := Code(secret, step); return c }
if step, ok := Verify(secret, code(cur), now, 0); !ok || step != cur {
t.Fatal("current code refused")
}
if _, ok := Verify(secret, code(cur - 1)[:3]+" "+code(cur - 1)[3:], now, 0); !ok {
t.Error("previous step (with a space) refused")
}
if _, ok := Verify(secret, code(cur+1), now, 0); !ok {
t.Error("next step refused")
}
if _, ok := Verify(secret, code(cur-2), now, 0); ok {
t.Error("code from a minute ago accepted")
}
// Replay: a step at or before the last used one is refused.
if _, ok := Verify(secret, code(cur), now, cur); ok {
t.Error("reused code accepted")
}
if _, ok := Verify(secret, code(cur+1), now, cur); !ok {
t.Error("newer code refused after a used one")
}
if _, ok := Verify(secret, "abcdef", now, 0); ok {
t.Error("letters accepted")
}
}
func TestURIAndQR(t *testing.T) {
u := URI("Jarl ns1", "adam", "JBSWY3DPEHPK3PXP")
if !strings.HasPrefix(u, "otpauth://totp/Jarl%20ns1:adam?") || !strings.Contains(u, "secret=JBSWY3DPEHPK3PXP") || !strings.Contains(u, "issuer=Jarl+ns1") {
t.Errorf("uri = %s", u)
}
img, err := QRDataURI(u)
if err != nil || !strings.HasPrefix(img, "data:image/svg+xml;base64,") {
t.Errorf("qr = %.60s, %v", img, err)
}
}
func TestRecoveryCodes(t *testing.T) {
codes := NewRecoveryCodes(RecoveryCodes)
seen := map[string]bool{}
for _, c := range codes {
if len(c) != 11 || c[5] != '-' || seen[c] || IsCode(c) {
t.Errorf("bad code %q", c)
}
seen[c] = true
}
if NormalizeRecovery(" K7DM2-XQ9PW ") != "k7dm2xq9pw" {
t.Error("normalize")
}
}
+21 -1
View File
@@ -23,8 +23,27 @@ export class APIError extends Error {
let onUnauthorized = () => {}; let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; } export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
// api calls the REST API. opts: {body, stage (bool), query}. // With two-factor login, some actions (API tokens, users) need a code
// from the last few minutes. The server then answers totp_step_up (or
// totp_invalid for a wrong code); askCode asks for one, and the request is
// sent again with it. It returns null when the user cancels.
let askCode = null;
export function setCodePrompt(fn) { askCode = fn; }
const stepUpCodes = new Set(["totp_step_up", "totp_invalid"]);
// api calls the REST API. opts: {body, stage (bool), query, code}.
export async function api(method, path, opts = {}) { export async function api(method, path, opts = {}) {
try {
return await call(method, path, opts);
} catch (e) {
if (!askCode || e.status !== 403 || !stepUpCodes.has(e.code) || path.startsWith("/me/totp/disable")) throw e;
const code = await askCode(e.code === "totp_invalid" ? e.message : "");
if (!code) throw e;
return api(method, path, { ...opts, code });
}
}
async function call(method, path, opts) {
const headers = { "Accept": "application/json" }; const headers = { "Accept": "application/json" };
let body; let body;
if (opts.body !== undefined) { if (opts.body !== undefined) {
@@ -40,6 +59,7 @@ export async function api(method, path, opts = {}) {
if (s) url += (url.includes("?") ? "&" : "?") + s; if (s) url += (url.includes("?") ? "&" : "?") + s;
} }
if (opts.stage) headers["X-Jarl-Changeset"] = String(opts.stage); if (opts.stage) headers["X-Jarl-Changeset"] = String(opts.stage);
if (opts.code) headers["X-Jarl-TOTP"] = opts.code;
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" }); const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
if (resp.status === 204) return null; if (resp.status === 204) return null;
const ct = resp.headers.get("Content-Type") || ""; const ct = resp.headers.get("Content-Type") || "";
+71 -12
View File
@@ -1,7 +1,8 @@
// Jarl web UI: router, layout and login. // Jarl web UI: router, layout and login.
import { h, mount, toast, errorBox, plural } from "./ui.js"; import { h, mount, toast, errorBox, plural } from "./ui.js";
import { wordmark, logo } from "./brand.js"; import { wordmark, logo } from "./brand.js";
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate } from "./api.js"; import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt } from "./api.js";
import * as totp from "./totp.js";
import * as dashboard from "./pages/dashboard.js"; import * as dashboard from "./pages/dashboard.js";
import * as zones from "./pages/zones.js"; import * as zones from "./pages/zones.js";
import * as zone from "./pages/zone.js"; import * as zone from "./pages/zone.js";
@@ -65,22 +66,81 @@ function renderLogin(message) {
e.preventDefault(); e.preventDefault();
mount(err); mount(err);
try { try {
const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } }); const r = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
state.me = me; if (r.totp_required) { renderCodeStep(r.challenge); return; }
state.csrf = me.csrf_token; await loggedIn(r);
await start();
} catch (ex) { } catch (ex) {
mount(err, errorBox(ex)); mount(err, errorBox(ex));
pass.select(); pass.select();
} }
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err); } }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
mount(app, h("div", { class: "login panel" }, mount(app, loginPanel(message, form));
user.focus();
}
function loginPanel(message, ...body) {
return h("div", { class: "login panel" },
logo(104, "login-logo"), logo(104, "login-logo"),
h("h1", null, wordmark()), h("h1", null, wordmark()),
h("div", { class: "tag" }, "Rule your DNS holds"), h("div", { class: "tag" }, "Rule your DNS holds"),
message ? h("div", { class: "msg warn" }, message) : null, message ? h("div", { class: "msg warn" }, message) : null,
form)); ...body);
user.focus(); }
// renderCodeStep is the second login step for users with two-factor login.
function renderCodeStep(challenge) {
const code = h("input", { name: "code", autocomplete: "one-time-code", inputmode: "numeric", placeholder: "6-digit code", required: true, class: "totp-code" });
const err = h("div");
let recovery = false;
const hint = h("div", { class: "small muted" });
const toggle = h("a", { href: "#", class: "small", onclick: (e) => {
e.preventDefault();
recovery = !recovery;
code.placeholder = recovery ? "Recovery code, e.g. k7dm2-xq9pw" : "6-digit code";
code.inputMode = recovery ? "text" : "numeric";
toggle.textContent = recovery ? "Use the authenticator app" : "Lost your phone? Use a recovery code";
code.value = "";
code.focus();
} }, "Lost your phone? Use a recovery code");
mount(hint, "Enter the code from your authenticator app.");
const form = h("form", { onsubmit: async (e) => {
e.preventDefault();
mount(err);
try {
const me = await api("POST", "/auth/login/totp", { body: { challenge, code: code.value } });
await loggedIn(me);
} catch (ex) {
if (ex.code === "login_expired") { renderLogin(ex.message); return; }
mount(err, errorBox(ex));
code.select();
}
} }, hint, code, h("button", { class: "primary", type: "submit" }, "Verify"), toggle, err);
mount(app, loginPanel(null, form));
code.focus();
}
async function loggedIn(me) {
state.me = me;
state.csrf = me.csrf_token || "";
if (me.totp_setup_required) renderSetupRequired();
else await start();
}
// renderSetupRequired: the server requires two-factor login for this
// user (totp.require), and it isn't set up yet. Nothing else works until
// it is.
function renderSetupRequired() {
const box = h("div");
mount(app, h("div", { class: "login panel wide" },
logo(64, "login-logo"),
h("h1", null, "Set up two-factor login"),
h("p", null, "This server requires two-factor login for your account. Set up an authenticator app to continue."),
box,
h("div", { class: "small", style: { marginTop: "12px" } }, h("a", { href: "#", onclick: (e) => { e.preventDefault(); logout(); } }, "Log out"))));
totp.setup(box, state.me, async () => {
const me = await get("/auth/whoami");
await loggedIn(me);
});
} }
function layout() { function layout() {
@@ -183,12 +243,11 @@ setUnauthorizedHandler(() => {
} }
}); });
setCodePrompt(totp.askCode);
(async function boot() { (async function boot() {
try { try {
const me = await get("/auth/whoami"); await loggedIn(await get("/auth/whoami"));
state.me = me;
state.csrf = me.csrf_token || "";
await start();
} catch (e) { } catch (e) {
if (e.status === 401) renderLogin(); if (e.status === 401) renderLogin();
else mount(app, h("div", { class: "login panel" }, h("h1", null, "Jarl"), errorBox(e))); else mount(app, h("div", { class: "login panel" }, h("h1", null, "Jarl"), errorBox(e)));
+64 -1
View File
@@ -1,5 +1,6 @@
import { h, mount, field, input, toast, errorBox } from "../ui.js"; import { h, mount, field, input, toast, errorBox, time, modal, badge } from "../ui.js";
import { api, get } from "../api.js"; import { api, get } from "../api.js";
import * as totp from "../totp.js";
import { tokenTable, newTokenForm, grantHelp } from "./users.js"; import { tokenTable, newTokenForm, grantHelp } from "./users.js";
export async function render(el) { export async function render(el) {
@@ -18,6 +19,7 @@ export async function render(el) {
h("h1", null, "Account: " + me.user.username), h("h1", null, "Account: " + me.user.username),
h("div", { class: "panel" }, h("h2", null, "Permissions"), h("div", { class: "panel" }, h("h2", null, "Permissions"),
h("div", { class: "grants" }, (me.permissions || []).map((p) => h("div", null, p)))), h("div", { class: "grants" }, (me.permissions || []).map((p) => h("div", null, p)))),
me.method === "session" ? twoFactorPanel(me) : null,
h("div", { class: "panel" }, h("h2", null, "Change password"), h("div", { class: "panel" }, h("h2", null, "Change password"),
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw1, "At least 8 characters."), field("Repeat", pw2)), h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw1, "At least 8 characters."), field("Repeat", pw2)),
err, err,
@@ -36,3 +38,64 @@ export async function render(el) {
grantHelp(grants)); grantHelp(grants));
await drawTokens(); await drawTokens();
} }
// twoFactorPanel shows and changes the user's two-factor login.
function twoFactorPanel(me) {
const el = h("div", { class: "panel" });
const draw = async () => {
const st = await get("/me/totp");
const body = h("div");
const redraw = () => draw();
if (!st.enabled) {
mount(el, h("h2", null, "Two-factor login"),
h("p", null, "Log in with your password and a code from an authenticator app on your phone, so a stolen password alone isn't enough.",
st.required ? h("strong", null, " This server requires it for your account.") : null),
body);
totp.setup(body, me, redraw);
return;
}
const actions = h("div", { class: "actions" });
mount(el, h("h2", null, "Two-factor login"),
h("p", null, badge("on", "ok"), " since ", time(st.enabled_at), ". ",
st.recovery_codes_left + " of 10 recovery codes left.",
st.required ? h("span", { class: "muted" }, " Required on this server.") : null),
st.recovery_codes_left <= 3 ? h("div", { class: "msg warn" }, "Only " + st.recovery_codes_left + " recovery codes are left: make new ones.") : null,
body, actions);
mount(actions,
h("button", { onclick: () => {
mount(actions);
totp.setup(body, me, redraw, { again: true, cancel: redraw });
} }, "Move to a new phone"),
h("button", { onclick: async () => {
try {
const r = await api("POST", "/me/totp/recovery-codes");
mount(actions);
mount(body, totp.recoveryBox(r.recovery_codes, me.user.username),
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: redraw }, "I have saved the codes")));
} catch (e) { toast(e.message, "err", 8000); }
} }, "New recovery codes"),
st.required ? null : h("button", { class: "danger", onclick: () => disable(me, redraw) }, "Turn off"));
};
draw();
return el;
}
async function disable(me, done) {
const pw = input({ type: "password", autocomplete: "current-password" });
const code = input({ autocomplete: "one-time-code", placeholder: "123456 or a recovery code" });
const err = h("div");
const ok = await modal("Turn off two-factor login", h("div", null,
h("p", null, "You will log in with your password alone."),
h("div", { class: "form-grid" },
me.user.has_password ? field("Password", pw) : null,
field("Code", code, "From your authenticator app, or a recovery code.")), err), [
{ label: "Cancel", value: null },
{ label: "Turn off", kind: "danger", onclick: async () => {
try {
await api("POST", "/me/totp/disable", { body: { password: pw.value, code: code.value } });
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
]);
if (ok) { toast("Two-factor login is off"); done(); }
}
@@ -40,6 +40,17 @@ export async function render(el) {
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error, h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server."))); h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
} }
const tf = st.totp;
if (tf && tf.missing && tf.missing.length) {
parts.push(h("div", { class: "msg warn" },
h("strong", null, "Two-factor login is required (" + tf.policy + ") but not set up by: "), tf.missing.join(", "), ". ",
"They can only set it up when they next log in. ", h("a", { href: "#/users" }, "Users")));
}
if (tf && tf.shared_admin) {
parts.push(h("div", { class: "msg info" },
h("strong", null, "The first account, " + tf.shared_admin + ", is still active "), "next to personal administrator accounts. If it is shared, disable it under ",
h("a", { href: "#/users" }, "Users"), ": everyone then logs in as themselves, with their own two-factor login, and the audit log shows who did what. Root on the server can always get back in with 'jarld user passwd'."));
}
const exp = st.expiring_tokens || []; const exp = st.expiring_tokens || [];
if (exp.length) { if (exp.length) {
const now = new Date(); const now = new Date();
+11
View File
@@ -16,11 +16,13 @@ export async function render(el) {
h("div", { class: "panel" }, table([ h("div", { class: "panel" }, table([
{ label: "User", render: (u) => h("strong", null, u.username) }, { label: "User", render: (u) => h("strong", null, u.username) },
{ label: "Role", render: (u) => [u.admin ? badge("admin", "accent") : null, u.disabled ? badge("disabled", "err") : null, !u.has_password ? badge("tokens only") : null] }, { label: "Role", render: (u) => [u.admin ? badge("admin", "accent") : null, u.disabled ? badge("disabled", "err") : null, !u.has_password ? badge("tokens only") : null] },
{ label: "2FA", render: (u) => u.totp_enabled_at ? badge("on", "ok") : u.totp_required ? badge("not set up", "warn") : !u.has_password ? h("span", { class: "muted" }, "–") : h("span", { class: "muted" }, "off") },
{ label: "Permissions", render: (u) => u.admin ? h("span", { class: "muted" }, "everything") : h("div", { class: "grants" }, (u.grant_strings || []).map((g) => h("div", null, g))), tdClass: "small" }, { label: "Permissions", render: (u) => u.admin ? h("span", { class: "muted" }, "everything") : h("div", { class: "grants" }, (u.grant_strings || []).map((g) => h("div", null, g))), tdClass: "small" },
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : h("span", { class: "muted" }, "never") }, { label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : h("span", { class: "muted" }, "never") },
{ label: "", render: (u) => h("span", null, { label: "", render: (u) => h("span", null,
h("button", { class: "small", onclick: () => tokensDialog(u, grants) }, "Tokens"), " ", h("button", { class: "small", onclick: () => tokensDialog(u, grants) }, "Tokens"), " ",
h("button", { class: "small", onclick: () => userDialog(u, grants) }, "Edit"), " ", h("button", { class: "small", onclick: () => userDialog(u, grants) }, "Edit"), " ",
u.totp_enabled_at ? [h("button", { class: "small", title: "Turn off their two-factor login (a lost phone without recovery codes)", onclick: () => resetTOTP(u) }, "Reset 2FA"), " "] : null,
h("button", { class: "small danger", disabled: u.id === state.me.user.id, onclick: () => del(u) }, "Delete")), tdClass: "actions-cell" }, h("button", { class: "small danger", disabled: u.id === state.me.user.id, onclick: () => del(u) }, "Delete")), tdClass: "actions-cell" },
], users)), ], users)),
grantHelp(grants)); grantHelp(grants));
@@ -78,6 +80,15 @@ async function userDialog(u, grants) {
], { wide: true, sticky: true }); ], { wide: true, sticky: true });
} }
async function resetTOTP(u) {
if (!(await confirmDialog("Reset two-factor login", "Turn off two-factor login for " + u.username + "? Their recovery codes stop working and they are logged out. They log in with the password next time" + (u.totp_required ? " and must set it up again." : ", and can set it up again under Account."), "Reset", "danger"))) return;
try {
await api("DELETE", "/users/" + u.id + "/totp");
toast("Two-factor login of " + u.username + " is off");
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
async function del(u) { async function del(u) {
if (!(await confirmDialog("Delete user", "Delete " + u.username + " and all their API tokens?", "Delete", "danger"))) return; if (!(await confirmDialog("Delete user", "Delete " + u.username + " and all their API tokens?", "Delete", "danger"))) return;
try { try {
+8
View File
@@ -242,6 +242,14 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.kv dd { margin: 0; } .kv dd { margin: 0; }
.grants { font-family: var(--mono); font-size: 12px; } .grants { font-family: var(--mono); font-size: 12px; }
.copy-box { display: flex; gap: 6px; align-items: center; } .copy-box { display: flex; gap: 6px; align-items: center; }
.login.wide { max-width: 520px; }
.totp-steps { padding-left: 20px; }
.totp-steps li { margin-bottom: 12px; }
.totp-qr { margin: 10px 0; }
.totp-qr img { display: block; width: 200px; height: 200px; background: #fff; border-radius: var(--radius); }
.totp-confirm { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
input.totp-code { font-family: var(--mono); font-size: 18px; letter-spacing: 0.15em; max-width: 14em; }
.recovery-codes { font-family: var(--mono); font-size: 15px; columns: 2; max-width: 360px; }
.copy-box input { flex: 1; font-family: var(--mono); } .copy-box input { flex: 1; font-family: var(--mono); }
.sortable-row { display: flex; align-items: center; gap: 8px; } .sortable-row { display: flex; align-items: center; gap: 8px; }
@media (max-width: 700px) { @media (max-width: 700px) {
+91
View File
@@ -0,0 +1,91 @@
// Two-factor login (TOTP): the code prompt, setting up an authenticator
// app, and recovery codes.
import { h, mount, modal, input, field, toast, errorBox, copyBox } from "./ui.js";
import { api } from "./api.js";
function codeInput(attrs = {}) {
return input({ inputmode: "numeric", autocomplete: "one-time-code", maxlength: 6, pattern: "[0-9]*", placeholder: "123456", class: "totp-code", ...attrs });
}
// askCode asks for a current code (actions that need a recent one).
// It returns the code, or null when canceled.
export async function askCode(message) {
const code = codeInput();
const r = await modal("Confirm with your authenticator", h("div", null,
message ? h("div", { class: "msg err" }, message) : null,
h("p", null, "This action needs a current code from your authenticator app."),
code), [
{ label: "Cancel", value: null },
{ label: "Confirm", kind: "primary", onclick: () => code.value.trim().length > 0 },
]);
return r === "Confirm" ? code.value.trim() : null;
}
// recoveryBox shows new recovery codes, to copy or download.
export function recoveryBox(codes, username) {
const text = "Jarl recovery codes for " + username + " (each works once)\n\n" + codes.join("\n") + "\n";
const download = () => {
const a = h("a", { href: URL.createObjectURL(new Blob([text], { type: "text/plain" })), download: "jarl-recovery-codes-" + username + ".txt" });
document.body.append(a);
a.click();
a.remove();
};
return h("div", null,
h("div", { class: "msg warn" }, "Save these recovery codes somewhere safe (a password manager, or printed). Each logs you in once if you lose your phone. They are shown only now."),
h("pre", { class: "recovery-codes" }, codes.join("\n")),
h("div", { class: "actions" },
h("button", { type: "button", onclick: async () => {
try { await navigator.clipboard.writeText(text); toast("Copied"); } catch { toast("Copy failed; select the codes instead", "err"); }
} }, "Copy"),
h("button", { type: "button", onclick: download }, "Download")));
}
// setup walks through setting up an authenticator app in el: password,
// QR code, confirming code, recovery codes. onDone runs when finished.
export function setup(el, me, onDone, opts = {}) {
const hasPassword = me.user.has_password !== false;
const pw = input({ type: "password", autocomplete: "current-password" });
const err = h("div");
const start = async () => {
mount(err);
let s;
try {
s = await api("POST", "/me/totp/setup", { body: { password: pw.value } });
} catch (e) { mount(err, errorBox(e)); return; }
showQR(s);
};
const showQR = (s) => {
const code = codeInput();
const err2 = h("div");
const confirm = async () => {
mount(err2);
try {
const r = await api("POST", "/me/totp/enable", { body: { code: code.value } });
mount(el,
h("div", { class: "msg ok" }, "Two-factor login is on. From now on you log in with your password and a code from the app."),
recoveryBox(r.recovery_codes, me.user.username),
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: onDone }, "I have saved the codes")));
} catch (e) { mount(err2, errorBox(e)); code.select(); }
};
code.addEventListener("keydown", (e) => { if (e.key === "Enter") confirm(); });
mount(el,
h("ol", { class: "totp-steps" },
h("li", null, "Scan this QR code with an authenticator app (Aegis, Google Authenticator, 1Password, Bitwarden, …).",
h("div", { class: "totp-qr" }, h("img", { src: s.qr, alt: "QR code for " + s.issuer + ": " + s.account, width: 200, height: 200 })),
h("details", null, h("summary", { class: "small" }, "Can't scan? Enter the key by hand"),
h("div", { class: "small muted" }, "Account: " + s.issuer + ": " + s.account + " · time-based, 6 digits, 30 seconds"),
copyBox(s.secret))),
h("li", null, "Enter the 6-digit code the app shows:", h("div", { class: "totp-confirm" }, code,
h("button", { class: "primary", onclick: confirm }, "Turn on")))),
err2);
code.focus();
};
pw.addEventListener("keydown", (e) => { if (e.key === "Enter") start(); });
mount(el,
hasPassword ? h("div", { class: "form-grid" }, field("Your password", pw, "To make sure it's you.")) : null,
err,
h("div", { class: "actions", style: { marginTop: "8px" } },
opts.cancel ? h("button", { onclick: opts.cancel }, "Cancel") : null,
h("button", { class: "primary", onclick: start }, opts.again ? "Set up a new app" : "Set up")));
if (hasPassword) pw.focus();
}