Commit ed8f3378b1
Verified · cmc
Layout: unified · split
CHANGELOG.org +4
| @@ -23,6 +23,10 @@ source and a =--from=/remote URL carrying a query or fragment. A | ||
| 23 | 23 | mirror or import whose host is written numerically (=127.1=, |
| 24 | 24 | =2130706433=, =0x7f.1=) rather than as a dotted address is refused |
| 25 | 25 | too; 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 | 30 | - The builds page's status badge section gives an org-mode snippet |
| 27 | 31 | beside the Markdown one, for a README.org (#299). |
| 28 | 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 | 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 | 134 | s.render(w, "account.html", struct { |
| 132 | 135 | basePage |
| 133 | 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 | 151 | ThemeSetting string // system, light or dark: the form's selected option |
| 149 | 152 | Tokens []accountToken |
| 150 | 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 | 155 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), |
| 152 | 156 | 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}) | |
| 155 | 159 | } |
| 156 | 160 | |
| 157 | 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 | 63 | next = "?" + q.Encode() |
| 64 | 64 | } |
| 65 | notice := s.takeFlash(w, r) | |
| 65 | 66 | s.render(w, "adminusers.html", struct { |
| 66 | 67 | basePage |
| 67 | 68 | Tab string |
| @@ -69,7 +70,8 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store | ||
| 69 | 70 | Users []adminUserRow |
| 70 | 71 | Next string |
| 71 | 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 | 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 | 34 | var stdout, stderr bytes.Buffer |
| 35 | 35 | ctx := &control.Ctx{ |
| 36 | 36 | User: u, |
| 37 | Source: "web", | |
| 37 | Source: control.SourceWeb, | |
| 38 | 38 | Scope: "full", |
| 39 | 39 | Store: s.st, |
| 40 | 40 | Cfg: s.cfg, |
| @@ -58,7 +58,7 @@ func (s *Server) runControlStream(u store.User, argv []string, out io.Writer, do | ||
| 58 | 58 | var stderr bytes.Buffer |
| 59 | 59 | ctx := &control.Ctx{ |
| 60 | 60 | User: u, |
| 61 | Source: "web", | |
| 61 | Source: control.SourceWeb, | |
| 62 | 62 | Scope: "full", |
| 63 | 63 | Store: s.st, |
| 64 | 64 | Cfg: s.cfg, |
| @@ -104,7 +104,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) | ||
| 104 | 104 | var stdout, stderr bytes.Buffer |
| 105 | 105 | ctx := &control.Ctx{ |
| 106 | 106 | User: u, |
| 107 | Source: "web", | |
| 107 | Source: control.SourceWeb, | |
| 108 | 108 | Scope: "full", |
| 109 | 109 | Store: s.st, |
| 110 | 110 | Cfg: s.cfg, |
| @@ -146,7 +146,7 @@ func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, ta | ||
| 146 | 146 | var stdout, stderr bytes.Buffer |
| 147 | 147 | ctx := &control.Ctx{ |
| 148 | 148 | User: u, |
| 149 | Source: "web", | |
| 149 | Source: control.SourceWeb, | |
| 150 | 150 | Scope: "full", |
| 151 | 151 | Store: s.st, |
| 152 | 152 | Cfg: s.cfg, |
| @@ -187,7 +187,7 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i | ||
| 187 | 187 | var stdout, stderr bytes.Buffer |
| 188 | 188 | ctx := &control.Ctx{ |
| 189 | 189 | User: u, |
| 190 | Source: "web", | |
| 190 | Source: control.SourceWeb, | |
| 191 | 191 | Scope: "full", |
| 192 | 192 | Store: s.st, |
| 193 | 193 | Cfg: s.cfg, |
internal/httpd/flash.go +14
| @@ -4,6 +4,8 @@ import ( | ||
| 4 | 4 | "net/http" |
| 5 | 5 | "net/url" |
| 6 | 6 | "strings" |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/control" | |
| 7 | 9 | ) |
| 8 | 10 | |
| 9 | 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 | 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). | |
| 53 | func (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 | ||
| 47 | 61 | const nextCookie = "gitbay_next" |
| 48 | 62 | |
| 49 | 63 | // setNext remembers the local path an anonymous visitor asked for, so |
internal/httpd/reauth_test.go added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 19 | func 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. | |
| 57 | func 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. | |
| 73 | func 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. | |
| 87 | func 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 | 26 | Runners []store.RepoRunner |
| 27 | 27 | Notice string |
| 28 | 28 | Saved bool |
| 29 | Reauth bool // Notice is the stale-session refusal: link to sign in | |
| 29 | 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 | 71 | Runners: runners, |
| 71 | 72 | Notice: notice, |
| 72 | 73 | Saved: strings.HasPrefix(notice, "Saved "), |
| 74 | Reauth: s.reauthNotice(w, notice, r.URL.Path), | |
| 73 | 75 | Submitted: subm, |
| 74 | 76 | }) |
| 75 | 77 | } |
internal/httpd/web.go +4 −1
| @@ -517,6 +517,7 @@ type ownerPage struct { | ||
| 517 | 517 | Self bool |
| 518 | 518 | Snippets int |
| 519 | 519 | Notice string |
| 520 | Reauth bool // Notice is the stale-session refusal: link to sign in | |
| 520 | 521 | Feed string |
| 521 | 522 | } |
| 522 | 523 | |
| @@ -572,6 +573,7 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) { | ||
| 572 | 573 | return |
| 573 | 574 | } |
| 574 | 575 | } |
| 576 | notice := s.takeFlash(w, r) | |
| 575 | 577 | s.render(w, "owner.html", ownerPage{ |
| 576 | 578 | basePage: s.baseFor(viewer), |
| 577 | 579 | Owner: name, |
| @@ -592,7 +594,8 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) { | ||
| 592 | 594 | CanAdmin: canAdmin, |
| 593 | 595 | Self: self, |
| 594 | 596 | Snippets: d.Snippets, |
| 595 | Notice: s.takeFlash(w, r), | |
| 597 | Notice: notice, | |
| 598 | Reauth: s.reauthNotice(w, notice, r.URL.Path), | |
| 596 | 599 | Feed: "/" + name + "/activity.atom", |
| 597 | 600 | }) |
| 598 | 601 | } |
internal/web/templates/account.html +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{define "title"}}account settings{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 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 | 6 | {{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} |
| 7 | 7 | |
| 8 | 8 | <div class="withcol narrow"> |
internal/web/templates/adminusers.html +1 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | {{define "content"}} |
| 8 | 8 | <h1>Accounts</h1> |
| 9 | 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 | 12 | <nav class="filters"> |
| 13 | 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 | 84 | {{if eq .Tab "people"}}{{$org := .Owner}} |
| 85 | 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 | 87 | <div class="tablewrap"><table class="keys"> |
| 88 | 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 | 89 | {{range .Members}}<tr> |
internal/web/templates/settings.html +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {{define "content"}} |
| 4 | 4 | {{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} |
| 5 | 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 | 7 | <div class="withcol narrow"> |
| 8 | 8 | <nav class="sidecol" aria-label="Sections"> |
| 9 | 9 | <details class="sidedrop"> |