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
23mirror or import whose host is written numerically (=127.1=, 23mirror or import whose host is written numerically (=127.1=,
24=2130706433=, =0x7f.1=) rather than as a dotted address is refused 24=2130706433=, =0x7f.1=) rather than as a dotted address is refused
25too; rewrite it before upgrading. 25too; 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).
26- The builds page's status badge section gives an org-mode snippet 30- The builds page's status badge section gives an org-mode snippet
27 beside the Markdown one, for a README.org (#299). 31 beside the Markdown one, for a README.org (#299).
28- API tokens on the settings page: create with a scope and optional 32- 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
128 aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath 128 aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath
129 } 129 }
130 130
131 notice := s.takeFlash(w, r)
132 reauth := s.reauthNotice(w, notice, "/settings")
133
131 s.render(w, "account.html", struct { 134 s.render(w, "account.html", struct {
132 basePage 135 basePage
133 Tab string // marks the rail's Settings row as current 136 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
148 ThemeSetting string // system, light or dark: the form's selected option 151 ThemeSetting string // system, light or dark: the form's selected option
149 Tokens []accountToken 152 Tokens []accountToken
150 TokenShown string // a token minted by this request, shown once 153 TokenShown string // a token minted by this request, shown once
154 Reauth bool // Notice is the stale-session refusal: link to sign in
151 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), 155 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
152 aboutRepo, aboutEdit, s.cfg.SiteHost(), 156 aboutRepo, aboutEdit, s.cfg.SiteHost(),
153 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme, 157 notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme,
154 tokens, tokenShown}) 158 tokens, tokenShown, reauth})
155} 159}
156 160
157// accountExport hands the browser the same bundle `account export` 161// 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
62 } 62 }
63 next = "?" + q.Encode() 63 next = "?" + q.Encode()
64 } 64 }
65 notice := s.takeFlash(w, r)
65 s.render(w, "adminusers.html", struct { 66 s.render(w, "adminusers.html", struct {
66 basePage 67 basePage
67 Tab string 68 Tab string
@@ -69,7 +70,8 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store
69 Users []adminUserRow 70 Users []adminUserRow
70 Next string 71 Next string
71 Notice string 72 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)})
73} 75}
74 76
75// adminUsersSubmit runs one account action. Each is the command an 77// 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
34 var stdout, stderr bytes.Buffer 34 var stdout, stderr bytes.Buffer
35 ctx := &control.Ctx{ 35 ctx := &control.Ctx{
36 User: u, 36 User: u,
37 Source: "web", 37 Source: control.SourceWeb,
38 Scope: "full", 38 Scope: "full",
39 Store: s.st, 39 Store: s.st,
40 Cfg: s.cfg, 40 Cfg: s.cfg,
@@ -58,7 +58,7 @@ func (s *Server) runControlStream(u store.User, argv []string, out io.Writer, do
58 var stderr bytes.Buffer 58 var stderr bytes.Buffer
59 ctx := &control.Ctx{ 59 ctx := &control.Ctx{
60 User: u, 60 User: u,
61 Source: "web", 61 Source: control.SourceWeb,
62 Scope: "full", 62 Scope: "full",
63 Store: s.st, 63 Store: s.st,
64 Cfg: s.cfg, 64 Cfg: s.cfg,
@@ -104,7 +104,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string)
104 var stdout, stderr bytes.Buffer 104 var stdout, stderr bytes.Buffer
105 ctx := &control.Ctx{ 105 ctx := &control.Ctx{
106 User: u, 106 User: u,
107 Source: "web", 107 Source: control.SourceWeb,
108 Scope: "full", 108 Scope: "full",
109 Store: s.st, 109 Store: s.st,
110 Cfg: s.cfg, 110 Cfg: s.cfg,
@@ -146,7 +146,7 @@ func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, ta
146 var stdout, stderr bytes.Buffer 146 var stdout, stderr bytes.Buffer
147 ctx := &control.Ctx{ 147 ctx := &control.Ctx{
148 User: u, 148 User: u,
149 Source: "web", 149 Source: control.SourceWeb,
150 Scope: "full", 150 Scope: "full",
151 Store: s.st, 151 Store: s.st,
152 Cfg: s.cfg, 152 Cfg: s.cfg,
@@ -187,7 +187,7 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i
187 var stdout, stderr bytes.Buffer 187 var stdout, stderr bytes.Buffer
188 ctx := &control.Ctx{ 188 ctx := &control.Ctx{
189 User: u, 189 User: u,
190 Source: "web", 190 Source: control.SourceWeb,
191 Scope: "full", 191 Scope: "full",
192 Store: s.st, 192 Store: s.st,
193 Cfg: s.cfg, 193 Cfg: s.cfg,
internal/httpd/flash.go +14
@@ -4,6 +4,8 @@ import (
4 "net/http" 4 "net/http"
5 "net/url" 5 "net/url"
6 "strings" 6 "strings"
7
8 "gitbay.org/gitbay/internal/control"
7) 9)
8 10
9// A form action that fails redirects back to the page it came from with 11// 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 {
44 return msg 46 return msg
45} 47}
46 48
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
47const nextCookie = "gitbay_next" 61const nextCookie = "gitbay_next"
48 62
49// setNext remembers the local path an anonymous visitor asked for, so 63// 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 {
26 Runners []store.RepoRunner 26 Runners []store.RepoRunner
27 Notice string 27 Notice string
28 Saved bool 28 Saved bool
29 Reauth bool // Notice is the stale-session refusal: link to sign in
29 Submitted map[string]string 30 Submitted map[string]string
30} 31}
31 32
@@ -70,6 +71,7 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
70 Runners: runners, 71 Runners: runners,
71 Notice: notice, 72 Notice: notice,
72 Saved: strings.HasPrefix(notice, "Saved "), 73 Saved: strings.HasPrefix(notice, "Saved "),
74 Reauth: s.reauthNotice(w, notice, r.URL.Path),
73 Submitted: subm, 75 Submitted: subm,
74 }) 76 })
75} 77}
internal/httpd/web.go +4 −1
@@ -517,6 +517,7 @@ type ownerPage struct {
517 Self bool 517 Self bool
518 Snippets int 518 Snippets int
519 Notice string 519 Notice string
520 Reauth bool // Notice is the stale-session refusal: link to sign in
520 Feed string 521 Feed string
521} 522}
522 523
@@ -572,6 +573,7 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
572 return 573 return
573 } 574 }
574 } 575 }
576 notice := s.takeFlash(w, r)
575 s.render(w, "owner.html", ownerPage{ 577 s.render(w, "owner.html", ownerPage{
576 basePage: s.baseFor(viewer), 578 basePage: s.baseFor(viewer),
577 Owner: name, 579 Owner: name,
@@ -592,7 +594,8 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
592 CanAdmin: canAdmin, 594 CanAdmin: canAdmin,
593 Self: self, 595 Self: self,
594 Snippets: d.Snippets, 596 Snippets: d.Snippets,
595 Notice: s.takeFlash(w, r), 597 Notice: notice,
598 Reauth: s.reauthNotice(w, notice, r.URL.Path),
596 Feed: "/" + name + "/activity.atom", 599 Feed: "/" + name + "/activity.atom",
597 }) 600 })
598} 601}
internal/web/templates/account.html +1 −1
@@ -2,7 +2,7 @@
2{{define "title"}}account settings{{end}} 2{{define "title"}}account settings{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>Account settings</h1> 4<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}}
6{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} 6{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}}
7 7
8<div class="withcol narrow"> 8<div class="withcol narrow">
internal/web/templates/adminusers.html +1 −1
@@ -7,7 +7,7 @@
7{{define "content"}} 7{{define "content"}}
8<h1>Accounts</h1> 8<h1>Accounts</h1>
9<p class="meta"><a href="/admin">Admin</a> · the same read as <code>gitbay admin user list</code>.</p> 9<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}}
11 11
12<nav class="filters"> 12<nav class="filters">
13 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a> 13 <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 @@
83 83
84{{if eq .Tab "people"}}{{$org := .Owner}} 84{{if eq .Tab "people"}}{{$org := .Owner}}
85<h2>people <span class="count">{{len .Members}}</span></h2> 85<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}}
87<div class="tablewrap"><table class="keys"> 87<div class="tablewrap"><table class="keys">
88<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"><span class="vh">actions</span></th></tr> 88<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"><span class="vh">actions</span></th></tr>
89{{range .Members}}<tr> 89{{range .Members}}<tr>
internal/web/templates/settings.html +1 −1
@@ -3,7 +3,7 @@
3{{define "content"}} 3{{define "content"}}
4{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} 4{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
5<h1>Settings</h1> 5<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}}
7<div class="withcol narrow"> 7<div class="withcol narrow">
8<nav class="sidecol" aria-label="Sections"> 8<nav class="sidecol" aria-label="Sections">
9<details class="sidedrop"> 9<details class="sidedrop">