internal/control/token.go

5460cca8a555d6d23646aebf992a3bdd82c8fee3
gitbay/internal/control/token.go history · blame · raw

134 lines · 3861 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 full|read] [--ttl 30d|720h]",
 19		SSHOnly: true, Run: runTokenCreate})
 20	register(Command{Path: []string{"token", "list"},
 21		Summary: "list API tokens",
 22		Usage:   "token list", ReadOnly: true, SSHOnly: true, Run: runTokenList})
 23	register(Command{Path: []string{"token", "revoke"},
 24		Summary: "revoke an API token by name",
 25		Usage:   "token revoke <name>",
 26		SSHOnly: true, Run: runTokenRevoke})
 27}
 28
 29// parseTTL accepts Go durations plus a day suffix ("30d").
 30func parseTTL(s string) (time.Duration, error) {
 31	if days, ok := strings.CutSuffix(s, "d"); ok {
 32		n, err := strconv.Atoi(days)
 33		if err != nil || n < 1 {
 34			return 0, fmt.Errorf("bad ttl %q", s)
 35		}
 36		return time.Duration(n) * 24 * time.Hour, nil
 37	}
 38	return time.ParseDuration(s)
 39}
 40
 41func runTokenCreate(c *Ctx, args []string) int {
 42	name, scope, ttl := "", "full", ""
 43	for i := 0; i < len(args); i++ {
 44		switch args[i] {
 45		case "--name", "--scope", "--ttl":
 46			if i+1 >= len(args) {
 47				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
 48			}
 49			switch args[i] {
 50			case "--name":
 51				name = args[i+1]
 52			case "--scope":
 53				scope = args[i+1]
 54			case "--ttl":
 55				ttl = args[i+1]
 56			}
 57			i++
 58		default:
 59			return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]")
 60		}
 61	}
 62	if name == "" || (scope != "full" && scope != "read") {
 63		return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]")
 64	}
 65	var expires *time.Time
 66	if ttl != "" {
 67		d, err := parseTTL(ttl)
 68		if err != nil {
 69			return c.fail(protocol.ExitUsage, "%v", 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); err != nil {
 81		return c.fail(protocol.ExitUsage, "%v", 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	}
106	var ds []out
107	for _, t := range tokens {
108		ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
109	}
110	return c.emit(ds, func(w io.Writer) {
111		for _, d := range ds {
112			exp := "never expires"
113			if d.ExpiresAt != nil {
114				exp = "expires " + d.ExpiresAt.UTC().Format(time.RFC3339)
115			}
116			fmt.Fprintf(w, "%s\t%s\t%s\n", d.Name, d.Scope, exp)
117		}
118	})
119}
120
121func runTokenRevoke(c *Ctx, args []string) int {
122	if len(args) != 1 {
123		return c.fail(protocol.ExitUsage, "usage: token revoke <name>")
124	}
125	if err := c.Store.RevokeAPIToken(c.User.ID, args[0]); err != nil {
126		if errors.Is(err, store.ErrNotFound) {
127			return c.fail(protocol.ExitNotFound, "no token named %q", args[0])
128		}
129		return c.fail(protocol.ExitFailure, "%v", err)
130	}
131	return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) {
132		fmt.Fprintf(w, "revoked %s\n", args[0])
133	})
134}