internal/control/token.go
137 lines · 4043 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 Flags: []Flag{
20 {"--name", "<n>", "the token's name", ""},
21 {"--scope", "full|read", "what the token may do", "full"},
22 {"--ttl", "30d|720h", "how long the token is valid", "never expires"},
23 },
24 Examples: []string{"token create --name laptop --scope read --ttl 30d"},
25 Run: runTokenCreate})
26 register(Command{Path: []string{"token", "list"},
27 Summary: "list API tokens",
28 Usage: "token list",
29 Examples: []string{"token list"}, ReadOnly: true, Run: runTokenList})
30 register(Command{Path: []string{"token", "revoke"},
31 Summary: "revoke an API token by name",
32 Usage: "token revoke <name>",
33 Examples: []string{"token revoke laptop"},
34 Run: runTokenRevoke})
35}
36
37// parseTTL accepts Go durations plus a day suffix ("30d").
38func parseTTL(s string) (time.Duration, error) {
39 if days, ok := strings.CutSuffix(s, "d"); ok {
40 n, err := strconv.Atoi(days)
41 if err != nil || n < 1 {
42 return 0, fmt.Errorf("bad ttl %q", s)
43 }
44 return time.Duration(n) * 24 * time.Hour, nil
45 }
46 return time.ParseDuration(s)
47}
48
49func runTokenCreate(c *Ctx, args []string) int {
50 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: "token create --name <n> [--scope full|read] [--ttl 30d]"})
51 if err != nil {
52 return c.fail(protocol.ExitUsage, "%v", err)
53 }
54 name, scope, ttl := f.Value("--name"), "full", f.Value("--ttl")
55 if f.Has("--scope") {
56 scope = f.Value("--scope")
57 }
58 if name == "" || (scope != "full" && scope != "read") {
59 return c.usage()
60 }
61 var expires *time.Time
62 if ttl != "" {
63 d, err := parseTTL(ttl)
64 if err != nil {
65 return c.failInput(err)
66 }
67 t := time.Now().Add(d)
68 expires = &t
69 }
70 raw, _, err := store.NewToken()
71 if err != nil {
72 return c.fail(protocol.ExitFailure, "%v", err)
73 }
74 // The gb_ prefix makes leaked tokens findable by secret scanners.
75 token := "gb_" + raw
76 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil {
77 return c.failErr(err)
78 }
79 type out struct {
80 Name string `json:"name"`
81 Scope string `json:"scope"`
82 Token string `json:"token"`
83 }
84 d := out{name, scope, token}
85 return c.emit(d, func(w io.Writer) {
86 fmt.Fprintf(w, "token %q (%s) — shown once, store it now:\n%s\n", d.Name, d.Scope, d.Token)
87 })
88}
89
90func runTokenList(c *Ctx, args []string) int {
91 tokens, err := c.Store.ListAPITokens(c.User.ID)
92 if err != nil {
93 return c.fail(protocol.ExitFailure, "%v", err)
94 }
95 type out struct {
96 Name string `json:"name"`
97 Scope string `json:"scope"`
98 CreatedAt string `json:"created_at"`
99 ExpiresAt *time.Time `json:"expires_at,omitempty"`
100 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
101 }
102 var ds []out
103 for _, t := range tokens {
104 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
105 }
106 return c.emit(ds, func(w io.Writer) {
107 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
108 for _, d := range ds {
109 exp := "never expires"
110 if d.ExpiresAt != nil {
111 ts := d.ExpiresAt.UTC().Format(time.RFC3339Nano)
112 if c.Term.Cols == 0 {
113 exp = "expires " + stamp(ts)
114 } else {
115 exp = "expires " + relAge(ts, termNow())
116 }
117 }
118 tb.row(cRef(d.Name), cState(d.Scope), cText(exp))
119 }
120 tb.flush()
121 })
122}
123
124func runTokenRevoke(c *Ctx, args []string) int {
125 if len(args) != 1 {
126 return c.usage()
127 }
128 if err := c.Store.RevokeAPIToken(c.User.ID, args[0]); err != nil {
129 if errors.Is(err, store.ErrNotFound) {
130 return c.fail(protocol.ExitNotFound, "no token named %q", args[0])
131 }
132 return c.fail(protocol.ExitFailure, "%v", err)
133 }
134 return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) {
135 fmt.Fprintf(w, "revoked %s\n", args[0])
136 })
137}