From 8f9cdd0a17d8e3ece3ded53eb4ff68b774735b0b Mon Sep 17 00:00:00 2001 From: abrown38 Date: Fri, 9 Oct 2026 14:12:24 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 26 + README.md | 8 +- cmd/jarl/admin_cmds.go | 73 ++- cmd/jarld/main.go | 42 +- docs/api.md | 30 +- docs/cli.md | 9 +- docs/configuration.md | 2 + docs/install.md | 8 +- docs/operations.md | 1 + docs/permissions.md | 91 +++- go.mod | 1 + go.sum | 2 + internal/api/types.go | 79 +++ internal/config/config.go | 41 ++ internal/model/changeset.go | 12 + internal/server/authn.go | 12 +- internal/server/h_auth.go | 27 +- internal/server/h_changes.go | 1 + internal/server/h_totp.go | 507 ++++++++++++++++++ internal/server/present.go | 18 +- internal/server/routes.go | 7 + internal/server/server.go | 12 + internal/server/totp_test.go | 273 ++++++++++ internal/store/store.go | 17 + internal/store/totp.go | 126 +++++ internal/store/users.go | 17 +- internal/totp/totp.go | 193 +++++++ internal/totp/totp_test.go | 82 +++ internal/web/static/assets/api.js | 22 +- internal/web/static/assets/app.js | 83 ++- internal/web/static/assets/pages/account.js | 65 ++- internal/web/static/assets/pages/dashboard.js | 11 + internal/web/static/assets/pages/users.js | 11 + internal/web/static/assets/style.css | 8 + internal/web/static/assets/totp.js | 91 ++++ 35 files changed, 1965 insertions(+), 43 deletions(-) create mode 100644 internal/server/h_totp.go create mode 100644 internal/server/totp_test.go create mode 100644 internal/store/totp.go create mode 100644 internal/totp/totp.go create mode 100644 internal/totp/totp_test.go create mode 100644 internal/web/static/assets/totp.js diff --git a/CHANGELOG.md b/CHANGELOG.md index dd760af..0ddb9a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ All notable changes to Jarl are documented here. The format follows ### 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 `). +- 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. 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 @@ -17,6 +40,9 @@ All notable changes to Jarl are documented here. The format follows ### 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; }` uses dynamic updates (validation already allowed it). - docs/cli.md gives the CLI configuration file's location on macOS diff --git a/README.md b/README.md index e7081fe..f18b066 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A) using it). Passwords use argon2id, sessions have CSRF protection, logins are rate 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 outcome. * **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/cli.md](docs/cli.md) | The `jarl` command line client | | [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/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 | @@ -133,7 +137,7 @@ these pages: * **Changes**: pending edits with the diff, named-checkconf output and problems, applied as one changeset. History lists applied changesets with 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 inline (green = new, yellow = changed, struck out = deleted) until you diff --git a/cmd/jarl/admin_cmds.go b/cmd/jarl/admin_cmds.go index 37fb897..677a870 100644 --- a/cmd/jarl/admin_cmds.go +++ b/cmd/jarl/admin_cmds.go @@ -200,6 +200,14 @@ func statusCmd() *cobra.Command { 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)) } + 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 { w.(interface{ Flush() error }).Flush() fmt.Println("Problems:") @@ -333,6 +341,19 @@ func userID(c *client.Client, name string) (int64, error) { 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) { fmt.Fprintf(w, "User:\t%s (id %d)\n", u.Username, u.ID) role := "user" @@ -346,6 +367,7 @@ func printUser(w io.Writer, u api.User) { role += ", no password (tokens only)" } fmt.Fprintf(w, "Role:\t%s\n", role) + fmt.Fprintf(w, "2FA:\t%s\n", twoFA(u)) for i, g := range u.GrantStrings { label := "" if i == 0 { @@ -384,9 +406,18 @@ func userCmd() *cobra.Command { return err } 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 { - 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") - 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 } diff --git a/cmd/jarld/main.go b/cmd/jarld/main.go index dbba407..d1f91f4 100644 --- a/cmd/jarld/main.go +++ b/cmd/jarld/main.go @@ -297,6 +297,7 @@ func serveCmd() *cobra.Command { SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure, Version: version.Version, Stats: collector, Socket: a.cfg.Socket, 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{ Enabled: a.cfg.ACME.HTTPReq.Enabled, AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS, @@ -464,7 +465,7 @@ Jarl starts empty. Either import your existing BIND configuration with 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(&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.)") @@ -610,6 +611,9 @@ func userCmd() *cobra.Command { if !u.HasPassword { flags = append(flags, "no password") } + if u.HasTOTP() { + flags = append(flags, "2fa") + } var gs []string for _, g := range u.Grants { gs = append(gs, auth.FormatGrant(g)) @@ -619,6 +623,40 @@ func userCmd() *cobra.Command { 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 } diff --git a/docs/api.md b/docs/api.md index 3903343..ad92bdc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -26,7 +26,27 @@ change something (anything but GET/HEAD) must send it as `X-CSRF-Token`. `POST /auth/logout` ends the session. `GET /auth/whoami` returns the user, the token (if any), the effective -permissions and whether the caller is an administrator. +permissions and whether the caller is an administrator. For sessions it +also has `totp` (the user's two-factor login: `enabled`, `required`, +`recovery_codes_left`) and `totp_setup_required`. + +**Two-factor login** ([permissions.md](permissions.md#two-factor-login)): +for a user with it, `POST /auth/login` answers a correct password with +`{"totp_required": true, "challenge": "..."}` and no cookie. Send +`POST /auth/login/totp` with `{"challenge": "...", "code": "123456"}` (or +a recovery code) within 5 minutes to get the session. Wrong codes answer +401 `totp_invalid`; after 5, or 5 minutes, `login_expired` (start again). + +Creating or rotating tokens, changing users and resetting someone's +two-factor login need, in a session of a user with two-factor login, a +code proved in the last 5 minutes. Otherwise they answer 403 +`totp_step_up`: send the request again with the current code in +`X-Jarl-TOTP: 123456` (a wrong one gives 403 `totp_invalid`). Tokens and +the local socket never need one. + +When `totp.require` applies to a user who hasn't set it up, a session can +only call `/auth/whoami`, `/auth/logout`, `/me/password` and `/me/totp...`; +everything else answers 403 `totp_setup_required`. **The local socket**: on jarld's unix socket (`/run/jarl/jarl.sock`), requests from root or jarld's own user need no `Authorization` header and @@ -354,13 +374,19 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`. |--------|------|------| | GET | `/users` | admin | | POST | `/users` | `{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""}` (`password: ""` = service account) | -| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled` | +| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` | | GET / POST | `/users/{id}/tokens` | admin (or yourself) | | GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once | | GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens | | DELETE | `/tokens/{id}` | revoke | | POST | `/tokens/{id}/rotate` | New secret, same token. Optional body `{"grace": "1h", "expires_in": "90d", "expires_at": "...", "no_expiry": false}`. The response's `token` field is the new secret, shown once; `previous_secret_valid_until` says when the old one stops. A restricted token may rotate itself and tokens no wider than itself | | POST | `/me/password` | `{"current_password": "...", "password": "..."}` (sessions only) | +| GET | `/me/totp` | Your two-factor login: `enabled`, `enabled_at`, `required`, `recovery_codes_left`, `policy` | +| POST | `/me/totp/setup` | `{"password": "..."}` (sessions only). A new secret: `secret`, `uri` (`otpauth://`), `qr` (an SVG `data:` URI), `issuer`, `account`. Not active until confirmed. With two-factor login already on (a new phone), it needs a recent code | +| POST | `/me/totp/enable` | `{"code": "123456"}`: confirms the secret from setup. Returns `{"recovery_codes": [...]}`, shown once, and logs out your other sessions | +| POST | `/me/totp/disable` | `{"password": "...", "code": "..."}` (a code or a recovery code). Refused (409) while `totp.require` applies to you | +| POST | `/me/totp/recovery-codes` | New recovery codes, replacing the old ones (needs a recent code) | +| DELETE | `/users/{id}/totp` | admin: turn off someone's two-factor login, delete their recovery codes and end their sessions | User and token changes take effect immediately. They are not changesets, because they don't touch named, but they are audited. diff --git a/docs/cli.md b/docs/cli.md index cf06023..52fb92a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 update alice --admin jarl user update alice --password # set a new password -jarl user update bob --disabled # logs bob out, blocks his tokens +jarl user update bob --disabled # logs bob out, blocks bob's tokens +jarl user update admin --rename adam # rename (e.g. the first account, to your own name) +jarl user totp reset alice # turn off alice's two-factor login (lost phone) jarl user delete bob -y jarl token list [--user alice | --all] [--revoked] @@ -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 revoke 7 +# 'jarl user list' and 'jarl user show' include two-factor login (2FA: on, +# off, or MISSING when totp.require applies and it isn't set up). Users set +# it up themselves in the web UI (Account); the CLI's tokens never need a +# code (permissions.md#two-factor-login). + jarl audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100] jarl search 192.168.1.20 jarl status # named, adoption, drift, open changesets, backups, expiring tokens, problems diff --git a/docs/configuration.md b/docs/configuration.md index 4aef99a..dbe8d2c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,6 +25,8 @@ The CLI has its own small configuration file, described in [cli.md](cli.md). | `session.lifetime` | `12h` | A web session ends this long after login... | | `session.idle_timeout` | `2h` | ...or after this long without requests. | | `session.cookie_secure` | `auto` | `auto` sets the cookie's Secure flag when the request came over HTTPS (directly or via a trusted proxy); `always` / `never` force it. | +| `totp.require` | `none` | Who must use [two-factor login](permissions.md#two-factor-login): `none` (each user decides), `admins`, or `all` (every user with a password). Someone who must and hasn't set it up can only set it up after logging in. API tokens and the local socket are not affected. | +| `totp.issuer` | `""` | The name authenticator apps show for the account. Empty means `Jarl `, 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`). diff --git a/docs/install.md b/docs/install.md index 0a2239b..67a3d24 100644 --- a/docs/install.md +++ b/docs/install.md @@ -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 ```sh -sudo -u jarl jarld init --token \ +sudo -u jarl jarld init --token -u adam \ --nameservers ns1.adbraun.lol. --contact hostmaster@adbraun.lol ``` -It asks for the administrator's password (user `admin`, change it with -`-u NAME`). `--token` also prints an API token for the CLI, shown only +It asks for the administrator's password. Name the account after yourself +(`-u`, default `admin`): each person should have their own account, with +their own [two-factor login](permissions.md#two-factor-login), which you +can turn on under Account once you have logged in. `--token` also prints an API token for the CLI, shown only once. `--nameservers` and `--contact` are the defaults for zones you create later, and you can change them in the UI or with `jarl settings set`. diff --git a/docs/operations.md b/docs/operations.md index 4ff0785..25849a9 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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` | | Locked out by the login rate limit | wait 15 minutes, or `systemctl restart jarl` (the limiter is in memory) | | Lost every admin account | `sudo -u jarl jarld user add --admin rescue` | +| Lost the phone with the authenticator app, and the recovery codes | another administrator: Users → Reset 2FA, or `jarl user totp reset NAME`; on the server: `sudo -u jarl jarld user totp reset NAME` | | Need a token without the web UI | `sudo jarl token create cli --user admin`, or `sudo -u jarl jarld user token admin cli` | ## A secondary server diff --git a/docs/permissions.md b/docs/permissions.md index e7a6704..86b1f17 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -4,7 +4,7 @@ | Who | How | Notes | |-----|-----|-------| -| People in the web UI | Username + password → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. | +| People in the web UI | Username + password (+ a code from an authenticator app, with [two-factor login](#two-factor-login)) → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. | | CLI, scripts, certbot | API token: `Authorization: Bearer jarl_...` | Shown once at creation, stored hashed. Optional expiry. Revocable. Last use time and IP are recorded. | | lego / Traefik (`httpreq`) | HTTP Basic: the token's user and the API token | Only on `/api/v1/acme/httpreq/...`, only when `acme.httpreq.enabled` is set, and by default only over HTTPS ([acme.md](acme.md)). The token's grants apply as usual. | | Root on the server, with the CLI | jarld's local socket (`/run/jarl/jarl.sock`): no token | `sudo jarl ...` with no server configured. The kernel tells jarld which system user is connecting; root and the `jarl` user are administrators, anyone else must send a token. Audited as the system user (e.g. `root`) via `socket`. See [The local socket](#the-local-socket). | @@ -24,6 +24,94 @@ A user **without a password** (`--no-password`) is a service account. It can't log in to the web UI and is used only through its tokens, which suits certbot, CI jobs and backups. +## Two-factor login + +Users can protect their web login with a second factor: a 6-digit code +from an authenticator app on their phone (TOTP: Aegis, Google +Authenticator, 1Password, Bitwarden and the like). A stolen password alone +then gets nobody in. + +**Setting it up** (Account → Two-factor login): enter your password, scan +the QR code (or type the key shown under it), and enter the code the app +shows. Only then is it on, so a bad scan can't lock you out. Jarl then +shows **10 recovery codes**, once: save them in a password manager or print +them. Each logs you in once instead of a code, for when the phone is lost. +Turning it on logs out your other sessions. + +**Logging in**: username and password first; the second screen asks for +the code (or, under "Lost your phone?", a recovery code). The password +step alone gives no session: it is good for 5 minutes and 5 tries. Codes +from one 30-second step either side of now are accepted, for clocks that +are a little off, and each code works only once. Wrong codes count +against the same rate limit as wrong passwords. + +**Actions that ask for a code again.** Some things would let a stolen +session or password outlive the second factor, so with two-factor login +they need a code from the last 5 minutes (logging in counts): creating +or rotating API tokens, creating, changing or deleting users, and +resetting someone's two-factor login. The web UI asks for a code when +needed; API clients send it in the `X-Jarl-TOTP` header +([api.md](api.md#authentication)). + +**What it doesn't cover**: API tokens and the local socket. A token is +already its own long random secret, and scripts can't answer a code +prompt, which is why making one asks for a code. Root on the server can +read the database anyway, so a code would add nothing there. + +**Making it compulsory**: `totp.require` in jarl.yaml +([configuration.md](configuration.md#web-ui-and-api)): + +| `totp.require` | Who must use it | +|--|--| +| `none` (default) | nobody; each user decides | +| `admins` | administrators | +| `all` | every user with a password (service accounts can't log in anyway) | + +A user who must use it and hasn't set it up yet can log in with the +password, but then only sees "Set up two-factor login" until they do. They +can't turn it off while it is required (moving to a new phone is "Move to +a new phone" under Account). The setting lives in jarl.yaml rather than in +the web UI on purpose: someone who steals an administrator's login can't +switch it off. The dashboard and `jarl status` list users who must use it +but haven't set it up. + +**Lost phone**: + +* With a recovery code: log in with it, then Account → Move to a new + phone. New recovery codes replace the old ones (Account → New recovery + codes; Jarl warns when 3 or fewer are left). +* Without: an administrator resets it (Users → Reset 2FA, or `jarl user + totp reset NAME`). That turns it off, deletes the recovery codes and + logs the user out; they log in with the password and set it up again. +* No administrator can log in: on the server, `sudo -u jarl jarld user + totp reset NAME` (works with jarld stopped too). + +Every setup, change, failed code, recovery code use and reset is in the +audit log (`user.totp_*`, `auth.totp_failed`). + +The secret is stored in the database, like TSIG secrets (it can't be +hashed: the server needs it to work out the codes), so it is as safe as +the database file and its backups (mode 0600, root and `jarl` only). +Recovery codes are stored hashed. + +### One account per person + +`jarld init` creates the first administrator, `admin` unless you name it +(`-u NAME`). If several people use that one account, nobody can tell from +the audit log who did what, and it can't have two-factor login per person. +So: + +* **Just you**: rename it to your name (`jarl user update admin --rename + adam`, or Users → Edit) and turn on two-factor login. +* **Several people**: give each their own administrator account with their + own two-factor login, then disable `admin` (`jarl user update admin + --disabled`). The dashboard and `jarl status` point this out while an + enabled `admin` exists next to other administrators. + +Nothing is lost by disabling it: root on the server always gets back in +([Recovering access](#recovering-access)), and Jarl refuses to disable, +demote or delete the last enabled administrator. + ## Grants A user's permissions are a list of grants. Administrators (`admin: true`) @@ -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 add --admin rescue # a new administrator sudo -u jarl jarld user token admin cli # a new API token for a user +sudo -u jarl jarld user totp reset admin # turn off two-factor login (a lost phone) ``` diff --git a/go.mod b/go.mod index 3cd9b81..9234fa7 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( golang.org/x/net v0.58.0 golang.org/x/term v0.46.0 modernc.org/sqlite v1.60.1 + rsc.io/qr v0.2.0 ) require ( diff --git a/go.sum b/go.sum index 54b87fb..b60fb70 100644 --- a/go.sum +++ b/go.sum @@ -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/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 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= diff --git a/internal/api/types.go b/internal/api/types.go index 08806d0..f3866f2 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -68,6 +68,9 @@ type Option struct { type User struct { model.User 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. @@ -86,6 +89,67 @@ type Whoami struct { Permissions []string `json:"permissions"` Admin bool `json:"admin"` 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. @@ -158,6 +222,21 @@ type StatusResponse struct { ExpiringTokens []Token `json:"expiring_tokens"` // Backup is the state of database backups (administrators only). 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 diff --git a/internal/config/config.go b/internal/config/config.go index 573df82..924d44b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type Config struct { StagingDir string `yaml:"staging_dir"` Log Log `yaml:"log"` Session Session `yaml:"session"` + TOTP TOTP `yaml:"totp"` BIND BIND `yaml:"bind"` Audit Audit `yaml:"audit"` Backup Backup `yaml:"backup"` @@ -93,6 +94,28 @@ type Session struct { } // 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 "). + Issuer string `yaml:"issuer"` +} + +// IssuerName is Issuer, or "Jarl ". +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 { MainConfig string `yaml:"main_config"` ConfigDir string `yaml:"config_dir"` @@ -194,6 +217,7 @@ func Default() *Config { StagingDir: "/var/lib/jarl/staging", Log: Log{Level: "info", Format: "text"}, Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"}, + TOTP: TOTP{Require: "none"}, BIND: BIND{ MainConfig: "/etc/bind/named.conf", ConfigDir: "/etc/bind/jarl", @@ -267,6 +291,14 @@ func (c *Config) check() error { default: 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 { return fmt.Errorf("stats: %w", err) } @@ -404,6 +436,15 @@ session: idle_timeout: 2h # ...or after this long without activity 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 " + bind: # The file named loads. 'jarld adopt' makes it include Jarl's config. main_config: /etc/bind/named.conf diff --git a/internal/model/changeset.go b/internal/model/changeset.go index 666f311..572bf09 100644 --- a/internal/model/changeset.go +++ b/internal/model/changeset.go @@ -70,8 +70,17 @@ type User struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` 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 // hash is stored. type Token struct { @@ -125,6 +134,9 @@ type Session struct { LastSeenAt time.Time `json:"last_seen_at"` IP string `json:"ip"` 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. diff --git a/internal/server/authn.go b/internal/server/authn.go index 092f0af..d6e5736 100644 --- a/internal/server/authn.go +++ b/internal/server/authn.go @@ -266,6 +266,10 @@ func (s *Server) authed(h handler) http.HandlerFunc { // The CLI warns its user when it sees this. 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) if se != nil { 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) 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) id, csrf := auth.NewSessionID() 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}) 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", "") - writeJSON(w, http.StatusOK, s.whoamiOf(p, se)) + writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se)) return nil } diff --git a/internal/server/h_auth.go b/internal/server/h_auth.go index 8aacafb..46622c7 100644 --- a/internal/server/h_auth.go +++ b/internal/server/h_auth.go @@ -18,7 +18,7 @@ import ( func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { 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 } @@ -136,6 +136,11 @@ func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth. if err := decodeJSON(r, &in); err != nil { 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) == "" { 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 { return conflict("token %d is revoked; create a new one", id) } + if err := s.stepUp(r, p); err != nil { + return err + } now := time.Now() var exp *time.Time switch { @@ -365,7 +373,7 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Princ } out := []api.User{} for _, u := range us { - out = append(out, presentUser(u)) + out = append(out, s.userOut(u)) } writeJSON(w, http.StatusOK, out) 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 { return err } + if err := s.stepUp(r, p); err != nil { + return err + } if err := s.store.CreateUser(r.Context(), u); err != nil { if errors.Is(err, store.ErrConflict) { 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 } 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 } @@ -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() { return forbidden("only administrators can see other users") } - writeJSON(w, http.StatusOK, presentUser(u)) + writeJSON(w, http.StatusOK, s.userOut(u)) 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 { return err } + if err := s.stepUp(r, p); err != nil { + return err + } if wasAdmin && (!u.Admin || u.Disabled) { 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) @@ -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.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 } @@ -523,6 +537,9 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Prin if u.ID == p.User.ID { return conflict("you can't delete yourself") } + if err := s.stepUp(r, p); err != nil { + return err + } if u.Admin && !u.Disabled { if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 { return conflict("%s is the last administrator", u.Username) diff --git a/internal/server/h_changes.go b/internal/server/h_changes.go index 1b85f28..5a913d2 100644 --- a/internal/server/h_changes.go +++ b/internal/server/h_changes.go @@ -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)} if p.IsAdmin() { resp.Backup = s.backupStatus(r.Context(), now) + resp.TOTP = s.totpOverview(r.Context()) } writeJSON(w, http.StatusOK, resp) return nil diff --git a/internal/server/h_totp.go b/internal/server/h_totp.go new file mode 100644 index 0000000..42f96e6 --- /dev/null +++ b/internal/server/h_totp.go @@ -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 +} diff --git a/internal/server/present.go b/internal/server/present.go index 760c491..e8db7a7 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -1,6 +1,8 @@ package server import ( + "context" + "jarl/internal/api" "jarl/internal/auth" "jarl/internal/catalog" @@ -131,8 +133,20 @@ func presentToken(t *model.Token) api.Token { return out } -func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami { - out := api.Whoami{User: presentUser(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version} +// userOut is presentUser with what depends on the server's settings. +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 { t := presentToken(p.Token) out.Token = &t diff --git a/internal/server/routes.go b/internal/server/routes.go index 7107ab5..6aa3917 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -24,9 +24,15 @@ func (s *Server) routes() { }) // Authentication 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("GET "+p+"/auth/whoami", s.authed(s.whoami)) 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("POST "+p+"/me/tokens", s.authed(s.createOwnToken)) 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("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens)) m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken)) + m.HandleFunc("DELETE "+p+"/users/{id}/totp", s.authed(s.resetUserTOTP)) // Views m.HandleFunc("GET "+p+"/views", s.authed(s.listViews)) m.HandleFunc("POST "+p+"/views", s.authed(s.createView)) diff --git a/internal/server/server.go b/internal/server/server.go index 0d9da3d..17a7632 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -47,6 +47,10 @@ type Config struct { // reports on (backup.dir and backup.warn_after). BackupDir string 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. @@ -64,6 +68,8 @@ type Server struct { oldSecretWarn throttle // expiryWarn limits warnings about expiring tokens to one a day each. expiryWarn throttle + // pendingLogins are logins waiting for their TOTP code. + pendingLogins pendingLogins // nsChecker, when set, replaces the default name server checker // (tests point it at local DNS servers). nsChecker *nscheck.Checker @@ -86,6 +92,12 @@ func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server { if cfg.CookieSecure == "" { cfg.CookieSecure = "auto" } + if cfg.TOTPRequire == "" { + cfg.TOTPRequire = "none" + } + if cfg.TOTPIssuer == "" { + cfg.TOTPIssuer = "Jarl" + } if log == nil { log = slog.Default() } diff --git a/internal/server/totp_test.go b/internal/server/totp_test.go new file mode 100644 index 0000000..40b1ba9 --- /dev/null +++ b/internal/server/totp_test.go @@ -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) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index c9f7dee..8c69bca 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -238,6 +238,23 @@ CREATE TABLE deployed_files ( 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_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) +); `, } diff --git a/internal/store/totp.go b/internal/store/totp.go new file mode 100644 index 0000000..a358ebb --- /dev/null +++ b/internal/store/totp.go @@ -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 +} diff --git a/internal/store/users.go b/internal/store/users.go index b3c73c8..fb2c408 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -18,14 +18,14 @@ func isUnique(err error) bool { 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) { u := &model.User{} var grants, ca, ua string var admin, disabled int - var ll sql.NullString - if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll); err != nil { + var ll, te sql.NullString + 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 } 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.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll) + u.TOTPEnabledAt = parseTSPtr(te) return u, nil } @@ -263,8 +264,8 @@ func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error { // CreateSession stores a session. 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 (?,?,?,?,?,?,?,?)`, - se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent) + _, 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, tsPtr(se.TOTPAt)) 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) { se := &model.Session{} 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). - Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent) + var ta sql.NullString + 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) { return nil, ErrNotFound } @@ -281,6 +283,7 @@ func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, return nil, err } se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls) + se.TOTPAt = parseTSPtr(ta) return se, nil } diff --git a/internal/totp/totp.go b/internal/totp/totp.go new file mode 100644 index 0000000..6f3de8a --- /dev/null +++ b/internal/totp/totp.go @@ -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 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, ``, n, n) + fmt.Fprintf(&b, ``) + 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)) +} diff --git a/internal/totp/totp_test.go b/internal/totp/totp_test.go new file mode 100644 index 0000000..fb91a85 --- /dev/null +++ b/internal/totp/totp_test.go @@ -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") + } +} diff --git a/internal/web/static/assets/api.js b/internal/web/static/assets/api.js index ddbe96f..bfdb8fc 100644 --- a/internal/web/static/assets/api.js +++ b/internal/web/static/assets/api.js @@ -23,8 +23,27 @@ export class APIError extends Error { let onUnauthorized = () => {}; 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 = {}) { + 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" }; let body; if (opts.body !== undefined) { @@ -40,6 +59,7 @@ export async function api(method, path, opts = {}) { if (s) url += (url.includes("?") ? "&" : "?") + s; } 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" }); if (resp.status === 204) return null; const ct = resp.headers.get("Content-Type") || ""; diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js index a118041..037c173 100644 --- a/internal/web/static/assets/app.js +++ b/internal/web/static/assets/app.js @@ -1,7 +1,8 @@ // Jarl web UI: router, layout and login. import { h, mount, toast, errorBox, plural } from "./ui.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 zones from "./pages/zones.js"; import * as zone from "./pages/zone.js"; @@ -65,22 +66,81 @@ function renderLogin(message) { e.preventDefault(); mount(err); try { - const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } }); - state.me = me; - state.csrf = me.csrf_token; - await start(); + const r = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } }); + if (r.totp_required) { renderCodeStep(r.challenge); return; } + await loggedIn(r); } catch (ex) { mount(err, errorBox(ex)); pass.select(); } } }, 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"), h("h1", null, wordmark()), h("div", { class: "tag" }, "Rule your DNS holds"), message ? h("div", { class: "msg warn" }, message) : null, - form)); - user.focus(); + ...body); +} + +// 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() { @@ -183,12 +243,11 @@ setUnauthorizedHandler(() => { } }); +setCodePrompt(totp.askCode); + (async function boot() { try { - const me = await get("/auth/whoami"); - state.me = me; - state.csrf = me.csrf_token || ""; - await start(); + await loggedIn(await get("/auth/whoami")); } catch (e) { if (e.status === 401) renderLogin(); else mount(app, h("div", { class: "login panel" }, h("h1", null, "Jarl"), errorBox(e))); diff --git a/internal/web/static/assets/pages/account.js b/internal/web/static/assets/pages/account.js index 1bc2bbd..e6473f4 100644 --- a/internal/web/static/assets/pages/account.js +++ b/internal/web/static/assets/pages/account.js @@ -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 * as totp from "../totp.js"; import { tokenTable, newTokenForm, grantHelp } from "./users.js"; export async function render(el) { @@ -18,6 +19,7 @@ export async function render(el) { h("h1", null, "Account: " + me.user.username), h("div", { class: "panel" }, h("h2", null, "Permissions"), 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: "form-grid" }, field("Current password", cur), field("New password", pw1, "At least 8 characters."), field("Repeat", pw2)), err, @@ -36,3 +38,64 @@ export async function render(el) { grantHelp(grants)); 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(); } +} diff --git a/internal/web/static/assets/pages/dashboard.js b/internal/web/static/assets/pages/dashboard.js index 439533f..fea1c22 100644 --- a/internal/web/static/assets/pages/dashboard.js +++ b/internal/web/static/assets/pages/dashboard.js @@ -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("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 || []; if (exp.length) { const now = new Date(); diff --git a/internal/web/static/assets/pages/users.js b/internal/web/static/assets/pages/users.js index c03c0a7..c18473d 100644 --- a/internal/web/static/assets/pages/users.js +++ b/internal/web/static/assets/pages/users.js @@ -16,11 +16,13 @@ export async function render(el) { h("div", { class: "panel" }, table([ { 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: "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: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : h("span", { class: "muted" }, "never") }, { label: "", render: (u) => h("span", null, h("button", { class: "small", onclick: () => tokensDialog(u, grants) }, "Tokens"), " ", 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" }, ], users)), grantHelp(grants)); @@ -78,6 +80,15 @@ async function userDialog(u, grants) { ], { 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) { if (!(await confirmDialog("Delete user", "Delete " + u.username + " and all their API tokens?", "Delete", "danger"))) return; try { diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 74f338d..6d3d64a 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -242,6 +242,14 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .kv dd { margin: 0; } .grants { font-family: var(--mono); font-size: 12px; } .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); } .sortable-row { display: flex; align-items: center; gap: 8px; } @media (max-width: 700px) { diff --git a/internal/web/static/assets/totp.js b/internal/web/static/assets/totp.js new file mode 100644 index 0000000..5980cee --- /dev/null +++ b/internal/web/static/assets/totp.js @@ -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(); +}