Commit ed8f3378b1

ed8f3378b1d69ae1e4ee55d0216141730ad592e2

parent: b79de56262

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 00:27 UTC

web: a stale session's mint or grant gets a sign-in link back to the form

Every web Ctx now carries control.SourceWeb instead of the literal
"web". The pages that dispatch a NeedsRecentSignIn command — account
settings (keys, PGP, tokens), repo settings (runner add), an org's
people tab (member and team forms), and admin account actions
(promote, enable) — recognise control.ReauthRefusal through the new
reauthNotice helper, show a "Sign in again" link next to it, and set
the gitbay_next cookie to the page so the sign-in returns there.

Ref #297

Layout: unified · split

CHANGELOG.org +4
@@ -23,6 +23,10 @@ source and a =--from=/remote URL carrying a query or fragment. A
2323mirror or import whose host is written numerically (=127.1=,
2424=2130706433=, =0x7f.1=) rather than as a dotted address is refused
2525too; rewrite it before upgrading.
26- A web session older than 15 minutes cannot mint a credential or grant
27 access — keys, PGP keys, tokens, org membership, and the admin
28 promote/enable actions — and the form it tried shows a sign-in link
29 that returns there (#297).
2630- The builds page's status badge section gives an org-mode snippet
2731 beside the Markdown one, for a README.org (#299).
2832- API tokens on the settings page: create with a scope and optional
internal/httpd/account.go +6 −2
@@ -128,6 +128,9 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U
128128 aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath
129129 }
130130
131 notice := s.takeFlash(w, r)
132 reauth := s.reauthNotice(w, notice, "/settings")
133
131134 s.render(w, "account.html", struct {
132135 basePage
133136 Tab string // marks the rail's Settings row as current
@@ -148,10 +151,11 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U
148151 ThemeSetting string // system, light or dark: the form's selected option
149152 Tokens []accountToken
150153 TokenShown string // a token minted by this request, shown once
154 Reauth bool // Notice is the stale-session refusal: link to sign in
151155 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
152156 aboutRepo, aboutEdit, s.cfg.SiteHost(),
153 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme,
154 tokens, tokenShown})
157 notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme,
158 tokens, tokenShown, reauth})
155159}
156160
157161// accountExport hands the browser the same bundle `account export`
internal/httpd/adminusers.go +3 −1
@@ -62,6 +62,7 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store
6262 }
6363 next = "?" + q.Encode()
6464 }
65 notice := s.takeFlash(w, r)
6566 s.render(w, "adminusers.html", struct {
6667 basePage
6768 Tab string
@@ -69,7 +70,8 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store
6970 Users []adminUserRow
7071 Next string
7172 Notice string
72 }{s.baseFor(viewer), "admin", state, page.Items, next, s.takeFlash(w, r)})
73 Reauth bool // Notice is the stale-session refusal: link to sign in
74 }{s.baseFor(viewer), "admin", state, page.Items, next, notice, s.reauthNotice(w, notice, r.URL.Path)})
7375}
7476
7577// adminUsersSubmit runs one account action. Each is the command an
internal/httpd/control.go +5 −5
@@ -34,7 +34,7 @@ func (s *Server) runControlCode(u store.User, argv []string) (out string, msg st
3434 var stdout, stderr bytes.Buffer
3535 ctx := &control.Ctx{
3636 User: u,
37 Source: "web",
37 Source: control.SourceWeb,
3838 Scope: "full",
3939 Store: s.st,
4040 Cfg: s.cfg,
@@ -58,7 +58,7 @@ func (s *Server) runControlStream(u store.User, argv []string, out io.Writer, do
5858 var stderr bytes.Buffer
5959 ctx := &control.Ctx{
6060 User: u,
61 Source: "web",
61 Source: control.SourceWeb,
6262 Scope: "full",
6363 Store: s.st,
6464 Cfg: s.cfg,
@@ -104,7 +104,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string)
104104 var stdout, stderr bytes.Buffer
105105 ctx := &control.Ctx{
106106 User: u,
107 Source: "web",
107 Source: control.SourceWeb,
108108 Scope: "full",
109109 Store: s.st,
110110 Cfg: s.cfg,
@@ -146,7 +146,7 @@ func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, ta
146146 var stdout, stderr bytes.Buffer
147147 ctx := &control.Ctx{
148148 User: u,
149 Source: "web",
149 Source: control.SourceWeb,
150150 Scope: "full",
151151 Store: s.st,
152152 Cfg: s.cfg,
@@ -187,7 +187,7 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i
187187 var stdout, stderr bytes.Buffer
188188 ctx := &control.Ctx{
189189 User: u,
190 Source: "web",
190 Source: control.SourceWeb,
191191 Scope: "full",
192192 Store: s.st,
193193 Cfg: s.cfg,
internal/httpd/flash.go +14
@@ -4,6 +4,8 @@ import (
44 "net/http"
55 "net/url"
66 "strings"
7
8 "gitbay.org/gitbay/internal/control"
79)
810
911// A form action that fails redirects back to the page it came from with
@@ -44,6 +46,18 @@ func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) string {
4446 return msg
4547}
4648
49// reauthNotice reports whether notice is Dispatch's refusal for a session
50// that signed in too long ago to mint a credential or grant access and,
51// when it is, remembers path so the sign-in the page links to returns
52// there (#297).
53func (s *Server) reauthNotice(w http.ResponseWriter, notice, path string) bool {
54 if notice != control.ReauthRefusal {
55 return false
56 }
57 s.setNext(w, path)
58 return true
59}
60
4761const nextCookie = "gitbay_next"
4862
4963// setNext remembers the local path an anonymous visitor asked for, so
internal/httpd/reauth_test.go added +159
@@ -0,0 +1,159 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "strings"
8 "testing"
9 "time"
10
11 "gitbay.org/gitbay/internal/config"
12 "gitbay.org/gitbay/internal/control"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// A session signed in longer ago than ReauthWindow cannot mint from the
17// settings page: the form comes back with the refusal and a sign-in
18// link, and the sign-in returns to /settings (#297).
19func TestWebMintNeedsRecentSignIn(t *testing.T) {
20 s, st, u := newTokenTestServer(t)
21 stale := u
22 stale.SignedInAt = time.Now().Add(-control.ReauthWindow - time.Minute)
23 rr := submitAccountForm(t, s, stale, url.Values{"field": {"token-create"}, "name": {"laptop"}, "scope": {"full"}})
24 if rr.Code != http.StatusSeeOther {
25 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
26 }
27 if list, err := st.ListAPITokens(u.ID); err != nil || len(list) != 0 {
28 t.Fatalf("a stale session minted %+v (%v)", list, err)
29 }
30
31 req := httptest.NewRequest("GET", "/settings", nil)
32 for _, c := range rr.Result().Cookies() {
33 req.AddCookie(c)
34 }
35 page := httptest.NewRecorder()
36 s.accountPage(page, req, stale)
37 body := page.Body.String()
38 if !strings.Contains(body, control.ReauthRefusal) {
39 t.Fatalf("refusal not shown: %s", body)
40 }
41 if !strings.Contains(body, `<a href="/login">Sign in again</a>`) {
42 t.Fatalf("no sign-in link: %s", body)
43 }
44 var next string
45 for _, c := range page.Result().Cookies() {
46 if c.Name == nextCookie {
47 next = c.Value
48 }
49 }
50 if next != url.QueryEscape("/settings") {
51 t.Fatalf("gitbay_next = %q, want /settings", next)
52 }
53}
54
55// An API token has no browser session: minting through the API is not
56// held to the sign-in window.
57func TestAPIMintIgnoresTheSignInWindow(t *testing.T) {
58 s, st, u := newTokenTestServer(t)
59 if err := st.CreateAPIToken(u.ID, "ci", store.HashToken("gb_reauthtest"), "full", nil, 0); err != nil {
60 t.Fatal(err)
61 }
62 req := httptest.NewRequest("POST", "/api/v1/cmd",
63 strings.NewReader(`{"argv":["token","create","--name","second","--scope","read"]}`))
64 req.Header.Set("Authorization", "Bearer gb_reauthtest")
65 rr := httptest.NewRecorder()
66 s.apiCmd(rr, req)
67 if rr.Code != http.StatusOK {
68 t.Fatalf("status %d: %s", rr.Code, rr.Body.String())
69 }
70}
71
72// A fresh session mints without any refusal.
73func TestWebMintFreshSessionSucceeds(t *testing.T) {
74 s, st, u := newTokenTestServer(t)
75 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}, "scope": {"full"}})
76 if rr.Code != http.StatusOK {
77 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
78 }
79 if list, err := st.ListAPITokens(u.ID); err != nil || len(list) != 1 {
80 t.Fatalf("token not minted: %+v (%v)", list, err)
81 }
82}
83
84// A stale session posting a grant form (org members add, on the
85// organization's people page) also sees the refusal and the sign-in
86// link, and the membership is not created.
87func TestWebGrantNeedsRecentSignIn(t *testing.T) {
88 st, err := store.Open(":memory:")
89 if err != nil {
90 t.Fatal(err)
91 }
92 defer st.Close()
93 if err := st.MigrateUp(); err != nil {
94 t.Fatal(err)
95 }
96 uid, err := st.CreateUser("alice", false)
97 if err != nil {
98 t.Fatal(err)
99 }
100 if _, err := st.CreateUser("bob", false); err != nil {
101 t.Fatal(err)
102 }
103 fresh := store.User{ID: uid, Username: "alice", SignedInAt: time.Now()}
104 stale := fresh
105 stale.SignedInAt = time.Now().Add(-control.ReauthWindow - time.Minute)
106
107 cfg := config.Default()
108 cfg.Web.Mode = "accounts"
109 s := New(cfg, st, nil)
110 if _, msg, ok := s.runControl(fresh, []string{"org", "create", "krz"}); !ok {
111 t.Fatalf("org create: %s", msg)
112 }
113
114 req := httptest.NewRequest("POST", "/krz",
115 strings.NewReader(url.Values{"field": {"member-add"}, "user": {"bob"}}.Encode()))
116 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
117 req.SetPathValue("owner", "krz")
118 rr := httptest.NewRecorder()
119 s.orgSubmit(rr, req, stale)
120 if rr.Code != http.StatusSeeOther {
121 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
122 }
123
124 req2 := httptest.NewRequest("GET", "/krz/-/people", nil)
125 req2.SetPathValue("owner", "krz")
126 for _, c := range rr.Result().Cookies() {
127 req2.AddCookie(c)
128 }
129 req2.AddCookie(sessionCookieFor(t, s, st, uid))
130 page := httptest.NewRecorder()
131 s.ownerProfile(page, req2)
132 body := page.Body.String()
133 if !strings.Contains(body, control.ReauthRefusal) {
134 t.Fatalf("refusal not shown: %s", body)
135 }
136 if !strings.Contains(body, `<a href="/login">Sign in again</a>`) {
137 t.Fatalf("no sign-in link: %s", body)
138 }
139 var next string
140 for _, c := range page.Result().Cookies() {
141 if c.Name == nextCookie {
142 next = c.Value
143 }
144 }
145 if next != url.QueryEscape("/krz/-/people") {
146 t.Fatalf("gitbay_next = %q, want /krz/-/people", next)
147 }
148
149 org, err := st.OrgByName("krz")
150 if err != nil {
151 t.Fatal(err)
152 }
153 members, _ := st.OrgMembers(org.ID)
154 for _, m := range members {
155 if m.Username == "bob" {
156 t.Fatalf("a stale session added bob to the org")
157 }
158 }
159}
internal/httpd/settings.go +2
@@ -26,6 +26,7 @@ type settingsPage struct {
2626 Runners []store.RepoRunner
2727 Notice string
2828 Saved bool
29 Reauth bool // Notice is the stale-session refusal: link to sign in
2930 Submitted map[string]string
3031}
3132
@@ -70,6 +71,7 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
7071 Runners: runners,
7172 Notice: notice,
7273 Saved: strings.HasPrefix(notice, "Saved "),
74 Reauth: s.reauthNotice(w, notice, r.URL.Path),
7375 Submitted: subm,
7476 })
7577}
internal/httpd/web.go +4 −1
@@ -517,6 +517,7 @@ type ownerPage struct {
517517 Self bool
518518 Snippets int
519519 Notice string
520 Reauth bool // Notice is the stale-session refusal: link to sign in
520521 Feed string
521522}
522523
@@ -572,6 +573,7 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
572573 return
573574 }
574575 }
576 notice := s.takeFlash(w, r)
575577 s.render(w, "owner.html", ownerPage{
576578 basePage: s.baseFor(viewer),
577579 Owner: name,
@@ -592,7 +594,8 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
592594 CanAdmin: canAdmin,
593595 Self: self,
594596 Snippets: d.Snippets,
595 Notice: s.takeFlash(w, r),
597 Notice: notice,
598 Reauth: s.reauthNotice(w, notice, r.URL.Path),
596599 Feed: "/" + name + "/activity.atom",
597600 })
598601}
internal/web/templates/account.html +1 −1
@@ -2,7 +2,7 @@
22{{define "title"}}account settings{{end}}
33{{define "content"}}
44<h1>Account settings</h1>
5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if .Notice}}<p class="error" role="alert">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}}
66{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}}
77
88<div class="withcol narrow">
internal/web/templates/adminusers.html +1 −1
@@ -7,7 +7,7 @@
77{{define "content"}}
88<h1>Accounts</h1>
99<p class="meta"><a href="/admin">Admin</a> · the same read as <code>gitbay admin user list</code>.</p>
10{{if .Notice}}<p class="notice" role="status">{{.Notice}}</p>{{end}}
10{{if .Notice}}<p class="notice" role="status">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}}
1111
1212<nav class="filters">
1313 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
internal/web/templates/owner.html +1 −1
@@ -83,7 +83,7 @@
8383
8484{{if eq .Tab "people"}}{{$org := .Owner}}
8585<h2>people <span class="count">{{len .Members}}</span></h2>
86{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
86{{if .Notice}}<p class="error" role="alert">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}}
8787<div class="tablewrap"><table class="keys">
8888<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"><span class="vh">actions</span></th></tr>
8989{{range .Members}}<tr>
internal/web/templates/settings.html +1 −1
@@ -3,7 +3,7 @@
33{{define "content"}}
44{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
55<h1>Settings</h1>
6{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}}
6{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}}{{end}}
77<div class="withcol narrow">
88<nav class="sidecol" aria-label="Sections">
99<details class="sidedrop">