internal/control/token.go
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}