Add token rotation
jarl token rotate, a Rotate button in the web UI and
POST /api/v1/tokens/{id}/rotate give a token a new secret while it keeps
its ID, name, user and grants. The old secret stops at once or after a
grace period of up to 7 days, during which jarld logs who still uses it.
A token that expires gets its lifetime again. --save writes the new
secret where the CLI got the old one (its configuration or --token @FILE),
--write FILE to any file, and --current picks the token the CLI runs
with, so a restricted token can renew itself. A restricted token can't
rotate a wider token of its user.
Also fixes token create --save for named servers, and future times
(token expiry) shown as "just now" in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b44b92975
commit
0b2159b1a4
21 files changed
+755
-46
No files matched your search
@@ -24,6 +24,15 @@ The first version, to be released as 0.1.0.
|
||||
the web UI. Networks smaller than a /24 explain that they belong in the
|
||||
enclosing /24's zone.
|
||||
- docs/operations.md: setting up a secondary server with its own jarld.
|
||||
- Token rotation: `jarl token rotate`, the Rotate button in the web UI and
|
||||
`POST /api/v1/tokens/{id}/rotate` give a token a new secret while it
|
||||
keeps its ID, name, user and grants. The old secret stops at once or after
|
||||
a grace period of up to 7 days (`--grace`), during which jarld logs who
|
||||
still uses it. A token that expires gets its lifetime again. `--save`
|
||||
writes the new secret where the CLI got the old one (its configuration or
|
||||
`--token @FILE`), `--write FILE` to any file, and `--current` picks the
|
||||
token the CLI runs with, so a restricted token can renew itself.
|
||||
`jarl token list` shows when a token was last rotated.
|
||||
- An endpoint for lego's `httpreq` DNS provider
|
||||
(`/api/v1/acme/httpreq/present` and `/cleanup`), so lego and Traefik can
|
||||
publish DNS-01 challenges with only environment variables, without the
|
||||
@@ -183,6 +192,11 @@ The first version, to be released as 0.1.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `jarl token create --save` stored the token at the top level of the
|
||||
CLI configuration even when a named server was selected; it now goes into
|
||||
that server's entry.
|
||||
- The web UI showed future times, such as token expiry dates, as "just
|
||||
now"; they now read "in 29 days".
|
||||
- The dashboard's "Pending changes (1 open)" counted empty changesets (and
|
||||
other users'), so the Changes page then showed none. It now counts your
|
||||
own changesets that contain changes, and the status API's
|
||||
|
||||
@@ -37,8 +37,10 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
||||
diff, and apply them together. Every applied changeset can be reverted.
|
||||
Serials are bumped automatically.
|
||||
* **Users and API tokens with granular permissions**, e.g. a token that may
|
||||
only write `_acme-challenge` TXT records in one zone. Passwords use
|
||||
argon2id, sessions have CSRF protection, logins are rate limited.
|
||||
only write `_acme-challenge` TXT records in one zone. Tokens can be
|
||||
rotated (a new secret, same grants), with an optional grace period.
|
||||
Passwords use argon2id, sessions have CSRF protection, logins are rate
|
||||
limited.
|
||||
* **Audit log** of every login, change and token, with user, token, IP and
|
||||
outcome.
|
||||
* **Query statistics** (optional, off by default): queries over time,
|
||||
|
||||
+10
-10
@@ -536,13 +536,17 @@ func tokenCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
return output(tokens, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tUSER\tNAME\tPREFIX\tGRANTS\tEXPIRES\tLAST USED\tREVOKED")
|
||||
fmt.Fprintln(w, "ID\tUSER\tNAME\tPREFIX\tGRANTS\tEXPIRES\tROTATED\tLAST USED\tREVOKED")
|
||||
for _, t := range tokens {
|
||||
g := "(user's)"
|
||||
if t.Grants != nil {
|
||||
g = strings.Join(t.GrantStrings, "; ")
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, t.PublicID, g, fmtTime(t.ExpiresAt), fmtTime(t.LastUsedAt), fmtTime(t.RevokedAt))
|
||||
rot := fmtTime(t.RotatedAt)
|
||||
if t.PrevExpiresAt != nil && time.Now().Before(*t.PrevExpiresAt) {
|
||||
rot += " (old secret until " + fmtTime(t.PrevExpiresAt) + ")"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, t.PublicID, g, fmtTime(t.ExpiresAt), rot, fmtTime(t.LastUsedAt), fmtTime(t.RevokedAt))
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -581,13 +585,9 @@ func tokenCmd() *cobra.Command {
|
||||
if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
where := flagConfig
|
||||
if save {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = t.Secret
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
if where, err = saveTokenInConfig(t.Secret); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -603,7 +603,7 @@ func tokenCmd() *cobra.Command {
|
||||
}
|
||||
fmt.Fprintf(w, "Expires:\t%s\n", fmtTime(t.ExpiresAt))
|
||||
if save {
|
||||
fmt.Fprintf(w, "Saved to:\t%s\n", flagConfig)
|
||||
fmt.Fprintf(w, "Saved to:\t%s\n", where)
|
||||
} else {
|
||||
fmt.Fprintln(w, "\nThe token is shown only once; store it now.")
|
||||
}
|
||||
@@ -636,7 +636,7 @@ func tokenCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(list, create, revoke)
|
||||
cmd.AddCommand(list, create, tokenRotateCmd(), revoke)
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/client"
|
||||
)
|
||||
|
||||
// tokenRotateCmd is 'jarl token rotate'.
|
||||
func tokenRotateCmd() *cobra.Command {
|
||||
var user, grace, expires, write string
|
||||
var current, noExpiry, save bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "rotate [ID|NAME]",
|
||||
Short: "Give a token a new secret, keeping its name, grants and ID",
|
||||
Long: `Gives a token a new secret. The token keeps its ID, name, user and grants,
|
||||
so nothing has to be set up again: only the programs that use it need the
|
||||
new secret, which is printed once (or written with --write/--save).
|
||||
|
||||
The old secret stops working at once. With --grace it keeps working for a
|
||||
while (at most 7d), so a client can be switched over without an outage;
|
||||
jarld logs every use of the old secret, so you can see what still uses it.
|
||||
To shut out a leaked token, rotate without --grace (or revoke it).
|
||||
|
||||
A token that expires gets its lifetime again, counted from now (a 90-day
|
||||
token is good for another 90 days). --expires sets a new lifetime and
|
||||
--no-expiry removes the expiry.
|
||||
|
||||
The token is given by ID, by name (your own tokens, or --user's), or with
|
||||
--current, the token this command runs with. A restricted token may rotate
|
||||
itself, which lets automation renew its own token.`,
|
||||
Example: ` jarl token rotate 12
|
||||
jarl token rotate laptop --save # and update the CLI's configuration
|
||||
jarl token rotate --current --save --grace 10m
|
||||
jarl token rotate deploy --user ci --grace 1h -q
|
||||
jarl --token @/etc/jarl/certbot.token token rotate --current --write /etc/jarl/certbot.token`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if current == (len(args) == 1) {
|
||||
return fmt.Errorf("give a token ID or name, or --current")
|
||||
}
|
||||
// Check that --save can work before the old secret is gone.
|
||||
var origin tokenOrigin
|
||||
if save {
|
||||
var err error
|
||||
if origin, err = findTokenOrigin(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var id int64
|
||||
if current {
|
||||
var who api.Whoami
|
||||
if err := c.Get(ctx(), "/auth/whoami", nil, &who); err != nil {
|
||||
return err
|
||||
}
|
||||
if who.Token == nil {
|
||||
return fmt.Errorf("--current: this command isn't using a token")
|
||||
}
|
||||
id = who.Token.ID
|
||||
} else if id, err = tokenID(c, args[0], user); err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.TokenRotation{Grace: grace, ExpiresIn: expires, NoExpiry: noExpiry}
|
||||
var t api.Token
|
||||
if err := c.Do(ctx(), "POST", fmt.Sprintf("/tokens/%d/rotate", id), nil, in, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
var saved []string
|
||||
if write != "" {
|
||||
if err := writeTokenFile(write, t.Secret); err != nil {
|
||||
return fmt.Errorf("the token was rotated, but writing %s failed: %w\nnew token: %s", write, err, t.Secret)
|
||||
}
|
||||
saved = append(saved, write)
|
||||
}
|
||||
if save {
|
||||
where, err := origin.store(t.Secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the token was rotated, but saving it failed: %w\nnew token: %s", err, t.Secret)
|
||||
}
|
||||
saved = append(saved, where)
|
||||
}
|
||||
if flagQuiet {
|
||||
if len(saved) == 0 {
|
||||
fmt.Println(t.Secret)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return output(t, func(w io.Writer) {
|
||||
if len(saved) == 0 {
|
||||
fmt.Fprintf(w, "Token:\t%s\n", t.Secret)
|
||||
}
|
||||
fmt.Fprintf(w, "Name:\t%s (id %d, user %s)\n", t.Name, t.ID, t.Username)
|
||||
if t.Grants != nil {
|
||||
fmt.Fprintf(w, "Grants:\t%s\n", strings.Join(t.GrantStrings, "; "))
|
||||
}
|
||||
fmt.Fprintf(w, "Expires:\t%s\n", fmtTime(t.ExpiresAt))
|
||||
if t.PrevExpiresAt != nil {
|
||||
fmt.Fprintf(w, "Old secret:\tworks until %s\n", fmtTime(t.PrevExpiresAt))
|
||||
} else {
|
||||
fmt.Fprintf(w, "Old secret:\tno longer works\n")
|
||||
}
|
||||
if len(saved) > 0 {
|
||||
fmt.Fprintf(w, "Saved to:\t%s\n", strings.Join(saved, ", "))
|
||||
} else {
|
||||
fmt.Fprintln(w, "\nThe new token is shown only once; store it now.")
|
||||
if current && t.PrevExpiresAt == nil {
|
||||
fmt.Fprintln(w, "This was the token this command ran with, so the CLI needs the new one (next time: --save).")
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.BoolVar(¤t, "current", false, "rotate the token this command runs with")
|
||||
f.StringVar(&user, "user", "", "look the NAME up among this user's tokens (admin)")
|
||||
f.StringVar(&grace, "grace", "", "keep the old secret working this long, e.g. 10m, 24h or 2d (at most 7d)")
|
||||
f.StringVar(&expires, "expires", "", "new lifetime from now, e.g. 90d (default: the token's current lifetime again)")
|
||||
f.BoolVar(&noExpiry, "no-expiry", false, "the token no longer expires")
|
||||
f.StringVar(&write, "write", "", "write the new token to this file (mode 0600 if new) instead of printing it")
|
||||
f.BoolVar(&save, "save", false, "store the new token where the CLI gets its token: its configuration file, or the @FILE it reads")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// tokenID finds a token by ID or by name.
|
||||
func tokenID(c *client.Client, arg, user string) (int64, error) {
|
||||
if id, err := strconv.ParseInt(arg, 10, 64); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
path := "/me/tokens"
|
||||
if user != "" {
|
||||
uid, err := userID(c, user)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
path = fmt.Sprintf("/users/%d/tokens", uid)
|
||||
}
|
||||
var tokens []api.Token
|
||||
if err := c.Get(ctx(), path, nil, &tokens); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var ids []string
|
||||
var found int64
|
||||
for _, t := range tokens {
|
||||
if t.Name == arg && t.RevokedAt == nil {
|
||||
found = t.ID
|
||||
ids = append(ids, strconv.FormatInt(t.ID, 10))
|
||||
}
|
||||
}
|
||||
switch len(ids) {
|
||||
case 0:
|
||||
if user == "" {
|
||||
return 0, fmt.Errorf("you have no token named %q (another user's: --user)", arg)
|
||||
}
|
||||
return 0, fmt.Errorf("user %s has no token named %q", user, arg)
|
||||
case 1:
|
||||
return found, nil
|
||||
}
|
||||
return 0, fmt.Errorf("several tokens are named %q (IDs %s); give the ID", arg, strings.Join(ids, ", "))
|
||||
}
|
||||
|
||||
// tokenOrigin is where the CLI's token comes from, for --save.
|
||||
type tokenOrigin struct {
|
||||
file string // the token is read from this file (@FILE)
|
||||
server string // else it's in the configuration file: this server's entry...
|
||||
top bool // ...or the top-level token
|
||||
}
|
||||
|
||||
// findTokenOrigin follows the same rules as newClient.
|
||||
func findTokenOrigin() (tokenOrigin, error) {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return tokenOrigin{}, err
|
||||
}
|
||||
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
||||
if err != nil {
|
||||
return tokenOrigin{}, err
|
||||
}
|
||||
if len(ts) != 1 {
|
||||
return tokenOrigin{}, fmt.Errorf("--save needs a single server")
|
||||
}
|
||||
t := ts[0]
|
||||
var o tokenOrigin
|
||||
var token string
|
||||
switch {
|
||||
case t.Name != "" && t.Token != "":
|
||||
token, o.server = t.Token, t.Name
|
||||
case flagToken != "":
|
||||
token = flagToken
|
||||
case os.Getenv("JARL_TOKEN") != "":
|
||||
token = os.Getenv("JARL_TOKEN")
|
||||
case t.Name != "":
|
||||
token, o.server = t.Token, t.Name
|
||||
default:
|
||||
token, o.top = cfg.Token, true
|
||||
}
|
||||
if f, ok := strings.CutPrefix(token, "@"); ok {
|
||||
return tokenOrigin{file: f}, nil
|
||||
}
|
||||
if !o.top && o.server == "" {
|
||||
return tokenOrigin{}, fmt.Errorf("--save: the token comes from --token or JARL_TOKEN, so there's nowhere to save it; use --write FILE (or put the token in a file and pass --token @FILE)")
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// store saves a new token where the old one came from, and says where.
|
||||
func (o tokenOrigin) store(secret string) (string, error) {
|
||||
if o.file != "" {
|
||||
return o.file, writeTokenFile(o.file, secret)
|
||||
}
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if o.server != "" {
|
||||
e := cfg.Servers[o.server]
|
||||
if e == nil {
|
||||
return "", fmt.Errorf("server %s is no longer in %s", o.server, flagConfig)
|
||||
}
|
||||
e.Token = secret
|
||||
} else {
|
||||
cfg.Token = secret
|
||||
}
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if o.server != "" {
|
||||
return flagConfig + " (server " + o.server + ")", nil
|
||||
}
|
||||
return flagConfig, nil
|
||||
}
|
||||
|
||||
// saveTokenInConfig stores a token in the CLI configuration file, for the
|
||||
// selected server (its named entry, or the top-level token).
|
||||
func saveTokenInConfig(secret string) (string, error) {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ts) == 1 && ts[0].Name != "" {
|
||||
if e := cfg.Servers[ts[0].Name]; e != nil {
|
||||
e.Token = secret
|
||||
return flagConfig + " (server " + ts[0].Name + ")", saveCLIConfig(cfg)
|
||||
}
|
||||
}
|
||||
cfg.Token = secret
|
||||
return flagConfig, saveCLIConfig(cfg)
|
||||
}
|
||||
|
||||
// writeTokenFile replaces a token file's content. An existing file keeps
|
||||
// its owner and mode; a new one is created with mode 0600.
|
||||
func writeTokenFile(path, secret string) error {
|
||||
return os.WriteFile(path, []byte(secret+"\n"), 0o600)
|
||||
}
|
||||
@@ -315,6 +315,7 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|
||||
| 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) |
|
||||
|
||||
User and token changes take effect immediately. They are not changesets,
|
||||
|
||||
+3
-1
@@ -272,6 +272,8 @@ jarl token list [--user alice | --all] [--revoked]
|
||||
jarl token create laptop --expires 365d
|
||||
jarl token create backup-script --grant "*:read"
|
||||
jarl token create acme --user certbot --role acme
|
||||
jarl token rotate laptop --save # new secret, same token and grants
|
||||
jarl token rotate --current --save --grace 10m # the token this CLI uses
|
||||
jarl token revoke 7
|
||||
|
||||
jarl audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100]
|
||||
@@ -281,7 +283,7 @@ jarl check # validation + named-checkconf of the current configuration
|
||||
jarl resync [--force] [--dry-run] # rewrite every file from the database
|
||||
```
|
||||
|
||||
Grant syntax and roles are in [permissions.md](permissions.md).
|
||||
Grant syntax, roles and token rotation are in [permissions.md](permissions.md).
|
||||
|
||||
## Query statistics
|
||||
|
||||
|
||||
@@ -113,6 +113,54 @@ jarl token revoke 12
|
||||
In the web UI: Account → API tokens (your own) and Users → *user* →
|
||||
Tokens (admin).
|
||||
|
||||
### Rotating a token
|
||||
|
||||
Rotating gives a token a **new secret** and changes nothing else: it keeps
|
||||
its ID, name, user, grants and audit history, so there is nothing to set
|
||||
up again. Only the programs that use the token need the new secret. Use it
|
||||
on a schedule, when someone who knew the token leaves, or when it may have
|
||||
leaked. Revoking is for a token that is no longer needed.
|
||||
|
||||
```sh
|
||||
jarl token rotate 12 # by ID (see 'jarl token list')
|
||||
jarl token rotate laptop --save # by name, and update the CLI's configuration
|
||||
jarl token rotate deploy --user ci --grace 1h # another user's token (admin), old secret works 1 more hour
|
||||
jarl token rotate --current --save # the token this CLI runs with
|
||||
```
|
||||
|
||||
* **The old secret** stops working at once. With `--grace` (at most 7d)
|
||||
it keeps working for that long, so clients can be switched over without
|
||||
an outage. While it does, jarld logs a warning each time the old secret
|
||||
is used (at most once a minute per token and address), with the client's
|
||||
IP, so you can find what still needs updating:
|
||||
`journalctl -u jarl | grep "old secret"`. `jarl token list` and the web
|
||||
UI show when the old secret stops. Rotating again ends the previous grace
|
||||
period. If the token may have leaked, rotate without `--grace`.
|
||||
* **Expiry**: a token that expires gets its lifetime again, counted from
|
||||
now, so a 90-day token is good for another 90 days. `--expires 30d` sets
|
||||
a new lifetime and `--no-expiry` removes the expiry. A token without an
|
||||
expiry keeps having none.
|
||||
* **Where the new secret goes**: it is printed once, like a new token.
|
||||
`--write FILE` writes it to a file instead (an existing file keeps its
|
||||
owner and mode; a new one gets mode 0600). `--save` puts it where the CLI
|
||||
got its token from: the configuration file (the selected server's entry)
|
||||
or the file named in `--token @FILE`. `--save` refuses before rotating
|
||||
when the token came from a literal `--token` or `JARL_TOKEN`.
|
||||
* **Who may rotate**: administrators any token; users their own tokens. A
|
||||
restricted token may rotate itself and tokens no wider than itself, but
|
||||
not a wider token of its user, since it would learn that token's secret.
|
||||
So automation can renew its own token, for example a monthly timer:
|
||||
|
||||
```sh
|
||||
jarl --token @/etc/jarl/certbot.token token rotate --current --save
|
||||
```
|
||||
* Every rotation is in the audit log (`token.rotate`), with when the old
|
||||
secret stops and the new expiry.
|
||||
|
||||
In the web UI, the **Rotate** button next to each token asks how long the
|
||||
old secret should keep working and how long the token should last, then
|
||||
shows the new secret once.
|
||||
|
||||
## Recovering access
|
||||
|
||||
On the server, as root or the `jarl` user:
|
||||
|
||||
@@ -191,6 +191,20 @@ type TokenInput struct {
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// TokenRotation gives a token a new secret (POST /tokens/{id}/rotate).
|
||||
// Every field is optional.
|
||||
type TokenRotation struct {
|
||||
// Grace keeps the old secret working this long (e.g. "1h", "2d"; at
|
||||
// most 7d), so clients can be updated without an outage. Default:
|
||||
// the old secret stops at once.
|
||||
Grace string `json:"grace,omitempty"`
|
||||
// The new expiry. Default: a token that expires gets its lifetime
|
||||
// again, counted from now; one that doesn't, still doesn't.
|
||||
ExpiresIn string `json:"expires_in,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
NoExpiry bool `json:"no_expiry,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneImport imports zone file text into a zone.
|
||||
type ZoneImport struct {
|
||||
Text string `json:"text"`
|
||||
|
||||
@@ -105,6 +105,13 @@ func NewToken() (token, publicID, secretHash string) {
|
||||
return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret)
|
||||
}
|
||||
|
||||
// RotateToken makes a new token with an existing public ID (a rotated
|
||||
// token keeps its ID; only the secret changes).
|
||||
func RotateToken(publicID string) (token, secretHash string) {
|
||||
secret := randomString(20)
|
||||
return TokenPrefix + publicID + "_" + secret, HashSecret(secret)
|
||||
}
|
||||
|
||||
// ParseToken splits a token into its public ID and secret.
|
||||
func ParseToken(token string) (publicID, secret string, err error) {
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix)
|
||||
|
||||
@@ -89,6 +89,20 @@ type Token struct {
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
LastUsedIP string `json:"last_used_ip,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
// RotatedAt is when the token last got a new secret (nil: never).
|
||||
RotatedAt *time.Time `json:"rotated_at,omitempty"`
|
||||
// After a rotation with a grace period, the previous secret keeps
|
||||
// working until PrevExpiresAt.
|
||||
PrevSecretHash string `json:"-"`
|
||||
PrevExpiresAt *time.Time `json:"previous_secret_valid_until,omitempty"`
|
||||
}
|
||||
|
||||
// IssuedAt is when the token's current secret was made.
|
||||
func (t *Token) IssuedAt() time.Time {
|
||||
if t.RotatedAt != nil {
|
||||
return *t.RotatedAt
|
||||
}
|
||||
return t.CreatedAt
|
||||
}
|
||||
|
||||
// Session is a logged-in web session.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -78,6 +79,31 @@ func (l *rateLimiter) cleanup() {
|
||||
}
|
||||
}
|
||||
|
||||
// throttle allows an event once a minute per key (for log lines that
|
||||
// could otherwise repeat on every request).
|
||||
type throttle struct {
|
||||
mu sync.Mutex
|
||||
last map[string]time.Time
|
||||
}
|
||||
|
||||
func (t *throttle) allow(key string, now time.Time) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.last == nil {
|
||||
t.last = map[string]time.Time{}
|
||||
}
|
||||
if now.Sub(t.last[key]) < time.Minute {
|
||||
return false
|
||||
}
|
||||
for k, v := range t.last {
|
||||
if now.Sub(v) > time.Hour {
|
||||
delete(t.last, k)
|
||||
}
|
||||
}
|
||||
t.last[key] = now
|
||||
return true
|
||||
}
|
||||
|
||||
var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer jarl_...'"}
|
||||
|
||||
// authenticate identifies the caller from a bearer token or a session
|
||||
@@ -143,10 +169,19 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !auth.CheckSecret(secret, t.SecretHash) {
|
||||
return nil, errors.New("wrong token secret")
|
||||
}
|
||||
now := time.Now()
|
||||
previous := false
|
||||
if !auth.CheckSecret(secret, t.SecretHash) {
|
||||
// After a rotation with a grace period the old secret works until
|
||||
// the grace period ends.
|
||||
if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) {
|
||||
return nil, errors.New("wrong token secret")
|
||||
}
|
||||
if now.After(*t.PrevExpiresAt) {
|
||||
return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended")
|
||||
}
|
||||
previous = true
|
||||
}
|
||||
if t.RevokedAt != nil {
|
||||
return nil, errors.New("token was revoked")
|
||||
}
|
||||
@@ -163,6 +198,9 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
|
||||
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
|
||||
s.store.TouchToken(ctx, t.ID, ip)
|
||||
}
|
||||
if previous && s.oldSecretWarn.allow(fmt.Sprintf("%d %s", t.ID, ip), now) {
|
||||
s.log.Warn("token used with its old secret; update this client before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
|
||||
}
|
||||
|
||||
|
||||
+123
-14
@@ -1,7 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -106,25 +109,28 @@ func parseExpiry(in api.TokenInput) (*time.Time, error) {
|
||||
if in.ExpiresIn == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v := strings.TrimSpace(in.ExpiresIn)
|
||||
var d time.Duration
|
||||
if n, ok := strings.CutSuffix(v, "d"); ok {
|
||||
days, err := strconv.Atoi(n)
|
||||
if err != nil || days <= 0 {
|
||||
return nil, badRequest("invalid expires_in %q", v)
|
||||
}
|
||||
d = time.Duration(days) * 24 * time.Hour
|
||||
} else {
|
||||
var err error
|
||||
d, err = time.ParseDuration(v)
|
||||
if err != nil || d <= 0 {
|
||||
return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", v)
|
||||
}
|
||||
d, err := parseDays(in.ExpiresIn)
|
||||
if err != nil || d <= 0 {
|
||||
return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
|
||||
}
|
||||
t := time.Now().Add(d)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// parseDays parses a duration in Go syntax ("720h", "15m") or in days
|
||||
// ("90d").
|
||||
func parseDays(v string) (time.Duration, error) {
|
||||
v = strings.TrimSpace(v)
|
||||
if n, ok := strings.CutSuffix(v, "d"); ok {
|
||||
days, err := strconv.Atoi(n)
|
||||
if err != nil || days < 0 {
|
||||
return 0, fmt.Errorf("invalid duration %q", v)
|
||||
}
|
||||
return time.Duration(days) * 24 * time.Hour, nil
|
||||
}
|
||||
return time.ParseDuration(v)
|
||||
}
|
||||
|
||||
func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error {
|
||||
var in api.TokenInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
@@ -243,6 +249,109 @@ func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxRotationGrace is the longest an old secret may keep working.
|
||||
const maxRotationGrace = 7 * 24 * time.Hour
|
||||
|
||||
// rotateToken: POST /tokens/{id}/rotate gives a token a new secret. The
|
||||
// token keeps its ID, name, user and grants, so nothing needs to be set up
|
||||
// again; only the clients that use it need the new secret. The old secret
|
||||
// stops working at once, or after the grace period. A token that expires
|
||||
// gets the same lifetime again, counted from now, unless expires_in or
|
||||
// expires_at says otherwise.
|
||||
func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.TokenRotation
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&in); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
t, err := s.store.GetToken(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return notFound("token %d not found", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if t.UserID != p.User.ID && !p.IsAdmin() {
|
||||
return notFound("token %d not found", id)
|
||||
}
|
||||
// A restricted token may rotate itself, and tokens no wider than
|
||||
// itself, but not a wider token of the same user: it would learn that
|
||||
// token's new secret.
|
||||
if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !narrower(t.Grants, p.Token.Grants)) {
|
||||
return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants")
|
||||
}
|
||||
if t.RevokedAt != nil {
|
||||
return conflict("token %d is revoked; create a new one", id)
|
||||
}
|
||||
now := time.Now()
|
||||
var exp *time.Time
|
||||
switch {
|
||||
case in.ExpiresIn != "" || in.ExpiresAt != nil:
|
||||
if in.NoExpiry {
|
||||
return badRequest("no_expiry can't be combined with expires_in or expires_at")
|
||||
}
|
||||
if exp, err = parseExpiry(api.TokenInput{ExpiresIn: in.ExpiresIn, ExpiresAt: in.ExpiresAt}); err != nil {
|
||||
return err
|
||||
}
|
||||
case in.NoExpiry:
|
||||
case t.ExpiresAt != nil:
|
||||
life := t.ExpiresAt.Sub(t.IssuedAt())
|
||||
if life <= 0 {
|
||||
return badRequest("can't tell the token's lifetime; give expires_in")
|
||||
}
|
||||
e := now.Add(life)
|
||||
exp = &e
|
||||
}
|
||||
var prevUntil *time.Time
|
||||
if in.Grace != "" {
|
||||
g, err := parseDays(in.Grace)
|
||||
if err != nil || g < 0 || g > maxRotationGrace {
|
||||
return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace)
|
||||
}
|
||||
until := now.Add(g)
|
||||
if t.ExpiresAt != nil && t.ExpiresAt.Before(until) {
|
||||
until = *t.ExpiresAt // the old secret never outlives its token
|
||||
}
|
||||
if g > 0 && until.After(now) {
|
||||
prevUntil = &until
|
||||
}
|
||||
}
|
||||
secret, hash := auth.RotateToken(t.PublicID)
|
||||
if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return conflict("token %d is revoked; create a new one", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
t, err = s.store.GetToken(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail := "old secret stops working now"
|
||||
if prevUntil != nil {
|
||||
detail = "old secret works until " + prevUntil.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if t.ExpiresAt != nil {
|
||||
detail += "; expires " + t.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "token.rotate", "token", t.ID, t.Username+"/"+t.Name, "success", detail)
|
||||
out := presentToken(t)
|
||||
out.Secret = secret
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
|
||||
@@ -26,6 +26,7 @@ func (s *Server) routes() {
|
||||
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))
|
||||
m.HandleFunc("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken))
|
||||
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
|
||||
// Users (admin)
|
||||
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
|
||||
|
||||
@@ -53,6 +53,8 @@ type Server struct {
|
||||
web fs.FS
|
||||
certs *certReloader
|
||||
statsQ *stats.Querier
|
||||
// oldSecretWarn limits warnings about rotated tokens' old secrets.
|
||||
oldSecretWarn throttle
|
||||
|
||||
mu sync.Mutex
|
||||
srvs []*http.Server
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTokenRotation(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
e.do("POST", "/users", e.token, map[string]any{"username": "ci", "password": "", "grant_strings": []string{"records:write zones=example.com"}}, 201)
|
||||
var ciID int64
|
||||
for _, u := range e.list("/users", e.token) {
|
||||
if u["username"] == "ci" {
|
||||
ciID = int64(u["id"].(float64))
|
||||
}
|
||||
}
|
||||
created := e.do("POST", "/users/"+itoa(ciID)+"/tokens", e.token, map[string]any{"name": "deploy", "grant_strings": []string{"records:write zones=example.com types=TXT"}, "expires_in": "30d"}, 201)
|
||||
id := itoa(int64(created["id"].(float64)))
|
||||
old := created["token"].(string)
|
||||
works := func(tok string) bool {
|
||||
_, status := e.req("GET", "/auth/whoami", tok, nil)
|
||||
return status == 200
|
||||
}
|
||||
|
||||
// Rotation keeps the token and its grants; the old secret stops.
|
||||
rot := e.do("POST", "/tokens/"+id+"/rotate", e.token, nil, 200)
|
||||
tok1 := rot["token"].(string)
|
||||
if tok1 == old || rot["id"] != created["id"] || rot["name"] != "deploy" || rot["public_id"] != created["public_id"] || rot["rotated_at"] == nil {
|
||||
t.Errorf("rotated = %v", rot)
|
||||
}
|
||||
if g := rot["grant_strings"].([]any); len(g) != 1 || g[0] != "records:write zones=example.com types=TXT" {
|
||||
t.Errorf("grants after rotation = %v", g)
|
||||
}
|
||||
if !works(tok1) || works(old) {
|
||||
t.Errorf("after rotation: new works %v, old works %v", works(tok1), works(old))
|
||||
}
|
||||
// The 30-day lifetime starts again.
|
||||
exp, _ := time.Parse(time.RFC3339Nano, rot["expires_at"].(string))
|
||||
if d := time.Until(exp); d < 29*24*time.Hour || d > 31*24*time.Hour {
|
||||
t.Errorf("expires in %v, want about 30 days", d)
|
||||
}
|
||||
|
||||
// With a grace period both secrets work for a while.
|
||||
rot = e.do("POST", "/tokens/"+id+"/rotate", e.token, map[string]any{"grace": "1h"}, 200)
|
||||
tok2 := rot["token"].(string)
|
||||
if !works(tok2) || !works(tok1) || rot["previous_secret_valid_until"] == nil {
|
||||
t.Errorf("grace: new %v, old %v, %v", works(tok2), works(tok1), rot)
|
||||
}
|
||||
// When the grace period ends, the old secret stops.
|
||||
e.st.DB().ExecContext(context.Background(), `UPDATE tokens SET prev_expires_at = ? WHERE id = ?`, time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano), id)
|
||||
if b, status := e.req("GET", "/auth/whoami", tok1, nil); status != 401 || !strings.Contains(string(b), "grace period has ended") {
|
||||
t.Errorf("old secret after grace: %d %s", status, b)
|
||||
}
|
||||
// Rotating again without grace drops the previous secret at once.
|
||||
e.do("POST", "/tokens/"+id+"/rotate", e.token, map[string]any{"grace": "1h"}, 200)
|
||||
rot = e.do("POST", "/tokens/"+id+"/rotate", e.token, nil, 200)
|
||||
tok3 := rot["token"].(string)
|
||||
if works(tok2) || !works(tok3) || rot["previous_secret_valid_until"] != nil {
|
||||
t.Errorf("rotation without grace kept an old secret: %v", rot)
|
||||
}
|
||||
|
||||
// A restricted token may rotate itself...
|
||||
self := e.do("POST", "/tokens/"+id+"/rotate", tok3, map[string]any{"expires_in": "2d"}, 200)
|
||||
tok4 := self["token"].(string)
|
||||
if works(tok3) || !works(tok4) {
|
||||
t.Error("self-rotation didn't replace the secret")
|
||||
}
|
||||
// ...but not a wider token of its user, whose secret it would learn.
|
||||
wide := e.do("POST", "/users/"+itoa(ciID)+"/tokens", e.token, map[string]any{"name": "wide"}, 201)
|
||||
wideID := itoa(int64(wide["id"].(float64)))
|
||||
if b, status := e.req("POST", "/tokens/"+wideID+"/rotate", tok4, nil); status != 403 {
|
||||
t.Errorf("restricted token rotated a wider one: %d %s", status, b)
|
||||
}
|
||||
// The unrestricted token can rotate the narrower one.
|
||||
e.do("POST", "/tokens/"+id+"/rotate", wide["token"].(string), map[string]any{"no_expiry": true}, 200)
|
||||
// Nobody else's tokens.
|
||||
if _, status := e.req("POST", "/tokens/1/rotate", wide["token"].(string), nil); status != 404 {
|
||||
t.Errorf("rotated the admin's token: %d", status)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
body map[string]any
|
||||
code int
|
||||
}{
|
||||
{map[string]any{"grace": "8d"}, 400},
|
||||
{map[string]any{"grace": "soon"}, 400},
|
||||
{map[string]any{"expires_in": "1d", "no_expiry": true}, 400},
|
||||
} {
|
||||
if b, status := e.req("POST", "/tokens/"+id+"/rotate", e.token, c.body); status != c.code {
|
||||
t.Errorf("%v: %d %s", c.body, status, b)
|
||||
}
|
||||
}
|
||||
e.do("DELETE", "/tokens/"+id, e.token, nil, 204)
|
||||
if _, status := e.req("POST", "/tokens/"+id+"/rotate", e.token, nil); status != 409 {
|
||||
t.Errorf("rotated a revoked token: %d", status)
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,13 @@ CREATE TABLE deployed_files (
|
||||
written_at TEXT NOT NULL,
|
||||
changeset_id INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`,
|
||||
// 2: token rotation. A rotated token gets a new secret; the previous
|
||||
// one may stay valid for a grace period.
|
||||
`
|
||||
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;
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -139,13 +139,13 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at`
|
||||
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at, t.rotated_at, t.prev_secret_hash, t.prev_expires_at`
|
||||
|
||||
func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
|
||||
t := &model.Token{}
|
||||
var grants, exp, lu, rev sql.NullString
|
||||
var grants, exp, lu, rev, rot, pexp sql.NullString
|
||||
var ca string
|
||||
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev); err != nil {
|
||||
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev, &rot, &t.PrevSecretHash, &pexp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if grants.Valid {
|
||||
@@ -157,6 +157,7 @@ func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
|
||||
}
|
||||
}
|
||||
t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev)
|
||||
t.RotatedAt, t.PrevExpiresAt = parseTSPtr(rot), parseTSPtr(pexp)
|
||||
t.CreatedAt = parseTS(ca)
|
||||
return t, nil
|
||||
}
|
||||
@@ -235,6 +236,25 @@ func (s *Store) RevokeToken(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateToken gives a token a new secret and expiry. With prevUntil set,
|
||||
// the old secret keeps working until then; otherwise it stops at once. A
|
||||
// revoked token can't be rotated (ErrNotFound).
|
||||
func (s *Store) RotateToken(ctx context.Context, id int64, secretHash string, expiresAt, prevUntil *time.Time) error {
|
||||
prevHash := `''`
|
||||
if prevUntil != nil {
|
||||
prevHash = `secret_hash`
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET prev_secret_hash = `+prevHash+`, prev_expires_at = ?, secret_hash = ?, expires_at = ?, rotated_at = ? WHERE id = ? AND revoked_at IS NULL`,
|
||||
tsPtr(prevUntil), secretHash, tsPtr(expiresAt), ts(time.Now()), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchToken records a token's use. It is best-effort and cheap.
|
||||
func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, copyBox, badge } from "../ui.js";
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, fmtTime, copyBox, badge } from "../ui.js";
|
||||
import { api, get, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
@@ -101,20 +101,69 @@ export async function tokensDialog(u, grants, own) {
|
||||
|
||||
export function tokenTable(tokens, redraw) {
|
||||
return table([
|
||||
{ label: "Name", render: (t) => h("strong", null, t.name) },
|
||||
{ label: "Name", render: (t) => [h("strong", null, t.name), rotationNote(t)] },
|
||||
{ label: "ID", render: (t) => "jarl_" + t.public_id + "_…", tdClass: "mono small" },
|
||||
{ label: "Restricted to", render: (t) => t.grant_strings && t.grant_strings.length ? h("div", { class: "grants" }, t.grant_strings.map((g) => h("div", null, g))) : h("span", { class: "muted" }, "all of the user's permissions"), tdClass: "small" },
|
||||
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : h("span", { class: "muted" }, "never") },
|
||||
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), " ", h("span", { class: "muted small" }, t.last_used_ip || "")] : h("span", { class: "muted" }, "never") },
|
||||
{ label: "", render: (t) => h("button", { class: "small danger", onclick: async () => {
|
||||
if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return;
|
||||
await api("DELETE", "/tokens/" + t.id);
|
||||
toast("Token revoked");
|
||||
redraw();
|
||||
} }, "Revoke"), tdClass: "actions-cell" },
|
||||
{ label: "", render: (t) => [
|
||||
h("button", { class: "small", onclick: () => rotateDialog(t, redraw) }, "Rotate"), " ",
|
||||
h("button", { class: "small danger", onclick: async () => {
|
||||
if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return;
|
||||
await api("DELETE", "/tokens/" + t.id);
|
||||
toast("Token revoked");
|
||||
redraw();
|
||||
} }, "Revoke")], tdClass: "actions-cell nowrap" },
|
||||
], tokens, { empty: "No tokens." });
|
||||
}
|
||||
|
||||
// rotationNote says when a token last got a new secret, and whether its
|
||||
// old secret still works.
|
||||
function rotationNote(t) {
|
||||
const parts = [];
|
||||
if (t.previous_secret_valid_until && new Date(t.previous_secret_valid_until) > new Date()) {
|
||||
parts.push(h("div", { class: "small warn-text" }, "old secret stops ", time(t.previous_secret_valid_until)));
|
||||
} else if (t.rotated_at) {
|
||||
parts.push(h("div", { class: "muted small" }, "rotated ", time(t.rotated_at)));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// rotateDialog gives a token a new secret. The token keeps its name,
|
||||
// grants and ID; only the programs using it need the new secret.
|
||||
async function rotateDialog(t, redraw) {
|
||||
const grace = select([["", "stops working now"], ["10m", "keeps working for 10 minutes"], ["1h", "keeps working for 1 hour"], ["1d", "keeps working for 1 day"], ["7d", "keeps working for 7 days"]], "");
|
||||
const expOpts = [["", t.expires_at ? "the same lifetime again, from now" : "never expires (as now)"], ["30d", "30 days from now"], ["90d", "90 days from now"], ["365d", "1 year from now"]];
|
||||
if (t.expires_at) expOpts.push(["never", "never expires"]);
|
||||
const exp = select(expOpts, "");
|
||||
const err = h("div");
|
||||
const box = h("div");
|
||||
const form = h("div", null,
|
||||
h("p", null, "Gives ", h("strong", null, t.name), " a new secret. It keeps its name, ID and permissions, so only the programs that use it need updating."),
|
||||
h("div", { class: "form-grid" },
|
||||
field("Old secret", grace, "A grace period lets you update clients without an outage. If the token may have leaked, let it stop now."),
|
||||
field("Expires", exp)),
|
||||
err,
|
||||
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async (e) => {
|
||||
mount(err);
|
||||
const body = {};
|
||||
if (grace.value) body.grace = grace.value;
|
||||
if (exp.value === "never") body.no_expiry = true;
|
||||
else if (exp.value) body.expires_in = exp.value;
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
const r = await api("POST", "/tokens/" + t.id + "/rotate", { body });
|
||||
mount(box, h("div", { class: "msg ok" },
|
||||
h("p", null, "New secret for " + t.name + ". Copy it now: it is not shown again."),
|
||||
copyBox(r.token),
|
||||
h("p", { class: "small" }, r.previous_secret_valid_until ? ["The old secret keeps working until ", fmtTime(r.previous_secret_valid_until), " (", time(r.previous_secret_valid_until), ")."] : "The old secret no longer works.")));
|
||||
if (redraw) redraw();
|
||||
} catch (ex) { e.target.disabled = false; mount(err, errorBox(ex)); }
|
||||
} }, "Rotate")));
|
||||
mount(box, form);
|
||||
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
|
||||
}
|
||||
|
||||
export function newTokenForm(base, grants, redraw) {
|
||||
const err = h("div");
|
||||
const out = h("div");
|
||||
|
||||
@@ -78,6 +78,7 @@ h3 { font-size: 14px; margin: 14px 0 6px; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.warn-text { color: var(--warn); }
|
||||
.right { text-align: right; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
|
||||
@@ -49,12 +49,15 @@ export function fmtTime(iso) {
|
||||
|
||||
export function relTime(iso) {
|
||||
if (!iso) return "";
|
||||
const s = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
const s = Math.abs(diff);
|
||||
if (s < 60) return "just now";
|
||||
if (s < 3600) return Math.floor(s / 60) + " min ago";
|
||||
if (s < 86400) return Math.floor(s / 3600) + " h ago";
|
||||
if (s < 86400 * 30) return Math.floor(s / 86400) + " days ago";
|
||||
return fmtTime(iso);
|
||||
let n;
|
||||
if (s < 3600) n = Math.floor(s / 60) + " min";
|
||||
else if (s < 86400) n = Math.floor(s / 3600) + " h";
|
||||
else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day");
|
||||
else return fmtTime(iso);
|
||||
return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry)
|
||||
}
|
||||
|
||||
export function time(iso) {
|
||||
|
||||
@@ -241,6 +241,17 @@ run "acme unset when nothing there" 0 $J $ACME acme unset adbraun.test
|
||||
run "acme in unknown zone" 1 $J $ACME acme set example.org v
|
||||
run "token list --all" 0 $J token list --all
|
||||
contains "certbot-hook"
|
||||
cp $E/acme.token $E/acme.token.old
|
||||
run "acme token rotates itself (grace)" 0 $J $ACME token rotate --current --save --grace 1h
|
||||
run "rotated token works" 0 $J $ACME whoami
|
||||
run "old secret works during grace" 0 $J --token @$E/acme.token.old whoami
|
||||
run "rotate again without grace" 0 $J $ACME token rotate --current --save
|
||||
run "old secret refused after rotation (exit 3)" 3 $J --token @$E/acme.token.old whoami
|
||||
run "rotated token keeps its grants" 0 $J $ACME acme set adbraun.test rotated
|
||||
run "acme unset after rotation" 0 $J $ACME acme unset adbraun.test rotated
|
||||
run "acme token cannot rotate the admin's token (exit 4)" 4 $J $ACME token rotate 1
|
||||
run "token list shows rotation" 0 $J token list --user certbot
|
||||
contains "certbot-hook"
|
||||
TID=$($J token list --user certbot -o json | grep -o '"id": [0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
run "token revoke" 0 $J token revoke $TID
|
||||
run "revoked token refused (exit 3)" 3 $J $ACME whoami
|
||||
|
||||
Reference in new issue
Block a user