Commit 1941ec0174
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +4
| @@ -498,6 +498,10 @@ func usesTokenStdin(args []string) bool { | ||
| 498 | 498 | func webCmd() *cobra.Command { |
| 499 | 499 | return group("web", "browser session", |
| 500 | 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 12 | func 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 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "errors" | |
| 4 | 5 | "fmt" |
| 5 | 6 | "io" |
| 6 | 7 | "time" |
| @@ -15,6 +16,51 @@ func init() { | ||
| 15 | 16 | register(Command{Path: []string{"web", "login"}, |
| 16 | 17 | Summary: "mint a one-time browser login URL", |
| 17 | 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 | ||
| 27 | func 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 | ||
| 42 | func 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 | |
| 20 | 66 | func runWebLogin(c *Ctx, args []string) int { |
internal/store/sessions.go +50
| @@ -79,3 +79,53 @@ func (s *Store) DeleteWebSession(hash string) error { | ||
| 79 | 79 | _, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash) |
| 80 | 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. | |
| 86 | type 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. | |
| 93 | func (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. | |
| 113 | func (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. | |
| 125 | func (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 | } | |