Add two-factor login (TOTP) with recovery codes
Users can set up an authenticator app from their account page; login then asks for a code after the password, with one-time recovery codes as a fallback. Sensitive actions (tokens, user changes, 2FA resets) need a code from the last 5 minutes. totp.require (none/admins/all) forces setup, and admins can reset a user's 2FA from the UI, the jarl CLI or jarld on the server. The dashboard and jarl status flag users missing 2FA and a shared "admin" account still in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b22ffee1bb
commit
8f9cdd0a17
35 files changed
+1965
-43
No files matched your search
@@ -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 <hostname>`).
|
||||
- Resetting a user's two-factor login (lost phone): Users → Reset 2FA,
|
||||
`jarl user totp reset NAME`, or on the server
|
||||
`jarld user totp reset NAME` (also with jarld stopped).
|
||||
- The dashboard and `jarl status` list users who must use two-factor login
|
||||
but haven't set it up, and suggest disabling the first `admin` account
|
||||
when it is still active next to personal administrator accounts.
|
||||
`jarl user list`/`show` and the Users page show who uses it.
|
||||
- API: `POST /auth/login/totp`, `/me/totp` (`setup`, `enable`, `disable`,
|
||||
`recovery-codes`), `DELETE /users/{id}/totp`; database migration 3.
|
||||
|
||||
- `scripts/testenv.sh`: turns a fresh Debian/Ubuntu machine into a test bed.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+70
-3
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+40
-2
@@ -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
|
||||
}
|
||||
+28
-2
@@ -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.
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
@@ -25,6 +25,8 @@ The CLI has its own small configuration file, described in [cli.md](cli.md).
|
||||
| `session.lifetime` | `12h` | A web session ends this long after login... |
|
||||
| `session.idle_timeout` | `2h` | ...or after this long without requests. |
|
||||
| `session.cookie_secure` | `auto` | `auto` sets the cookie's Secure flag when the request came over HTTPS (directly or via a trusted proxy); `always` / `never` force it. |
|
||||
| `totp.require` | `none` | Who must use [two-factor login](permissions.md#two-factor-login): `none` (each user decides), `admins`, or `all` (every user with a password). Someone who must and hasn't set it up can only set it up after logging in. API tokens and the local socket are not affected. |
|
||||
| `totp.issuer` | `""` | The name authenticator apps show for the account. Empty means `Jarl <short hostname>`, so several servers are told apart. At most 64 characters, no `:`. Changing it doesn't affect apps already set up. |
|
||||
|
||||
Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`).
|
||||
|
||||
|
||||
+5
-3
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+90
-1
@@ -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)
|
||||
```
|
||||
@@ -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 (
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
|
||||
@@ -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 <hostname>").
|
||||
Issuer string `yaml:"issuer"`
|
||||
}
|
||||
|
||||
// IssuerName is Issuer, or "Jarl <short hostname>".
|
||||
func (t TOTP) IssuerName() string {
|
||||
if t.Issuer != "" {
|
||||
return t.Issuer
|
||||
}
|
||||
h, _ := os.Hostname()
|
||||
if h, _, _ = strings.Cut(h, "."); h == "" {
|
||||
return "Jarl"
|
||||
}
|
||||
return "Jarl " + h
|
||||
}
|
||||
|
||||
type BIND struct {
|
||||
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 <hostname>"
|
||||
|
||||
bind:
|
||||
# The file named loads. 'jarld adopt' makes it include Jarl's config.
|
||||
main_config: /etc/bind/named.conf
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SetTOTPPending stores a secret being set up (or clears it with ""). It
|
||||
// becomes the user's secret once a code confirms it (EnableTOTP).
|
||||
func (s *Store) SetTOTPPending(ctx context.Context, userID int64, secret string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET totp_pending = ? WHERE id = ?`, secret, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableTOTP turns two-factor login on: the secret, the step of the code
|
||||
// that confirmed it (so that code can't be used again), and the hashes of
|
||||
// new recovery codes, replacing any old ones.
|
||||
func (s *Store) EnableTOTP(ctx context.Context, userID int64, secret string, step int64, codeHashes []string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := ts(time.Now())
|
||||
res, err := tx.ExecContext(ctx, `UPDATE users SET totp_secret = ?, totp_pending = '', totp_enabled_at = ?, totp_last_step = ?, updated_at = ? WHERE id = ?`,
|
||||
secret, now, step, now, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := replaceRecoveryCodes(ctx, tx, userID, codeHashes); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DisableTOTP turns two-factor login off and deletes the recovery codes.
|
||||
func (s *Store) DisableTOTP(ctx context.Context, userID int64) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(ctx, `UPDATE users SET totp_secret = '', totp_pending = '', totp_enabled_at = NULL, totp_last_step = 0, updated_at = ? WHERE id = ?`, ts(time.Now()), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UseTOTPStep records that the code of step was used. It returns false if
|
||||
// that step (or a later one) was used already: each code works once, even
|
||||
// with two requests at the same moment.
|
||||
func (s *Store) UseTOTPStep(ctx context.Context, userID, step int64) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET totp_last_step = ? WHERE id = ? AND totp_last_step < ? AND totp_secret != ''`, step, userID, step)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// ReplaceRecoveryCodes gives a user new recovery codes (their hashes),
|
||||
// making the old ones useless.
|
||||
func (s *Store) ReplaceRecoveryCodes(ctx context.Context, userID int64, codeHashes []string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := replaceRecoveryCodes(ctx, tx, userID, codeHashes); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func replaceRecoveryCodes(ctx context.Context, tx *sql.Tx, userID int64, hashes []string) error {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, h := range hashes {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)`, userID, h); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UseRecoveryCode marks an unused recovery code (by its hash) used. It
|
||||
// returns false if the user has no such unused code.
|
||||
func (s *Store) UseRecoveryCode(ctx context.Context, userID int64, codeHash string) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL`, ts(time.Now()), userID, codeHash)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// RecoveryCodesLeft counts a user's unused recovery codes.
|
||||
func (s *Store) RecoveryCodesLeft(ctx context.Context, userID int64) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM recovery_codes WHERE user_id = ? AND used_at IS NULL`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// SetSessionTOTP records that a session proved a TOTP code at at.
|
||||
func (s *Store) SetSessionTOTP(ctx context.Context, idHash string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET totp_at = ? WHERE id_hash = ?`, ts(at), idHash)
|
||||
return err
|
||||
}
|
||||
+10
-7
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// Package totp implements time-based one-time passwords (RFC 6238) for
|
||||
// two-factor login, with the settings every authenticator app supports:
|
||||
// HMAC-SHA1, 6 digits, a 30-second step. It also makes the one-time
|
||||
// recovery codes and the QR code that sets up an app.
|
||||
package totp
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
const (
|
||||
// Digits is the length of a code.
|
||||
Digits = 6
|
||||
// Period is the length of a time step.
|
||||
Period = 30 * time.Second
|
||||
// Skew is how many steps before and after the current one are
|
||||
// accepted, for clocks that are a little off.
|
||||
Skew = 1
|
||||
// SecretBytes is the length of a secret (160 bits, as RFC 4226
|
||||
// recommends).
|
||||
SecretBytes = 20
|
||||
// RecoveryCodes is how many recovery codes a user gets.
|
||||
RecoveryCodes = 10
|
||||
)
|
||||
|
||||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// NewSecret returns a new random secret, base32 encoded (the form
|
||||
// authenticator apps take).
|
||||
func NewSecret() string {
|
||||
b := make([]byte, SecretBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b32.EncodeToString(b)
|
||||
}
|
||||
|
||||
func decode(secret string) ([]byte, error) {
|
||||
s := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(secret), " ", ""))
|
||||
k, err := b32.DecodeString(strings.TrimRight(s, "="))
|
||||
if err != nil || len(k) == 0 {
|
||||
return nil, errors.New("invalid TOTP secret")
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// Step returns the time step t falls in.
|
||||
func Step(t time.Time) int64 { return t.Unix() / int64(Period/time.Second) }
|
||||
|
||||
// Code returns the code for a step.
|
||||
func Code(secret string, step int64) (string, error) {
|
||||
k, err := decode(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var msg [8]byte
|
||||
binary.BigEndian.PutUint64(msg[:], uint64(step))
|
||||
m := hmac.New(sha1.New, k)
|
||||
m.Write(msg[:])
|
||||
sum := m.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
v := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
|
||||
return fmt.Sprintf("%0*d", Digits, v%1_000_000), nil
|
||||
}
|
||||
|
||||
// Normalize removes the spaces and dashes people type in codes.
|
||||
func Normalize(code string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == ' ' || r == '-' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
// IsCode reports whether s looks like a TOTP code (as opposed to a
|
||||
// recovery code).
|
||||
func IsCode(s string) bool {
|
||||
s = Normalize(s)
|
||||
if len(s) != Digits {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Verify checks a code against the steps around now and returns the step
|
||||
// it matched. Steps up to and including last (the newest step already
|
||||
// used) are refused, so a code works only once.
|
||||
func Verify(secret, code string, now time.Time, last int64) (int64, bool) {
|
||||
code = Normalize(code)
|
||||
if !IsCode(code) {
|
||||
return 0, false
|
||||
}
|
||||
cur := Step(now)
|
||||
for d := -Skew; d <= Skew; d++ {
|
||||
step := cur + int64(d)
|
||||
if step <= last {
|
||||
continue
|
||||
}
|
||||
want, err := Code(secret, step)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||||
return step, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// URI is the otpauth:// URI that authenticator apps read from the QR code.
|
||||
func URI(issuer, account, secret string) string {
|
||||
label := url.PathEscape(issuer) + ":" + url.PathEscape(account)
|
||||
q := url.Values{}
|
||||
q.Set("secret", secret)
|
||||
q.Set("issuer", issuer)
|
||||
q.Set("algorithm", "SHA1")
|
||||
q.Set("digits", fmt.Sprint(Digits))
|
||||
q.Set("period", fmt.Sprint(int(Period/time.Second)))
|
||||
return "otpauth://totp/" + label + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// QRDataURI renders text as a QR code: an SVG image as a data: URI, ready
|
||||
// for an <img> tag.
|
||||
func QRDataURI(text string) (string, error) {
|
||||
c, err := qr.Encode(text, qr.M)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
const quiet = 4 // the white border scanners need, in modules
|
||||
n := c.Size + 2*quiet
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, n, n)
|
||||
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n)
|
||||
for y := 0; y < c.Size; y++ {
|
||||
for x := 0; x < c.Size; x++ {
|
||||
if c.Black(x, y) {
|
||||
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+quiet, y+quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString(`"/></svg>`)
|
||||
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(b.String())), nil
|
||||
}
|
||||
|
||||
// recoveryAlphabet has no 0/o, 1/l/i, so codes are easy to read and type.
|
||||
const recoveryAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
|
||||
// NewRecoveryCodes returns n random recovery codes like "k7dm2-xq9pw"
|
||||
// (about 49 bits each).
|
||||
func NewRecoveryCodes(n int) []string {
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
var s strings.Builder
|
||||
for j := range 10 {
|
||||
if j == 5 {
|
||||
s.WriteByte('-')
|
||||
}
|
||||
v, err := rand.Int(rand.Reader, big.NewInt(int64(len(recoveryAlphabet))))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s.WriteByte(recoveryAlphabet[v.Int64()])
|
||||
}
|
||||
out[i] = s.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NormalizeRecovery puts a recovery code as typed into the form it is
|
||||
// stored in (lower case, no spaces or dashes).
|
||||
func NormalizeRecovery(code string) string {
|
||||
return strings.ToLower(Normalize(code))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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") || "";
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user