internal/control/token.go

ba0a7d33f3a65ce53aafb074fda1682cf1cecfdf
gitbay/internal/control/token.go history · blame · raw

170 lines · 5245 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"gitbay.org/gitbay/internal/protocol"
 12	"gitbay.org/gitbay/internal/store"
 13)
 14
 15func init() {
 16	register(Command{Path: []string{"token", "create"},
 17		Summary: "mint an API token (shown once)",
 18		Usage:   "token create --name <n> [--scope read|full] [--ttl 30d|720h]",
 19		Flags: []Flag{
 20			{"--name", "<n>", "the token's name", ""},
 21			{"--scope", "read|full", "what the token may do; full is needed to change anything", "read"},
 22			{"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"},
 23		},
 24		Examples:        []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
 25		MintsCredential: true,
 26		Run:             runTokenCreate})
 27	register(Command{Path: []string{"token", "list"},
 28		Summary:  "list API tokens",
 29		Usage:    "token list",
 30		Examples: []string{"token list"}, ReadOnly: true, Run: runTokenList})
 31	register(Command{Path: []string{"token", "revoke"},
 32		Summary: "revoke an API token by name",
 33		Usage:   "token revoke <name> [--created]",
 34		Flags: []Flag{
 35			{"--created", "", "also revoke the tokens and keys it created, at any depth", ""},
 36		},
 37		Examples: []string{"token revoke laptop", "token revoke laptop --created"},
 38		Run:      runTokenRevoke})
 39}
 40
 41// parseTTL accepts Go durations plus a day suffix ("30d").
 42func parseTTL(s string) (time.Duration, error) {
 43	if days, ok := strings.CutSuffix(s, "d"); ok {
 44		n, err := strconv.Atoi(days)
 45		if err != nil || n < 1 {
 46			return 0, fmt.Errorf("bad ttl %q", s)
 47		}
 48		return time.Duration(n) * 24 * time.Hour, nil
 49	}
 50	return time.ParseDuration(s)
 51}
 52
 53// ttlFlag reads --ttl as an expiry; nil when the flag is absent. The
 54// code is -1 when the caller may go on.
 55func (c *Ctx) ttlFlag(f flags) (*time.Time, int) {
 56	if !f.Has("--ttl") {
 57		return nil, -1
 58	}
 59	d, err := parseTTL(f.Value("--ttl"))
 60	if err != nil || d <= 0 {
 61		return nil, c.fail(protocol.ExitUsage, "bad ttl %q: give a duration such as 30d or 720h", f.Value("--ttl"))
 62	}
 63	t := time.Now().Add(d)
 64	return &t, -1
 65}
 66
 67func runTokenCreate(c *Ctx, args []string) int {
 68	f, err := c.parseArgs(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
 69	if err != nil {
 70		return c.fail(protocol.ExitUsage, "%v", err)
 71	}
 72	name, scope := f.Value("--name"), "read"
 73	if f.Has("--scope") {
 74		scope = f.Value("--scope")
 75	}
 76	if name == "" || (scope != "full" && scope != "read") {
 77		return c.usage()
 78	}
 79	expires, code := c.ttlFlag(f)
 80	if code >= 0 {
 81		return code
 82	}
 83	raw, _, err := store.NewToken()
 84	if err != nil {
 85		return c.fail(protocol.ExitFailure, "%v", err)
 86	}
 87	// The gb_ prefix makes leaked tokens findable by secret scanners.
 88	token := "gb_" + raw
 89	if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil {
 90		return c.failErr(err)
 91	}
 92	type out struct {
 93		Name  string `json:"name"`
 94		Scope string `json:"scope"`
 95		Token string `json:"token"`
 96	}
 97	d := out{name, scope, token}
 98	return c.emit(d, func(w io.Writer) {
 99		fmt.Fprintf(w, "token %q (%s) — shown once, store it now:\n%s\n", d.Name, d.Scope, d.Token)
100	})
101}
102
103func runTokenList(c *Ctx, args []string) int {
104	tokens, err := c.Store.ListAPITokens(c.User.ID)
105	if err != nil {
106		return c.fail(protocol.ExitFailure, "%v", err)
107	}
108	type out struct {
109		Name       string     `json:"name"`
110		Scope      string     `json:"scope"`
111		CreatedAt  string     `json:"created_at"`
112		ExpiresAt  *time.Time `json:"expires_at,omitempty"`
113		LastUsedAt *time.Time `json:"last_used_at,omitempty"`
114		CreatedBy  string     `json:"created_by,omitempty"`
115	}
116	var ds []out
117	for _, t := range tokens {
118		ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy})
119	}
120	now := time.Now()
121	return c.emit(ds, func(w io.Writer) {
122		tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
123		for _, d := range ds {
124			tb.row(cRef(d.Name), cState(d.Scope), cText(expiresText(d.ExpiresAt, now)))
125		}
126		tb.flush()
127	})
128}
129
130func runTokenRevoke(c *Ctx, args []string) int {
131	f, err := c.parseArgs(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
132	if err != nil {
133		return c.fail(protocol.ExitUsage, "%v", err)
134	}
135	name := f.pos(0)
136	if name == "" {
137		return c.usage()
138	}
139	withCreated := f.Has("--created")
140	created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
141	if err != nil {
142		if errors.Is(err, store.ErrNotFound) {
143			return c.fail(protocol.ExitNotFound, "no token named %q", name)
144		}
145		return c.fail(protocol.ExitFailure, "%v", err)
146	}
147	type out struct {
148		Revoked        string        `json:"revoked"`
149		Created        store.Created `json:"created"`
150		CreatedRevoked bool          `json:"created_revoked"`
151	}
152	d := out{name, created, withCreated}
153	return c.emit(d, func(w io.Writer) {
154		fmt.Fprintf(w, "revoked %s\n", name)
155		if len(created.Tokens)+len(created.Keys) == 0 {
156			return
157		}
158		if withCreated {
159			fmt.Fprintln(w, "and what it created:")
160		} else {
161			fmt.Fprintln(w, "it created these, still in place:")
162		}
163		for _, n := range created.Tokens {
164			fmt.Fprintf(w, "  token %s\n", n)
165		}
166		for _, fp := range created.Keys {
167			fmt.Fprintf(w, "  key %s\n", fp)
168		}
169	})
170}