Commit f4b0c29240

f4b0c29240c39e4e0985890ba0c962ac065ba5a5

parent: 7d1aad238f

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 22:38 UTC

web: Settings → API tokens: create, list, revoke

Ref #264

Layout: unified · split

internal/httpd/account.go +70 −1
@@ -45,6 +45,16 @@ type accountDevice struct {
4545 Confirm string // the id as text, typed back to confirm removal
4646}
4747
48// accountToken is one API token as the settings page shows it: never
49// the token itself, only what identifies and describes it.
50type accountToken struct {
51 Name string
52 Scope string
53 Created string
54 Expires string // "never" or a formatted timestamp
55 LastUsed string // "never" or a formatted timestamp
56}
57
4858// accountForm renders the account's own settings: keys, addresses, and the
4959// commands for everything that stays on SSH.
5060func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -53,6 +63,12 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
5363
5464// accountPage renders the settings page.
5565func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User) {
66 s.renderAccount(w, r, u, "")
67}
68
69// renderAccount draws the settings page. tokenShown is a token minted
70// by the request being answered; it is shown in this response only.
71func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.User, tokenShown string) {
5672 var keys []accountKey
5773 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
5874 for _, k := range list {
@@ -90,6 +106,20 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
90106 }
91107 }
92108
109 var tokens []accountToken
110 if list, err := s.st.ListAPITokens(u.ID); err == nil {
111 for _, tk := range list {
112 expires, lastUsed := "never", "never"
113 if tk.ExpiresAt != nil {
114 expires = tk.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
115 }
116 if tk.LastUsedAt != nil {
117 lastUsed = tk.LastUsedAt.UTC().Format("2006-01-02 15:04 UTC")
118 }
119 tokens = append(tokens, accountToken{tk.Name, tk.Scope, tk.CreatedAt, expires, lastUsed})
120 }
121 }
122
93123 // The about text is a file. The page points at it rather than editing
94124 // it: the repository's own editor already does that job.
95125 aboutRepo := u.Username + "/" + control.ProfileRepoName
@@ -116,9 +146,12 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
116146 PushOn bool
117147 Devices []accountDevice
118148 ThemeSetting string // system, light or dark: the form's selected option
149 Tokens []accountToken
150 TokenShown string // a token minted by this request, shown once
119151 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
120152 aboutRepo, aboutEdit, s.cfg.SiteHost(),
121 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme})
153 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme,
154 tokens, tokenShown})
122155}
123156
124157// accountExport hands the browser the same bundle `account export`
@@ -263,6 +296,42 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
263296 return
264297 }
265298 back("", "primary address changed")
299 case "token-create":
300 name := strings.TrimSpace(r.FormValue("name"))
301 if name == "" {
302 back("name the token", "")
303 return
304 }
305 scope := r.FormValue("scope")
306 if scope != "full" {
307 scope = "read"
308 }
309 argv := []string{"token", "create", "--name", name, "--scope", scope}
310 if ttl := strings.TrimSpace(r.FormValue("ttl")); ttl != "" {
311 argv = append(argv, "--ttl", ttl)
312 }
313 var minted struct {
314 Token string `json:"token"`
315 }
316 if msg, ok := s.runControlInto(u, argv, &minted); !ok {
317 back(msg, "")
318 return
319 }
320 // The token is shown in this response and nowhere else: not in a
321 // redirect, a URL or a cookie, and never stored to be shown later.
322 w.Header().Set("Cache-Control", "no-store")
323 s.renderAccount(w, r, u, minted.Token)
324 case "token-revoke":
325 name := r.FormValue("name")
326 if ok, msg := confirmed(r, name); !ok {
327 back(msg, "")
328 return
329 }
330 if _, msg, ok := s.runControl(u, []string{"token", "revoke", "--", name}); !ok {
331 back(msg, "")
332 return
333 }
334 back("", "token revoked")
266335 case "theme":
267336 if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
268337 back(msg, "")
internal/httpd/account_test.go +213
@@ -284,3 +284,216 @@ func TestWatchToggleCyclesThroughMuted(t *testing.T) {
284284 }
285285 assertAudited(t, st, "cmd repo unwatch")
286286}
287
288// newTokenTestServer is a server over a fresh store with one user.
289func newTokenTestServer(t *testing.T) (*Server, *store.Store, store.User) {
290 t.Helper()
291 st, err := store.Open(":memory:")
292 if err != nil {
293 t.Fatal(err)
294 }
295 t.Cleanup(func() { st.Close() })
296 if err := st.MigrateUp(); err != nil {
297 t.Fatal(err)
298 }
299 uid, err := st.CreateUser("alice", false)
300 if err != nil {
301 t.Fatal(err)
302 }
303 return New(config.Default(), st), st, store.User{ID: uid, Username: "alice"}
304}
305
306// The settings page lists a user's API tokens with scope and expiry,
307// never the hash (#264).
308func TestAccountPageListsTokens(t *testing.T) {
309 s, st, u := newTokenTestServer(t)
310 if err := st.CreateAPIToken(u.ID, "laptop", "somehash", "read", nil, 0); err != nil {
311 t.Fatal(err)
312 }
313 rr := httptest.NewRecorder()
314 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), u)
315 body := rr.Body.String()
316 if !strings.Contains(body, "<td>laptop</td>") || !strings.Contains(body, "<td>read</td>") {
317 t.Fatalf("token row missing: %s", body)
318 }
319 if strings.Contains(body, "somehash") {
320 t.Fatal("the page printed a token hash")
321 }
322}
323
324// Creating a token answers the POST itself with the token, marked
325// no-store, and puts it in no header: not a Location, not a cookie. A
326// later GET of the page does not show it (#264).
327func TestAccountSubmitTokenCreateShownOnce(t *testing.T) {
328 s, st, u := newTokenTestServer(t)
329 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}, "scope": {"full"}})
330 if rr.Code != http.StatusOK {
331 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
332 }
333 if got := rr.Header().Get("Cache-Control"); got != "no-store" {
334 t.Errorf("Cache-Control = %q, want no-store", got)
335 }
336 body := rr.Body.String()
337 i := strings.Index(body, "gb_")
338 if i < 0 {
339 t.Fatalf("token not shown: %s", body)
340 }
341 token := body[i:]
342 token = token[:strings.IndexAny(token, "<\n")]
343 for name, vals := range rr.Header() {
344 for _, v := range vals {
345 if strings.Contains(v, token) {
346 t.Errorf("header %s carries the token", name)
347 }
348 }
349 }
350 got, tk, err := st.APITokenUser(store.HashToken(token))
351 if err != nil || got.ID != u.ID || tk.Name != "laptop" || tk.Scope != "full" {
352 t.Fatalf("shown token does not resolve: %+v %+v %v", got, tk, err)
353 }
354
355 rr = httptest.NewRecorder()
356 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), u)
357 if strings.Contains(rr.Body.String(), token) {
358 t.Fatal("a later GET showed the token")
359 }
360 if !strings.Contains(rr.Body.String(), "<td>laptop</td>") {
361 t.Fatal("the new token is not listed")
362 }
363}
364
365// The form sends --scope explicitly, read unless full was picked, so the
366// page does not depend on token create's own default (#264, #257).
367func TestAccountSubmitTokenCreateScope(t *testing.T) {
368 s, st, u := newTokenTestServer(t)
369 for _, c := range []struct{ name, scope, want string }{
370 {"a", "", "read"}, {"b", "read", "read"}, {"c", "bogus", "read"}, {"d", "full", "full"},
371 } {
372 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {c.name}, "scope": {c.scope}})
373 if rr.Code != http.StatusOK {
374 t.Fatalf("%s: status %d", c.name, rr.Code)
375 }
376 }
377 tokens, err := st.ListAPITokens(u.ID)
378 if err != nil || len(tokens) != 4 {
379 t.Fatalf("tokens: %v %v", tokens, err)
380 }
381 want := map[string]string{"a": "read", "b": "read", "c": "read", "d": "full"}
382 for _, tk := range tokens {
383 if tk.Scope != want[tk.Name] {
384 t.Errorf("%s: scope %q, want %q", tk.Name, tk.Scope, want[tk.Name])
385 }
386 }
387}
388
389// A failed create redirects with the reason and shows no token.
390func TestAccountSubmitTokenCreateRefusal(t *testing.T) {
391 s, _, u := newTokenTestServer(t)
392 for _, form := range []url.Values{
393 {"field": {"token-create"}, "name": {""}},
394 {"field": {"token-create"}, "name": {"x"}, "ttl": {"-1h"}},
395 } {
396 rr := submitAccountForm(t, s, u, form)
397 if rr.Code != http.StatusSeeOther || strings.Contains(rr.Body.String(), "gb_") {
398 t.Errorf("%v: status %d, body %s", form, rr.Code, rr.Body.String())
399 }
400 }
401}
402
403// Revoking a token requires the name typed back, the same guard every
404// other removal on this page uses.
405func TestAccountSubmitTokenRevokeRequiresConfirm(t *testing.T) {
406 s, st, u := newTokenTestServer(t)
407 if err := st.CreateAPIToken(u.ID, "laptop", "somehash", "read", nil, 0); err != nil {
408 t.Fatal(err)
409 }
410 submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}})
411 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 1 {
412 t.Fatal("token revoked without confirmation")
413 }
414 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}, "confirm": {"laptop"}})
415 if rr.Code != http.StatusSeeOther {
416 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
417 }
418 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 0 {
419 t.Fatal("token not revoked")
420 }
421}
422
423// A cross-site POST to /settings is refused before a token is minted.
424func TestAccountTokenCreateCrossSiteRefused(t *testing.T) {
425 _, st, u := newTokenTestServer(t)
426 cfg := config.Default()
427 cfg.Web.Mode = "accounts"
428 s := New(cfg, st)
429 form := url.Values{"field": {"token-create"}, "name": {"evil"}, "scope": {"full"}}
430 for _, r := range s.Routes() {
431 if r.Method != "POST" || r.Pattern != "/settings" {
432 continue
433 }
434 req := httptest.NewRequest("POST", "http://example.com/settings", strings.NewReader(form.Encode()))
435 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
436 req.Header.Set("Origin", "https://evil.example")
437 rr := httptest.NewRecorder()
438 r.Handler(rr, req)
439 if rr.Code != http.StatusForbidden {
440 t.Fatalf("status %d, want 403", rr.Code)
441 }
442 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 0 {
443 t.Fatal("a cross-site POST minted a token")
444 }
445 return
446 }
447 t.Fatal("no POST /settings route")
448}
449
450// A token named like a flag, which token create accepts, can still be
451// revoked from the page: the name goes after "--".
452func TestAccountSubmitTokenRevokeFlagLikeName(t *testing.T) {
453 s, st, u := newTokenTestServer(t)
454 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"--x"}})
455 if rr.Code != http.StatusOK {
456 t.Fatalf("create: status %d, body %s", rr.Code, rr.Body.String())
457 }
458 rr = submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"--x"}, "confirm": {"--x"}})
459 if rr.Code != http.StatusSeeOther {
460 t.Fatalf("revoke: status %d", rr.Code)
461 }
462 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 0 {
463 t.Fatalf("token not revoked: %+v", tokens)
464 }
465}
466
467// The audit row for a web token create records the command but not the
468// minted token.
469func TestAccountTokenCreateAuditOmitsToken(t *testing.T) {
470 s, st, u := newTokenTestServer(t)
471 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}})
472 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "gb_") {
473 t.Fatalf("create: status %d", rr.Code)
474 }
475 rows, err := st.DB.Query("SELECT action, data_json FROM audit_log")
476 if err != nil {
477 t.Fatal(err)
478 }
479 defer rows.Close()
480 found := false
481 for rows.Next() {
482 var action, data string
483 if err := rows.Scan(&action, &data); err != nil {
484 t.Fatal(err)
485 }
486 if action == "cmd token create" {
487 found = true
488 }
489 if strings.Contains(data, "gb_") {
490 t.Errorf("audit row %q carries the token: %s", action, data)
491 }
492 }
493 if err := rows.Err(); err != nil {
494 t.Fatal(err)
495 }
496 if !found {
497 t.Fatal("no cmd token create audit row")
498 }
499}
internal/web/templates/account.html +40 −4
@@ -18,6 +18,7 @@
1818 <li><a href="#notifications">Notifications</a></li>
1919 <li><a href="#appearance">Appearance</a></li>
2020 <li><a href="#export">Export</a></li>
21 <li><a href="#tokens">API tokens</a></li>
2122 <li><a href="#cli">On the command line</a></li>
2223 </ul></div>
2324</details>
@@ -188,11 +189,46 @@ never included; a replayed bundle's emails arrive unverified.</p>
188189<p><a href="/settings/export">Download bundle</a></p>
189190</section>
190191
192<section id="tokens"><h2>API tokens</h2>
193<p class="meta">A token signs in the iOS app, or a script, without your
194password. A phone app needs full scope to comment and merge; full scope
195on an admin account can administer the instance, so give a token the
196narrowest scope and shortest lifetime the job needs.</p>
197{{if .TokenShown}}<p class="notice" role="status">Token created. It is shown once; store it now.</p>
198<pre class="message" tabindex="0">{{.TokenShown}}</pre>{{end}}
199{{if .Tokens}}<div class="tablewrap"><table class="keys nowrap">
200<tr class="cols"><th scope="col">name</th><th scope="col">scope</th><th scope="col">created</th><th scope="col">expires</th><th scope="col">last used</th><th scope="col"><span class="vh">actions</span></th></tr>
201{{range .Tokens}}<tr>
202 <td>{{.Name}}</td>
203 <td>{{.Scope}}</td>
204 <td>{{when .Created}}</td>
205 <td>{{.Expires}}</td>
206 <td>{{.LastUsed}}</td>
207 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="token-revoke"><input type="hidden" name="name" value="{{.Name}}">{{template "confirmfield" .Name}} <button type="submit" class="danger">Revoke</button></form></td>
208</tr>
209{{end}}</table></div>
210{{else}}<p class="none">No API tokens.</p>{{end}}
211<details class="editbox">
212 <summary>Create a token</summary>
213 <form method="post" action="/settings" class="setform stack">
214 <input type="hidden" name="field" value="token-create">
215 <label for="token-name">Name</label>
216 <input id="token-name" name="name" required autocomplete="off" placeholder="e.g. iphone">
217 <label for="token-scope">Scope</label>
218 <select id="token-scope" name="scope">
219 <option value="read" selected>read: read-only commands</option>
220 <option value="full">full: everything your account can do</option>
221 </select>
222 <label for="token-ttl">Expires after</label>
223 <input id="token-ttl" name="ttl" autocomplete="off" placeholder="e.g. 30d or 720h; empty never expires">
224 <button type="submit" class="btn">Create token</button>
225 </form>
226</details>
227</section>
228
191229<section id="cli"><h2>On the command line</h2>
192<p class="meta">No page here yet, and nothing refusing one: a credential is
193easier to pipe than to paste, and a minted token is shown once.</p>
194<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens, read-only unless --scope full
195gitbay web sessions list # browser sessions
230<p class="meta">No page here yet, and nothing refusing one.</p>
231<pre class="message" tabindex="0">gitbay web sessions list # browser sessions
196232gitbay admin ... # instance administration</pre>
197233<p class="meta">All of it works from stock OpenSSH too, with the CLI's
198234grouping words dropped: <code>ssh git@{{.Host}} whoami</code>,