internal/control/token.go

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

169 lines · 5127 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
 53func runTokenCreate(c *Ctx, args []string) int {
 54	f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
 55	if err != nil {
 56		return c.fail(protocol.ExitUsage, "%v", err)
 57	}
 58	name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")
 59	if f.Has("--scope") {
 60		scope = f.Value("--scope")
 61	}
 62	if name == "" || (scope != "full" && scope != "read") {
 63		return c.usage()
 64	}
 65	var expires *time.Time
 66	if ttl != "" {
 67		d, err := parseTTL(ttl)
 68		if err != nil {
 69			return c.failInput(err)
 70		}
 71		t := time.Now().Add(d)
 72		expires = &t
 73	}
 74	raw, _, err := store.NewToken()
 75	if err != nil {
 76		return c.fail(protocol.ExitFailure, "%v", err)
 77	}
 78	// The gb_ prefix makes leaked tokens findable by secret scanners.
 79	token := "gb_" + raw
 80	if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil {
 81		return c.failErr(err)
 82	}
 83	type out struct {
 84		Name  string `json:"name"`
 85		Scope string `json:"scope"`
 86		Token string `json:"token"`
 87	}
 88	d := out{name, scope, token}
 89	return c.emit(d, func(w io.Writer) {
 90		fmt.Fprintf(w, "token %q (%s) — shown once, store it now:\n%s\n", d.Name, d.Scope, d.Token)
 91	})
 92}
 93
 94func runTokenList(c *Ctx, args []string) int {
 95	tokens, err := c.Store.ListAPITokens(c.User.ID)
 96	if err != nil {
 97		return c.fail(protocol.ExitFailure, "%v", err)
 98	}
 99	type out struct {
100		Name       string     `json:"name"`
101		Scope      string     `json:"scope"`
102		CreatedAt  string     `json:"created_at"`
103		ExpiresAt  *time.Time `json:"expires_at,omitempty"`
104		LastUsedAt *time.Time `json:"last_used_at,omitempty"`
105		CreatedBy  string     `json:"created_by,omitempty"`
106	}
107	var ds []out
108	for _, t := range tokens {
109		ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy})
110	}
111	return c.emit(ds, func(w io.Writer) {
112		tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
113		for _, d := range ds {
114			exp := "never expires"
115			if d.ExpiresAt != nil {
116				ts := d.ExpiresAt.UTC().Format(time.RFC3339Nano)
117				if c.Term.Cols == 0 {
118					exp = "expires " + stamp(ts)
119				} else {
120					exp = "expires " + relAge(ts, termNow())
121				}
122			}
123			tb.row(cRef(d.Name), cState(d.Scope), cText(exp))
124		}
125		tb.flush()
126	})
127}
128
129func runTokenRevoke(c *Ctx, args []string) int {
130	f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
131	if err != nil {
132		return c.fail(protocol.ExitUsage, "%v", err)
133	}
134	name := f.pos(0)
135	if name == "" {
136		return c.usage()
137	}
138	withCreated := f.Has("--created")
139	created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
140	if err != nil {
141		if errors.Is(err, store.ErrNotFound) {
142			return c.fail(protocol.ExitNotFound, "no token named %q", name)
143		}
144		return c.fail(protocol.ExitFailure, "%v", err)
145	}
146	type out struct {
147		Revoked        string        `json:"revoked"`
148		Created        store.Created `json:"created"`
149		CreatedRevoked bool          `json:"created_revoked"`
150	}
151	d := out{name, created, withCreated}
152	return c.emit(d, func(w io.Writer) {
153		fmt.Fprintf(w, "revoked %s\n", name)
154		if len(created.Tokens)+len(created.Keys) == 0 {
155			return
156		}
157		if withCreated {
158			fmt.Fprintln(w, "and what it created:")
159		} else {
160			fmt.Fprintln(w, "it created these, still in place:")
161		}
162		for _, n := range created.Tokens {
163			fmt.Fprintf(w, "  token %s\n", n)
164		}
165		for _, fp := range created.Keys {
166			fmt.Fprintf(w, "  key %s\n", fp)
167		}
168	})
169}