Commit b53740ec50

b53740ec50cf79a06cf2b7b5364ef0b41412236c

parent: e2dec5d9ff

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-12 04:02 UTC

web: login returns to the page that needed it

requireUser remembers a GET path in a short-lived cookie; the login
page names it and both login paths redirect there once.

Ref #182
e2e/websessions_test.go +23
@@ -84,4 +84,27 @@ func TestWebSessionsListRevoke(t *testing.T) {
8484 if _, _, code := inst.ssh(t, aliceKey, "", "web", "sessions", "revoke", "abcdefabcdef"); code != 3 {
8585 t.Fatal("unknown id accepted")
8686 }
87 // An anonymous visit to a page that needs a session lands on the
88 // login page, which says where the visitor was going; the login
89 // link then returns them there.
90 anon := newBrowser(t)
91 status, body := browserGet(t, anon, inst.base()+"/settings")
92 if status != 200 || !strings.Contains(body, "continue to <code>/settings</code>") {
93 t.Fatalf("login page without the destination: %d\n%s", status, body)
94 }
95 out, _, _ := inst.ssh(t, aliceKey, "", "web", "login", "--json")
96 var env struct {
97 Data struct {
98 URL string `json:"url"`
99 } `json:"data"`
100 }
101 json.Unmarshal([]byte(out), &env)
102 link := inst.base() + env.Data.URL[strings.Index(env.Data.URL, "/login"):]
103 if status, body := browserGet(t, anon, link); status != 200 || !strings.Contains(body, "Account settings") {
104 t.Fatalf("login did not return to /settings: %d\n%s", status, body)
105 }
106 // The destination is used once.
107 if _, body := browserGet(t, anon, inst.base()+"/login"); strings.Contains(body, "continue to") {
108 t.Fatal("next survived its use")
109 }
87110}
internal/httpd/accounts.go +15 −7
@@ -49,6 +49,9 @@ func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.Us
4949 return func(w http.ResponseWriter, r *http.Request) {
5050 u := s.viewer(r)
5151 if u.ID == 0 {
52 if r.Method == http.MethodGet {
53 s.setNext(w, r.URL.RequestURI())
54 }
5255 http.Redirect(w, r, "/login", http.StatusSeeOther)
5356 return
5457 }
@@ -76,15 +79,16 @@ func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
7679// the page can tell a brand-new visitor how to get an account. EmailLogin
7780// says whether this instance can mail a link; Sent switches the page to the
7881// confirmation that follows a request.
79func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool) {
82func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool, next string) {
8083 s.render(w, "login.html", struct {
8184 basePage
8285 Mode string // closed | invite | open
8386 Error string
8487 EmailLogin bool
8588 Sent bool
89 Next string
8690 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()},
87 s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent})
91 s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent, next})
8892}
8993
9094// emailLoginEnabled reports whether a link can be mailed at all. There is no
@@ -111,18 +115,18 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
111115 if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil {
112116 log.Printf("login link: %v", err)
113117 }
114 s.renderLogin(w, "", true)
118 s.renderLogin(w, "", true, "")
115119}
116120
117121func (s *Server) login(w http.ResponseWriter, r *http.Request) {
118122 token := r.URL.Query().Get("token")
119123 if token == "" {
120 s.renderLogin(w, "", false)
124 s.renderLogin(w, "", false, s.peekNext(r))
121125 return
122126 }
123127 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
124128 if err != nil {
125 s.renderLogin(w, badLoginToken, false)
129 s.renderLogin(w, badLoginToken, false, "")
126130 return
127131 }
128132 // A token minted before the account was suspended is still consumable,
@@ -130,7 +134,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
130134 // read. Checking here covers every mint path. The message is the one a
131135 // bad token gets: a distinct one would confirm the account exists.
132136 if u, err := s.st.UserByID(userID); err != nil || u.Disabled {
133 s.renderLogin(w, badLoginToken, false)
137 s.renderLogin(w, badLoginToken, false, "")
134138 return
135139 }
136140 sessTok, sessHash, err := store.NewToken()
@@ -143,7 +147,11 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
143147 return
144148 }
145149 http.SetCookie(w, s.sessionCookieFor(sessTok))
146 http.Redirect(w, r, "/", http.StatusSeeOther)
150 dest := s.takeNext(w, r)
151 if dest == "" {
152 dest = "/"
153 }
154 http.Redirect(w, r, dest, http.StatusSeeOther)
147155}
148156
149157// sessionCookieFor is the cookie a new session ships in. Secure follows TLS
internal/httpd/flash.go +46
@@ -3,6 +3,7 @@ package httpd
33import (
44 "net/http"
55 "net/url"
6 "strings"
67)
78
89// A form action that fails redirects back to the page it came from with
@@ -43,6 +44,51 @@ func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) string {
4344 return msg
4445}
4546
47const nextCookie = "gitbay_next"
48
49// setNext remembers the local path an anonymous visitor asked for, so
50// the login that follows can return there. Only a GET path is stored:
51// a POST must not be replayed.
52func (s *Server) setNext(w http.ResponseWriter, path string) {
53 if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || len(path) > 300 {
54 return
55 }
56 http.SetCookie(w, &http.Cookie{
57 Name: nextCookie, Value: url.QueryEscape(path), Path: "/",
58 HttpOnly: true, SameSite: http.SameSiteLaxMode,
59 Secure: s.cfg.HTTP.TLS != "off", MaxAge: 600,
60 })
61}
62
63// takeNext returns the remembered path once and clears it. Anything
64// that is not a local path comes back empty.
65func (s *Server) takeNext(w http.ResponseWriter, r *http.Request) string {
66 c, err := r.Cookie(nextCookie)
67 if err != nil || c.Value == "" {
68 return ""
69 }
70 http.SetCookie(w, s.clearCookie(nextCookie, http.SameSiteLaxMode))
71 p, err := url.QueryUnescape(c.Value)
72 if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
73 return ""
74 }
75 return p
76}
77
78// peekNext reads the remembered path without clearing it, for the
79// login page to say where the visitor is going.
80func (s *Server) peekNext(r *http.Request) string {
81 c, err := r.Cookie(nextCookie)
82 if err != nil {
83 return ""
84 }
85 p, err := url.QueryUnescape(c.Value)
86 if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
87 return ""
88 }
89 return p
90}
91
4692// clearCookie is the expiring twin of a Set-Cookie, carrying the same
4793// attributes the setting call used.
4894//
internal/web/templates/login.html +1
@@ -1,6 +1,7 @@
11{{define "title"}}login · {{.Site}}{{end}}
22{{define "content"}}
33<h1>Log in</h1>
4{{if .Next}}<p class="meta">Log in to continue to <code>{{.Next}}</code>.</p>{{end}}
45{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
56{{if .Sent}}
67<p>If that account exists, a login link is on its way. It works once and