web sessions: list and revoke !173

merged merged by cmc on 2026-09-02 23:44 UTC · krz/gitbay:stack-7-sessions into main

4 files changed, +187 −0

Layout: unified · split

cmd/gitbay/main.go +4
@@ -498,6 +498,10 @@ func usesTokenStdin(args []string) bool {
498498func webCmd() *cobra.Command {
499499 return group("web", "browser session",
500500 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 ),
501505 )
502506}
503507
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 @@
11package control
22
33import (
4 "errors"
45 "fmt"
56 "io"
67 "time"
@@ -15,6 +16,51 @@ func init() {
1516 register(Command{Path: []string{"web", "login"},
1617 Summary: "mint a one-time browser login URL",
1718 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 })
1864}
1965
2066func runWebLogin(c *Ctx, args []string) int {
internal/store/sessions.go +50
@@ -79,3 +79,53 @@ func (s *Store) DeleteWebSession(hash string) error {
7979 _, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash)
8080 return err
8181}
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}