Commit 1941ec0174

1941ec0174c6be6798a2380aea9d7db294406b47

parent: 6042b09dfc

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-02 04:35 UTC

web sessions: list and revoke

A browser session lasted seven days and only the current one could be
ended, through /logout; disabling the account was the only way to drop
the others.

web sessions list shows each unexpired session as the first twelve hex
digits of its stored token hash with its creation and expiry; web
sessions revoke <id> ends one and --all ends every one. Both are
SSH-only, matching the token commands, and scoped to the caller: a
session id from another account is not found.

Closes #83

Layout: unified · split

cmd/gitbay/main.go +4
@@ -498,6 +498,10 @@ func usesTokenStdin(args []string) bool {
498func webCmd() *cobra.Command { 498func webCmd() *cobra.Command {
499 return group("web", "browser session", 499 return group("web", "browser session",
500 pass("login", "mint a one-time browser login URL over ssh", passOpts{server: []string{"web", "login"}}), 500 pass("login", "mint a one-time browser login URL over ssh", passOpts{server: []string{"web", "login"}}),
501 group("sessions", "your browser sessions",
502 pass("list", "list your browser sessions", passOpts{server: []string{"web", "sessions", "list"}}),
503 pass("revoke", "end a browser session: <id>|--all", passOpts{server: []string{"web", "sessions", "revoke"}}),
504 ),
501 ) 505 )
502} 506}
503 507
e2e/websessions_test.go added +87
@@ -0,0 +1,87 @@
1package e2e
2
3import (
4 "encoding/json"
5 "net/http"
6 "strings"
7 "testing"
8)
9
10// A browser session can be listed and ended from SSH, one at a time or
11// all at once, and only its owner sees it.
12func TestWebSessionsListRevoke(t *testing.T) {
13 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
14 aliceKey := inst.newKey(t, "alice")
15 bobKey := inst.newKey(t, "bob")
16 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
17 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
18
19 if out, _, code := inst.ssh(t, aliceKey, "", "web", "sessions", "list", "--json"); code != 0 || !strings.Contains(out, `"data":[]`) {
20 t.Fatalf("no sessions yet: exit %d %s", code, out)
21 }
22 first := inst.login(t, aliceKey)
23 second := inst.login(t, aliceKey)
24 list := func() []struct {
25 ID string `json:"id"`
26 } {
27 t.Helper()
28 out, errOut, code := inst.ssh(t, aliceKey, "", "web", "sessions", "list", "--json")
29 if code != 0 {
30 t.Fatalf("list: %s", errOut)
31 }
32 var env struct {
33 Data []struct {
34 ID string `json:"id"`
35 } `json:"data"`
36 }
37 if err := json.Unmarshal([]byte(out), &env); err != nil {
38 t.Fatalf("list json: %v\n%s", err, out)
39 }
40 return env.Data
41 }
42 sessions := list()
43 if len(sessions) != 2 || len(sessions[0].ID) != 12 {
44 t.Fatalf("two sessions expected: %+v", sessions)
45 }
46 // Bob sees none of them, and cannot revoke one by id.
47 if out, _, _ := inst.ssh(t, bobKey, "", "web", "sessions", "list", "--json"); !strings.Contains(out, `"data":[]`) {
48 t.Fatalf("bob sees alice's sessions:\n%s", out)
49 }
50 if _, _, code := inst.ssh(t, bobKey, "", "web", "sessions", "revoke", sessions[0].ID); code != 3 {
51 t.Fatalf("bob revoked alice's session: exit %d", code)
52 }
53 // Both browsers work; revoking the newest logs that one out.
54 // The client follows the logged-out redirect to /login, so the page
55 // body tells the two apart, not the status.
56 loggedIn := func(c *http.Client) bool {
57 _, body := browserGet(t, c, inst.base()+"/settings")
58 return strings.Contains(body, "SSH keys")
59 }
60 if !loggedIn(first) || !loggedIn(second) {
61 t.Fatal("both browsers should be logged in")
62 }
63 if out, _, code := inst.ssh(t, aliceKey, "", "web", "sessions", "revoke", sessions[0].ID); code != 0 || !strings.Contains(out, "revoked browser session") {
64 t.Fatalf("revoke: exit %d %s", code, out)
65 }
66 if got := list(); len(got) != 1 {
67 t.Fatalf("one session left expected: %+v", got)
68 }
69 okCount := 0
70 for _, c := range []*http.Client{first, second} {
71 if loggedIn(c) {
72 okCount++
73 }
74 }
75 if okCount != 1 {
76 t.Fatalf("exactly one browser should still be logged in, got %d", okCount)
77 }
78 if out, _, code := inst.ssh(t, aliceKey, "", "web", "sessions", "revoke", "--all"); code != 0 || !strings.Contains(out, "revoked 1 browser sessions") {
79 t.Fatalf("revoke --all: exit %d %s", code, out)
80 }
81 if loggedIn(first) || loggedIn(second) {
82 t.Fatal("a browser is still logged in after revoke --all")
83 }
84 if _, _, code := inst.ssh(t, aliceKey, "", "web", "sessions", "revoke", "abcdefabcdef"); code != 3 {
85 t.Fatal("unknown id accepted")
86 }
87}
internal/control/web.go +46
@@ -1,6 +1,7 @@
1package control 1package control
2 2
3import ( 3import (
4 "errors"
4 "fmt" 5 "fmt"
5 "io" 6 "io"
6 "time" 7 "time"
@@ -15,6 +16,51 @@ func init() {
15 register(Command{Path: []string{"web", "login"}, 16 register(Command{Path: []string{"web", "login"},
16 Summary: "mint a one-time browser login URL", 17 Summary: "mint a one-time browser login URL",
17 Usage: "web login", Run: runWebLogin}) 18 Usage: "web login", Run: runWebLogin})
19 register(Command{Path: []string{"web", "sessions", "list"},
20 Summary: "list your browser sessions",
21 Usage: "web sessions list", ReadOnly: true, SSHOnly: true, Run: runWebSessionsList})
22 register(Command{Path: []string{"web", "sessions", "revoke"},
23 Summary: "end a browser session, or all of them",
24 Usage: "web sessions revoke <id>|--all", SSHOnly: true, Run: runWebSessionsRevoke})
25}
26
27func runWebSessionsList(c *Ctx, args []string) int {
28 if len(args) != 0 {
29 return c.fail(protocol.ExitUsage, "usage: web sessions list")
30 }
31 sessions, err := c.Store.ListWebSessions(c.User.ID)
32 if err != nil {
33 return c.fail(protocol.ExitFailure, "%v", err)
34 }
35 return c.emit(sessions, func(w io.Writer) {
36 for _, s := range sessions {
37 fmt.Fprintf(w, "%s\tsince %s\tuntil %s\n", s.ID, s.CreatedAt, s.ExpiresAt)
38 }
39 })
40}
41
42func runWebSessionsRevoke(c *Ctx, args []string) int {
43 if len(args) != 1 {
44 return c.fail(protocol.ExitUsage, "usage: web sessions revoke <id>|--all")
45 }
46 if args[0] == "--all" {
47 n, err := c.Store.RevokeAllWebSessions(c.User.ID)
48 if err != nil {
49 return c.fail(protocol.ExitFailure, "%v", err)
50 }
51 return c.emit(map[string]any{"revoked": n}, func(w io.Writer) {
52 fmt.Fprintf(w, "revoked %d browser sessions\n", n)
53 })
54 }
55 if err := c.Store.RevokeWebSession(c.User.ID, args[0]); err != nil {
56 if errors.Is(err, store.ErrNotFound) {
57 return c.fail(protocol.ExitNotFound, "no browser session %s on your account", args[0])
58 }
59 return c.fail(protocol.ExitFailure, "%v", err)
60 }
61 return c.emit(map[string]any{"revoked": args[0]}, func(w io.Writer) {
62 fmt.Fprintf(w, "revoked browser session %s\n", args[0])
63 })
18} 64}
19 65
20func runWebLogin(c *Ctx, args []string) int { 66func runWebLogin(c *Ctx, args []string) int {
internal/store/sessions.go +50
@@ -79,3 +79,53 @@ func (s *Store) DeleteWebSession(hash string) error {
79 _, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash) 79 _, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash)
80 return err 80 return err
81} 81}
82
83// WebSession is one browser session as its owner lists it. ID is the first
84// twelve hex digits of the stored token hash: enough to name it, and a
85// hash of the cookie rather than the cookie.
86type WebSession struct {
87 ID string `json:"id"`
88 CreatedAt string `json:"created_at"`
89 ExpiresAt string `json:"expires_at"`
90}
91
92// ListWebSessions lists the user's unexpired browser sessions, newest first.
93func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
94 rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at
95 FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`,
96 userID, fmtTime(time.Now()))
97 if err != nil {
98 return nil, err
99 }
100 defer rows.Close()
101 var out []WebSession
102 for rows.Next() {
103 var ws WebSession
104 if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt); err != nil {
105 return nil, err
106 }
107 out = append(out, ws)
108 }
109 return out, rows.Err()
110}
111
112// RevokeWebSession ends one of the user's sessions by its listed id.
113func (s *Store) RevokeWebSession(userID int64, id string) error {
114 res, err := s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ? AND substr(token_hash, 1, 12) = ?", userID, id)
115 if err != nil {
116 return err
117 }
118 if n, _ := res.RowsAffected(); n == 0 {
119 return ErrNotFound
120 }
121 return nil
122}
123
124// RevokeAllWebSessions ends every browser session the user has.
125func (s *Store) RevokeAllWebSessions(userID int64) (int64, error) {
126 res, err := s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ?", userID)
127 if err != nil {
128 return 0, err
129 }
130 return res.RowsAffected()
131}