internal/control/token.go
122 lines · 3669 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 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: "token create --name <n> [--scope full|read] [--ttl 30d]"})
43 if err != nil {
44 return c.fail(protocol.ExitUsage, "%v", err)
45 }
46 name, scope, ttl := f.Value("--name"), "full", f.Value("--ttl")
47 if f.Has("--scope") {
48 scope = f.Value("--scope")
49 }
50 if name == "" || (scope != "full" && scope != "read") {
51 return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]")
52 }
53 var expires *time.Time
54 if ttl != "" {
55 d, err := parseTTL(ttl)
56 if err != nil {
57 return c.failErr(err)
58 }
59 t := time.Now().Add(d)
60 expires = &t
61 }
62 raw, _, err := store.NewToken()
63 if err != nil {
64 return c.fail(protocol.ExitFailure, "%v", err)
65 }
66 // The gb_ prefix makes leaked tokens findable by secret scanners.
67 token := "gb_" + raw
68 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil {
69 return c.failErr(err)
70 }
71 type out struct {
72 Name string `json:"name"`
73 Scope string `json:"scope"`
74 Token string `json:"token"`
75 }
76 d := out{name, scope, token}
77 return c.emit(d, func(w io.Writer) {
78 fmt.Fprintf(w, "token %q (%s) — shown once, store it now:\n%s\n", d.Name, d.Scope, d.Token)
79 })
80}
81
82func runTokenList(c *Ctx, args []string) int {
83 tokens, err := c.Store.ListAPITokens(c.User.ID)
84 if err != nil {
85 return c.fail(protocol.ExitFailure, "%v", err)
86 }
87 type out struct {
88 Name string `json:"name"`
89 Scope string `json:"scope"`
90 CreatedAt string `json:"created_at"`
91 ExpiresAt *time.Time `json:"expires_at,omitempty"`
92 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
93 }
94 var ds []out
95 for _, t := range tokens {
96 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
97 }
98 return c.emit(ds, func(w io.Writer) {
99 for _, d := range ds {
100 exp := "never expires"
101 if d.ExpiresAt != nil {
102 exp = "expires " + d.ExpiresAt.UTC().Format(time.RFC3339)
103 }
104 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Name, d.Scope, exp)
105 }
106 })
107}
108
109func runTokenRevoke(c *Ctx, args []string) int {
110 if len(args) != 1 {
111 return c.fail(protocol.ExitUsage, "usage: token revoke <name>")
112 }
113 if err := c.Store.RevokeAPIToken(c.User.ID, args[0]); err != nil {
114 if errors.Is(err, store.ErrNotFound) {
115 return c.fail(protocol.ExitNotFound, "no token named %q", args[0])
116 }
117 return c.fail(protocol.ExitFailure, "%v", err)
118 }
119 return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) {
120 fmt.Fprintf(w, "revoked %s\n", args[0])
121 })
122}