internal/control/token.go
169 lines · 5127 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
53func runTokenCreate(c *Ctx, args []string) int {
54 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
55 if err != nil {
56 return c.fail(protocol.ExitUsage, "%v", err)
57 }
58 name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")
59 if f.Has("--scope") {
60 scope = f.Value("--scope")
61 }
62 if name == "" || (scope != "full" && scope != "read") {
63 return c.usage()
64 }
65 var expires *time.Time
66 if ttl != "" {
67 d, err := parseTTL(ttl)
68 if err != nil {
69 return c.failInput(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, c.TokenID); err != nil {
81 return c.failErr(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 CreatedBy string `json:"created_by,omitempty"`
106 }
107 var ds []out
108 for _, t := range tokens {
109 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy})
110 }
111 return c.emit(ds, func(w io.Writer) {
112 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
113 for _, d := range ds {
114 exp := "never expires"
115 if d.ExpiresAt != nil {
116 ts := d.ExpiresAt.UTC().Format(time.RFC3339Nano)
117 if c.Term.Cols == 0 {
118 exp = "expires " + stamp(ts)
119 } else {
120 exp = "expires " + relAge(ts, termNow())
121 }
122 }
123 tb.row(cRef(d.Name), cState(d.Scope), cText(exp))
124 }
125 tb.flush()
126 })
127}
128
129func runTokenRevoke(c *Ctx, args []string) int {
130 f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
131 if err != nil {
132 return c.fail(protocol.ExitUsage, "%v", err)
133 }
134 name := f.pos(0)
135 if name == "" {
136 return c.usage()
137 }
138 withCreated := f.Has("--created")
139 created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
140 if err != nil {
141 if errors.Is(err, store.ErrNotFound) {
142 return c.fail(protocol.ExitNotFound, "no token named %q", name)
143 }
144 return c.fail(protocol.ExitFailure, "%v", err)
145 }
146 type out struct {
147 Revoked string `json:"revoked"`
148 Created store.Created `json:"created"`
149 CreatedRevoked bool `json:"created_revoked"`
150 }
151 d := out{name, created, withCreated}
152 return c.emit(d, func(w io.Writer) {
153 fmt.Fprintf(w, "revoked %s\n", name)
154 if len(created.Tokens)+len(created.Keys) == 0 {
155 return
156 }
157 if withCreated {
158 fmt.Fprintln(w, "and what it created:")
159 } else {
160 fmt.Fprintln(w, "it created these, still in place:")
161 }
162 for _, n := range created.Tokens {
163 fmt.Fprintf(w, " token %s\n", n)
164 }
165 for _, fp := range created.Keys {
166 fmt.Fprintf(w, " key %s\n", fp)
167 }
168 })
169}