internal/control/token.go
186 lines · 5729 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, NeedsRecentSignIn: 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.emitView(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(c.expiresText(d.ExpiresAt, now)))
125 }
126 tb.flush()
127 }, func() screen {
128 rows := make([]row, len(ds))
129 for i, d := range ds {
130 lead := cGlyph("")
131 if d.ExpiresAt != nil && !d.ExpiresAt.After(now) {
132 lead = cGlyph("failed")
133 }
134 by := ""
135 if d.CreatedBy != "" {
136 by = "by " + d.CreatedBy
137 }
138 rows[i] = rowOf(cRef(d.Name), lead, cState(d.Scope), cMeta(by, c.expiresText(d.ExpiresAt, now)))
139 }
140 return listScreen("API tokens", rows,
141 action{"Tokens", []string{"token", "revoke", "<name>"}},
142 )
143 })
144}
145
146func runTokenRevoke(c *Ctx, args []string) int {
147 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
148 if err != nil {
149 return c.fail(protocol.ExitUsage, "%v", err)
150 }
151 name := f.pos(0)
152 if name == "" {
153 return c.usage()
154 }
155 withCreated := f.Has("--created")
156 created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
157 if err != nil {
158 if errors.Is(err, store.ErrNotFound) {
159 return c.fail(protocol.ExitNotFound, "no token named %q", name)
160 }
161 return c.fail(protocol.ExitFailure, "%v", err)
162 }
163 type out struct {
164 Revoked string `json:"revoked"`
165 Created store.Created `json:"created"`
166 CreatedRevoked bool `json:"created_revoked"`
167 }
168 d := out{name, created, withCreated}
169 return c.emit(d, func(w io.Writer) {
170 fmt.Fprintf(w, "revoked %s\n", name)
171 if len(created.Tokens)+len(created.Keys) == 0 {
172 return
173 }
174 if withCreated {
175 fmt.Fprintln(w, "and what it created:")
176 } else {
177 fmt.Fprintln(w, "it created these, still in place:")
178 }
179 for _, n := range created.Tokens {
180 fmt.Fprintf(w, " token %s\n", n)
181 }
182 for _, fp := range created.Keys {
183 fmt.Fprintf(w, " key %s\n", fp)
184 }
185 })
186}