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