diff --git a/CHANGELOG.md b/CHANGELOG.md index 6818dcd..f48014a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,8 +80,12 @@ and a CLI, built the way Jarl is. - It lists what it doesn't support instead of dropping it silently. - Importing again keeps subnets' and reservations' IDs and Steward's subnet names. -- **`stewardd`** commands: `init`, `user add|password|token`, `check`, - `backup` (with pruning), `restore`, `config --example`. +- **`stewardd`** commands: `init` (asks for the administrator's name), + `user add|password|token`, `check`, `backup` (with pruning), `restore`, + `config --example`. +- **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`, + Rotate in the web UI): a new secret for a token, with an optional grace + period for the old one, as in Jarl. - **Packaging:** - a sandboxed systemd unit (`steward.service`, also `stewardd.service`) whose only privilege is `CAP_NET_BIND_SERVICE`; diff --git a/cmd/steward/admin.go b/cmd/steward/admin.go index 9e21450..21c89d3 100644 --- a/cmd/steward/admin.go +++ b/cmd/steward/admin.go @@ -483,6 +483,83 @@ func tokenCmd() *cobra.Command { f.StringVar(&user, "user", "", "the user it belongs to (administrators)") f.BoolVar(&save, "save", false, "save it as this CLI's token") cmd.AddCommand(create) + var grace, rexp string + var current, rsave bool + rotate := &cobra.Command{ + Use: "rotate [ID]", + Short: "Give a token a new secret (shown once)", + Long: `Gives a token a new secret. It keeps its ID, name and permissions, so only +the programs that use it need the new secret. The old secret stops at once, +or after --grace (up to 7d) so you can update them without an outage. A +token that expires gets its lifetime again, from now, unless --expires says +otherwise (90d, or never). + + steward token rotate --current --save # this CLI's own token`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + id := "" + switch { + case current && len(args) == 0: + var me api.Whoami + if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil { + return err + } + if me.TokenID == 0 { + return fmt.Errorf("this CLI isn't using a token (%s); give the token's ID", me.Method) + } + id = strconv.FormatInt(me.TokenID, 10) + case len(args) == 1 && !current: + id = args[0] + default: + return fmt.Errorf("give the token's ID, or --current") + } + body := api.TokenRotation{Grace: grace} + switch rexp { + case "": + case "never": + body.NoExpiry = true + default: + body.ExpiresIn = rexp + } + var t api.Token + if err := c.Do(ctx(), "POST", "/tokens/"+id+"/rotate", nil, body, &t); err != nil { + return err + } + if rsave { + cfg, err := loadCLIConfig() + if err != nil { + return err + } + cfg.Token = t.Secret + if err := saveCLIConfig(cfg); err != nil { + return err + } + fmt.Fprintln(os.Stderr, "saved in", flagConfig) + } + if flagOutput == "json" || flagOutput == "yaml" { + return output(t, nil) + } + fmt.Println(t.Secret) + if !flagQuiet { + if t.PrevExpiresAt != nil { + fmt.Fprintf(os.Stderr, "the old secret works until %s\n", t.PrevExpiresAt.Local().Format("2006-01-02 15:04")) + } else { + fmt.Fprintln(os.Stderr, "the old secret no longer works") + } + } + return nil + }, + } + f = rotate.Flags() + f.StringVar(&grace, "grace", "", "keep the old secret working this long (30m, 24h, up to 7d)") + f.StringVar(&rexp, "expires", "", "a new lifetime from now (90d), or never") + f.BoolVar(¤t, "current", false, "rotate the token this CLI uses") + f.BoolVar(&rsave, "save", false, "save the new secret as this CLI's token") + cmd.AddCommand(rotate) cmd.AddCommand(&cobra.Command{ Use: "revoke ID", Short: "Revoke a token", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/stewardd/main.go b/cmd/stewardd/main.go index 5eec515..d7a2e5a 100644 --- a/cmd/stewardd/main.go +++ b/cmd/stewardd/main.go @@ -221,6 +221,26 @@ func readPassword(prompt string, fromStdin bool) (string, error) { return string(b), nil } +// readLine reads one line a byte at a time, so nothing after it is +// buffered away from the password prompt that follows. +func readLine(r io.Reader) string { + var b []byte + one := make([]byte, 1) + for { + n, err := r.Read(one) + if n == 1 { + if one[0] == '\n' { + break + } + b = append(b, one[0]) + } + if err != nil { + break + } + } + return strings.TrimRight(string(b), "\r") +} + func localPrincipal(u *model.User) *auth.Principal { return &auth.Principal{User: u, Method: "local", IP: "local"} } @@ -266,6 +286,13 @@ web UI, or import a Kea configuration with 'steward import kea'.`, if len(users) > 0 { return fmt.Errorf("Steward is already set up (%d users); add users with 'stewardd user add' or 'steward user add'", len(users)) } + // Asked for when not given, on a terminal. + if !cmd.Flags().Changed("user") && !stdin && term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprint(os.Stderr, "The first administrator's user name [admin]: ") + if name := strings.TrimSpace(readLine(os.Stdin)); name != "" { + username = name + } + } if err := server.ValidUsername(username); err != nil { return err } @@ -298,7 +325,7 @@ web UI, or import a Kea configuration with 'steward import kea'.`, return nil }, } - cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account)") + cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account); asked for on a terminal") cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input") cmd.Flags().BoolVar(&withToken, "token", false, "also make an API token for the CLI") return cmd diff --git a/docs/api.md b/docs/api.md index 946c898..719189e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -128,10 +128,11 @@ settings'. | | | |---|---| -| `POST /auth/login`, `POST /auth/logout`, `GET /auth/whoami` | | +| `POST /auth/login`, `POST /auth/logout`, `GET /auth/whoami` | `whoami` has `token_id` when the caller uses a token | | `POST /me/password` | `{"current", "password"}` | | `GET /me/tokens`, `POST /me/tokens` | `{"name", "role", "grants", "expires_in"}`; the answer's `secret` is shown once | | `GET /tokens`, `DELETE /tokens/{id}` | all tokens (administrators), revoke | +| `POST /tokens/{id}/rotate` | a new secret: `{"grace": "1h", "expires_in": "90d", "no_expiry": false}`, all optional; answers with `secret` and `previous_secret_valid_until` | | `GET`, `POST /users`; `PATCH`, `DELETE /users/{id}` | administrators; `{"username", "password", "admin", "role", "grants", "disabled", "comment"}` | | `GET`, `POST /users/{id}/tokens` | | | `GET /audit[?user=&action=&result=&q=&before=&limit=]` | | diff --git a/docs/cli.md b/docs/cli.md index af4cb9b..e969a97 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -136,6 +136,7 @@ see [kea.md](kea.md). | `whoami` | who you are to the server, and what you may do | | `user list\|add\|update\|delete` | users (administrators); `user add NAME --role operator` | | `token list [--all]`, `token create NAME [--role R] [--grant G]... [--expires 90d] [--user U] [--save]`, `token revoke ID` | API tokens | +| `token rotate ID\|--current [--grace 1h] [--expires 90d\|never] [--save]` | a new secret for a token, the old one working for the grace period | | `password` | change your password | | `audit [--user U] [--action PREFIX] [--search TEXT]` | the audit log | | `config show`, `config set server\|token\|insecure\|ca_cert [VALUE]` | this CLI's settings | @@ -150,7 +151,7 @@ The daemon's own commands work on the database directly, as root or the | Command | | |---|---| | `stewardd serve` | run (the systemd unit does) | -| `stewardd init [-u NAME] [--token]` | create the database and the first administrator | +| `stewardd init [-u NAME] [--token]` | create the database and the first administrator (asks for the name on a terminal) | | `stewardd user add NAME [--admin]`, `user password NAME`, `user token NAME TOKEN-NAME` | when the API can't be used | | `stewardd check` | check the configuration against this machine's interfaces | | `stewardd backup [FILE]`, `stewardd restore FILE` | [operations.md](operations.md) | diff --git a/docs/install.md b/docs/install.md index 7ef9257..2bed061 100644 --- a/docs/install.md +++ b/docs/install.md @@ -57,6 +57,10 @@ sudo chgrp steward /etc/steward /etc/steward/steward.yaml && sudo systemctl daem ```sh sudo -u steward stewardd init ``` + + It asks for the administrator's name (`admin` if you just press Enter; + your own name is better, since each person should have an account) and + password. `-u NAME` gives the name without asking. 3. **Stop any other DHCP server** on the interfaces Steward will serve (Kea, isc-dhcp-server, dnsmasq): only one program can use an interface's port 67. Moving from Kea: [kea.md](kea.md). diff --git a/docs/permissions.md b/docs/permissions.md index 8ec6944..145193e 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -7,7 +7,7 @@ else gets **grants**, `resource:access`: | Resource | read | write | |---|---|---| -| `config` | settings, subnets, reservations, history, interfaces | change them, undo, import | +| `config` | settings, subnets, reservations, history, interfaces (the web UI shows the forms locked) | change them, undo, import | | `leases` | leases and DHCP activity | delete a lease (making a reservation from one needs `config:write` too) | | `audit` | the audit log | – | | `*` | all of the above | | @@ -38,7 +38,24 @@ steward token create backup-script --role leases --expires 365d ``` The secret is shown once; only its hash is stored. A restricted token can -only create tokens with a subset of its own grants. `steward token list` +only create tokens with a subset of its own grants. The web UI only offers +roles within what your account may do; a token with more would get no +more than its user anyway. + +**Rotating** gives a token a new secret, keeping its ID, name and +permissions, so only the programs that use it need updating: + +```sh +steward token rotate 7 --grace 1h # the old secret works for another hour +steward token rotate --current --save # this CLI's own token +``` + +A token that expires gets its lifetime again, from now (`--expires 90d` +or `never` to change it). Without `--grace` the old secret stops at once: +use that when it may have leaked. Using an old secret during its grace +period is logged, naming the token and the address, so you can find the +programs still to update. A restricted token may rotate itself and tokens +no wider than itself. `steward token list` shows when each token was last used, and from where. `steward token revoke ID` stops one at once. diff --git a/internal/api/types.go b/internal/api/types.go index c7a529a..9e69e49 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -30,12 +30,14 @@ type ErrorInfo struct { // Whoami describes the caller. type Whoami struct { - User *model.User `json:"user"` - Admin bool `json:"admin"` - Method string `json:"method"` - Permissions []string `json:"permissions"` - CSRFToken string `json:"csrf_token,omitempty"` - Build version.Build `json:"build"` + User *model.User `json:"user"` + Admin bool `json:"admin"` + Method string `json:"method"` + Permissions []string `json:"permissions"` + // TokenID is the token the caller authenticated with (0: none). + TokenID int64 `json:"token_id,omitempty"` + CSRFToken string `json:"csrf_token,omitempty"` + Build version.Build `json:"build"` } // Login is the login request. @@ -184,6 +186,15 @@ type TokenInput struct { ExpiresAt *time.Time `json:"expires_at,omitempty"` } +// TokenRotation gives a token a new secret. Grace keeps the old secret +// working for a while (up to 7d). The token gets its lifetime again, +// counted from now, unless ExpiresIn or NoExpiry says otherwise. +type TokenRotation struct { + Grace string `json:"grace,omitempty"` + ExpiresIn string `json:"expires_in,omitempty"` + NoExpiry bool `json:"no_expiry,omitempty"` +} + // Token is a token; Secret is only set when it is created. type Token struct { *model.Token diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8276e13..a9c5481 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -108,6 +108,12 @@ func NewToken() (token, publicID, secretHash string) { return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret) } +// RotateToken makes a new secret for a token's public ID. +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) diff --git a/internal/model/auth.go b/internal/model/auth.go index a333d04..7b0d184 100644 --- a/internal/model/auth.go +++ b/internal/model/auth.go @@ -43,6 +43,21 @@ 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: its last +// rotation, or its creation. +func (t *Token) IssuedAt() time.Time { + if t.RotatedAt != nil { + return *t.RotatedAt + } + return t.CreatedAt } // Session is a web UI login. diff --git a/internal/server/authn.go b/internal/server/authn.go index ceecbaa..e5e0144 100644 --- a/internal/server/authn.go +++ b/internal/server/authn.go @@ -150,13 +150,21 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au if err != nil { return nil, err } + now := time.Now() if !auth.CheckSecret(secret, t.SecretHash) { - return nil, errors.New("wrong token secret") + // 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") + } + s.log.Warn("token used with its old secret; give this client the new one before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339)) } if t.RevokedAt != nil { return nil, errors.New("the token was revoked") } - now := time.Now() if t.ExpiresAt != nil && now.After(*t.ExpiresAt) { return nil, errors.New("the token has expired") } @@ -271,6 +279,9 @@ func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami { if se != nil { out.CSRFToken = se.CSRFToken } + if p.Token != nil { + out.TokenID = p.Token.ID + } return out } diff --git a/internal/server/h_users.go b/internal/server/h_users.go index 188d29a..d8ac9c9 100644 --- a/internal/server/h_users.go +++ b/internal/server/h_users.go @@ -200,6 +200,86 @@ 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. It +// keeps its ID, name, user and grants, so only the programs that use it +// need the new secret. The old secret stops at once, or after the grace +// period. A token that expires gets its lifetime again, from now. +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 + if err := decodeOptional(r, &in); err != nil { + return err + } + t, err := s.store.GetToken(r.Context(), id) + if err != nil || 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 one: it would learn that token's secret. + if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !auth.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; make a new one", id) + } + now := time.Now() + var exp *time.Time + switch { + case in.ExpiresIn != "": + if in.NoExpiry { + return badRequest("no_expiry can't be combined with expires_in") + } + d, err := config.ParseDuration(in.ExpiresIn) + if err != nil || d <= 0 { + return badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn) + } + e := now.Add(d) + exp = &e + case in.NoExpiry: + case t.ExpiresAt != nil: + if life := t.ExpiresAt.Sub(t.IssuedAt()); life > 0 { + e := now.Add(life) + exp = &e + } + } + var prevUntil *time.Time + if in.Grace != "" { + g, err := config.ParseDuration(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 { + return err + } + if t, err = s.store.GetToken(r.Context(), id); err != nil { + return err + } + detail := "the old secret stops now" + if prevUntil != nil { + detail = "the old secret works until " + prevUntil.UTC().Format(time.RFC3339) + } + s.eng.Audit(r.Context(), p, "token.rotate", "token "+t.Username+"/"+t.Name, "success", detail) + out := presentToken(t) + out.Secret = secret + writeJSON(w, http.StatusOK, out) + return nil +} + func presentUser(u *model.User) api.User { return api.User{User: u, GrantStrings: grantStrings(u.Grants)} } diff --git a/internal/server/routes.go b/internal/server/routes.go index 34a17c1..536362e 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -28,6 +28,7 @@ func (s *Server) routes() { m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken)) m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens)) 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+"/users", s.authed(s.listUsers)) m.HandleFunc("POST "+p+"/users", s.authed(s.createUser)) m.HandleFunc("PATCH "+p+"/users/{id}", s.authed(s.updateUser)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 9f7f586..e5ee6fa 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -335,3 +335,48 @@ func TestImportKea(t *testing.T) { t.Errorf("import over a named subnet: %d %+v", code, third) } } + +func TestTokenRotation(t *testing.T) { + e := newEnv(t) + var tok api.Token + e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "script", ExpiresIn: "30d"}, &tok) + var rot api.Token + if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "1h"}, &rot); code != 200 || rot.Secret == "" || rot.Secret == tok.Secret || rot.PrevExpiresAt == nil { + t.Fatalf("rotate: %d %+v", code, rot) + } + if rot.ID != tok.ID || rot.Name != "script" || rot.ExpiresAt == nil || rot.ExpiresAt.Sub(*tok.ExpiresAt) < 0 { + t.Errorf("rotated token %+v", rot) + } + if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 200 { + t.Errorf("new secret: %d", code) + } + if code := e.call(tok.Secret, "GET", "/auth/whoami", nil, nil); code != 200 { + t.Errorf("old secret in its grace period: %d", code) + } + var me api.Whoami + e.call(rot.Secret, "GET", "/auth/whoami", nil, &me) + if me.TokenID != tok.ID { + t.Errorf("whoami token id %d, want %d", me.TokenID, tok.ID) + } + // Without a grace period the old secret stops at once. + var rot2 api.Token + e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, &rot2) + if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 401 { + t.Errorf("old secret without grace: %d", code) + } + if code := e.call(rot2.Secret, "GET", "/auth/whoami", nil, nil); code != 200 { + t.Errorf("newest secret: %d", code) + } + if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "30d"}, nil); code != 400 { + t.Errorf("a grace over 7 days: %d", code) + } + // A restricted token can't rotate a wider one. + var narrow api.Token + e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "narrow", Role: "leases"}, &narrow) + if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, nil); code != 403 { + t.Errorf("narrow token rotating a wider one: %d", code) + } + if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(narrow.ID)+"/rotate", nil, nil); code != 200 { + t.Errorf("a token rotating itself: %d", code) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 5deeee0..d29df9d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -187,6 +187,12 @@ CREATE TABLE audit ( detail TEXT NOT NULL DEFAULT '' ); CREATE INDEX audit_time ON audit (time); +`, + // 2: token rotation: a new secret, the old one working 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; `, } diff --git a/internal/store/users.go b/internal/store/users.go index 4041aee..3360c61 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -132,13 +132,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 { @@ -150,6 +150,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 } @@ -228,6 +229,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. 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) diff --git a/internal/web/static/assets/pages/account.js b/internal/web/static/assets/pages/account.js index 0b947f8..acde875 100644 --- a/internal/web/static/assets/pages/account.js +++ b/internal/web/static/assets/pages/account.js @@ -1,8 +1,8 @@ // Your account: password and API tokens. import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js"; -import { get, api, state, catalog } from "../api.js"; +import { get, api, state, catalog, can } from "../api.js"; import { rerender } from "../app.js"; -import { revoke } from "./users.js"; +import { revoke, rotateDialog } from "./users.js"; export async function render(el) { const me = state.me; @@ -36,13 +36,21 @@ export async function render(el) { { label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) }, { label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" }, { label: "Last used", render: (t) => t.last_used_at ? time(t.last_used_at) : "never" }, - { label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") }, + { label: "", tdClass: "actions-cell", render: (t) => [ + h("button", { class: "small reveal", onclick: () => rotateDialog(t) }, "Rotate"), " ", + h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] }, ], tokens, { empty: "No tokens." }))); } async function newToken(cat) { const name = input({ placeholder: "laptop, backup script…" }); - const role = select([["", "everything you may do"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], ""); + // Only roles within what the user may do: a token can never do more + // than its user, so offering more would only mislead. + const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => { + const [res, acc] = g.split(":"); + return can(res, acc === "write"); + })); + const role = select([["", "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], ""); const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d"); const err = h("div"); await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [ diff --git a/internal/web/static/assets/pages/settings.js b/internal/web/static/assets/pages/settings.js index 8256903..969c18d 100644 --- a/internal/web/static/assets/pages/settings.js +++ b/internal/web/static/assets/pages/settings.js @@ -1,6 +1,6 @@ // Settings: the default lease time, conflict checks, authority, and the // options every subnet gets. -import { h, mount, input, field, checkbox, errorBox } from "../ui.js"; +import { h, mount, input, field, checkbox, errorBox, readOnly } from "../ui.js"; import { get, change, can } from "../api.js"; import { optionsEditor, fmtSeconds, parseSeconds } from "../widgets.js"; import { rerender } from "../app.js"; @@ -27,6 +27,7 @@ export async function render(el) { } catch (e) { mount(err, errorBox(e)); } }; mount(el, + write ? null : h("div", { class: "msg info" }, "You can see the settings but not change them (that needs config:write)."), h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Settings and options"), h("div", { class: "muted" }, "Server-wide settings, and the options every subnet sends. A subnet's or a reservation's own option of the same name wins."))), h("div", { class: "panel" }, @@ -40,4 +41,5 @@ export async function render(el) { write ? h("div", { class: "actions", style: { marginTop: "14px" } }, h("button", { class: "primary", onclick: () => save(false) }, "Save"), h("button", { onclick: () => save(true), title: "Check without saving" }, "Check")) : null)); + if (!write) readOnly(el.querySelector(".panel")); } diff --git a/internal/web/static/assets/pages/subnet.js b/internal/web/static/assets/pages/subnet.js index f87a6f9..a77554f 100644 --- a/internal/web/static/assets/pages/subnet.js +++ b/internal/web/static/assets/pages/subnet.js @@ -1,5 +1,5 @@ // A subnet's page: its leases, its reservations and its settings. -import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce } from "../ui.js"; +import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly } from "../ui.js"; import { get, change, can } from "../api.js"; import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js"; import { leasesTable } from "./leases.js"; @@ -53,11 +53,11 @@ async function reservationsTab(el, s) { async function settingsTab(el, s) { const settings = await get("/settings"); + const form = await subnetForm(s, settings); if (!can("config", true)) { - mount(el, h("div", { class: "panel" }, h("pre", null, JSON.stringify(s, null, 2)))); + mount(el, h("div", { class: "msg info" }, "You can see the subnet's settings but not change them (that needs config:write)."), readOnly(h("div", { class: "panel" }, form))); return; } - const form = await subnetForm(s, settings); const err = h("div"); const save = async (dry) => { mount(err); diff --git a/internal/web/static/assets/pages/users.js b/internal/web/static/assets/pages/users.js index 9e279c9..254dc0f 100644 --- a/internal/web/static/assets/pages/users.js +++ b/internal/web/static/assets/pages/users.js @@ -1,5 +1,5 @@ // Users and everyone's tokens (administrators). -import { h, mount, table, time, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge } from "../ui.js"; +import { h, mount, table, time, fmtTime, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge, copyBox } from "../ui.js"; import { get, api, catalog } from "../api.js"; import { rerender } from "../app.js"; @@ -26,10 +26,46 @@ export async function render(el) { { label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) }, { label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" }, { label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), h("span", { class: "muted small" }, " from " + t.last_used_ip)] : "never" }, - { label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") }, + { label: "", tdClass: "actions-cell", render: (t) => [ + h("button", { class: "small reveal", onclick: () => rotateDialog(t) }, "Rotate"), " ", + h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] }, ], tokens, { empty: "No tokens." }))); } +// rotateDialog gives a token a new secret. It keeps its name, ID and +// permissions; only the programs that use it need the new secret. +export async function rotateDialog(t) { + 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 go = h("button", { class: "primary", onclick: async () => { + 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; + go.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.secret), + h("p", { class: "small" }, r.previous_secret_valid_until ? ["The old secret keeps working until ", fmtTime(r.previous_secret_valid_until), "."] : "The old secret no longer works."))); + rerender(); + } catch (ex) { go.disabled = false; mount(err, errorBox(ex)); } + } }, "Rotate"); + mount(box, + 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 the programs without an outage. If the token may have leaked, let it stop now."), + field("Expires", exp)), + err, h("div", { class: "actions mt" }, go)); + await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true }); +} + export async function revoke(t) { if (!await confirmDialog("Revoke token", "Revoke " + t.username + "'s token “" + t.name + "”? Anything using it is refused from now on.", "Revoke", "danger")) return; try { diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 846b499..834521c 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -351,3 +351,5 @@ table.activity td.detail { white-space: normal; color: var(--muted); } .type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); } .type-RELEASE { color: var(--muted); } td.mono, span.mono { font-family: var(--mono); font-size: 13px; } +.mt { margin-top: 10px; } +input:disabled, select:disabled, textarea:disabled { background: var(--info-soft); color: var(--muted); cursor: not-allowed; opacity: 1; } diff --git a/internal/web/static/assets/ui.js b/internal/web/static/assets/ui.js index 03fd4cf..4fac9de 100644 --- a/internal/web/static/assets/ui.js +++ b/internal/web/static/assets/ui.js @@ -226,3 +226,11 @@ export function backLink(label, href) { export function safeBack(v) { return v && /^#\/[A-Za-z0-9\/?=&%._~+-]*$/.test(v) ? v : null; } + +// readOnly locks a form for someone who may look but not change: every +// control is disabled, and the editors' add and remove buttons hidden. +export function readOnly(el) { + for (const c of el.querySelectorAll("input, select, textarea")) c.disabled = true; + for (const b of el.querySelectorAll("button")) b.classList.add("hidden"); + return el; +} diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 2d490d7..9a90e00 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -185,6 +185,14 @@ run "a token" 0 test -n "$TOKEN" run "CLI over HTTP with the token" 0 "$WORK/steward" --config "$WORK/cli.yaml" --server http://$NA.2:8067 --token "$TOKEN" lease list contains "laptop" run "without a token: refused" 3 "$WORK/steward" --config "$WORK/cli.yaml" --server http://$NA.2:8067 --token steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb status +H="$WORK/steward --config $WORK/cli.yaml --server http://$NA.2:8067" +NEW=$($H --token "$TOKEN" token rotate --current --grace 1h 2>/dev/null) +run "rotate the CLI's own token" 0 test -n "$NEW" +run "the new secret works" 0 $H --token "$NEW" whoami +run "the old one too, during its grace period" 0 $H --token "$TOKEN" whoami +NEWER=$($H --token "$NEW" token rotate --current 2>/dev/null) +run "rotated without grace: the old secret stops" 3 $H --token "$NEW" whoami +TOKEN=$NEWER LEASES=$(S token create readonly --user admin --role leases 2>/dev/null) run "a leases-only token can't change subnets" 3 "$WORK/steward" --config "$WORK/cli.yaml" --server http://$NA.2:8067 --token "$LEASES" subnet disable office run "delete a lease" 0 S lease delete $NA.101 --yes