internal/control/token.go
183 lines · 5551 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 := parseFlags(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, ttl := f.Value("--name"), "read", f.Value("--ttl")
73 if f.Has("--scope") {
74 scope = f.Value("--scope")
75 }
76 if name == "" || (scope != "full" && scope != "read") {
77 return c.usage()
78 }
79 var expires *time.Time
80 if ttl != "" {
81 d, err := parseTTL(ttl)
82 if err != nil {
83 return c.failInput(err)
84 }
85 t := time.Now().Add(d)
86 expires = &t
87 }
88 raw, _, err := store.NewToken()
89 if err != nil {
90 return c.fail(protocol.ExitFailure, "%v", err)
91 }
92 // The gb_ prefix makes leaked tokens findable by secret scanners.
93 token := "gb_" + raw
94 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil {
95 return c.failErr(err)
96 }
97 type out struct {
98 Name string `json:"name"`
99 Scope string `json:"scope"`
100 Token string `json:"token"`
101 }
102 d := out{name, scope, token}
103 return c.emit(d, func(w io.Writer) {
104 fmt.Fprintf(w, "token %q (%s) — shown once, store it now:\n%s\n", d.Name, d.Scope, d.Token)
105 })
106}
107
108func runTokenList(c *Ctx, args []string) int {
109 tokens, err := c.Store.ListAPITokens(c.User.ID)
110 if err != nil {
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 type out struct {
114 Name string `json:"name"`
115 Scope string `json:"scope"`
116 CreatedAt string `json:"created_at"`
117 ExpiresAt *time.Time `json:"expires_at,omitempty"`
118 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
119 CreatedBy string `json:"created_by,omitempty"`
120 }
121 var ds []out
122 for _, t := range tokens {
123 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy})
124 }
125 return c.emit(ds, func(w io.Writer) {
126 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
127 for _, d := range ds {
128 exp := "never expires"
129 if d.ExpiresAt != nil {
130 ts := d.ExpiresAt.UTC().Format(time.RFC3339Nano)
131 if c.Term.Cols == 0 {
132 exp = "expires " + stamp(ts)
133 } else {
134 exp = "expires " + relAge(ts, termNow())
135 }
136 }
137 tb.row(cRef(d.Name), cState(d.Scope), cText(exp))
138 }
139 tb.flush()
140 })
141}
142
143func runTokenRevoke(c *Ctx, args []string) int {
144 f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
145 if err != nil {
146 return c.fail(protocol.ExitUsage, "%v", err)
147 }
148 name := f.pos(0)
149 if name == "" {
150 return c.usage()
151 }
152 withCreated := f.Has("--created")
153 created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
154 if err != nil {
155 if errors.Is(err, store.ErrNotFound) {
156 return c.fail(protocol.ExitNotFound, "no token named %q", name)
157 }
158 return c.fail(protocol.ExitFailure, "%v", err)
159 }
160 type out struct {
161 Revoked string `json:"revoked"`
162 Created store.Created `json:"created"`
163 CreatedRevoked bool `json:"created_revoked"`
164 }
165 d := out{name, created, withCreated}
166 return c.emit(d, func(w io.Writer) {
167 fmt.Fprintf(w, "revoked %s\n", name)
168 if len(created.Tokens)+len(created.Keys) == 0 {
169 return
170 }
171 if withCreated {
172 fmt.Fprintln(w, "and what it created:")
173 } else {
174 fmt.Fprintln(w, "it created these, still in place:")
175 }
176 for _, n := range created.Tokens {
177 fmt.Fprintf(w, " token %s\n", n)
178 }
179 for _, fp := range created.Keys {
180 fmt.Fprintf(w, " key %s\n", fp)
181 }
182 })
183}