Commit b53740ec50
Verified · cmc
e2e/websessions_test.go +23
| @@ -84,4 +84,27 @@ func TestWebSessionsListRevoke(t *testing.T) { | ||
| 84 | 84 | if _, _, code := inst.ssh(t, aliceKey, "", "web", "sessions", "revoke", "abcdefabcdef"); code != 3 { |
| 85 | 85 | t.Fatal("unknown id accepted") |
| 86 | 86 | } |
| 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 | } | |
| 87 | 110 | } |
internal/httpd/accounts.go +15 −7
| @@ -49,6 +49,9 @@ func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.Us | ||
| 49 | 49 | return func(w http.ResponseWriter, r *http.Request) { |
| 50 | 50 | u := s.viewer(r) |
| 51 | 51 | if u.ID == 0 { |
| 52 | if r.Method == http.MethodGet { | |
| 53 | s.setNext(w, r.URL.RequestURI()) | |
| 54 | } | |
| 52 | 55 | http.Redirect(w, r, "/login", http.StatusSeeOther) |
| 53 | 56 | return |
| 54 | 57 | } |
| @@ -76,15 +79,16 @@ func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc { | ||
| 76 | 79 | // the page can tell a brand-new visitor how to get an account. EmailLogin |
| 77 | 80 | // says whether this instance can mail a link; Sent switches the page to the |
| 78 | 81 | // confirmation that follows a request. |
| 79 | func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool) { | |
| 82 | func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool, next string) { | |
| 80 | 83 | s.render(w, "login.html", struct { |
| 81 | 84 | basePage |
| 82 | 85 | Mode string // closed | invite | open |
| 83 | 86 | Error string |
| 84 | 87 | EmailLogin bool |
| 85 | 88 | Sent bool |
| 89 | Next string | |
| 86 | 90 | }{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}) | |
| 88 | 92 | } |
| 89 | 93 | |
| 90 | 94 | // 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) { | ||
| 111 | 115 | if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil { |
| 112 | 116 | log.Printf("login link: %v", err) |
| 113 | 117 | } |
| 114 | s.renderLogin(w, "", true) | |
| 118 | s.renderLogin(w, "", true, "") | |
| 115 | 119 | } |
| 116 | 120 | |
| 117 | 121 | func (s *Server) login(w http.ResponseWriter, r *http.Request) { |
| 118 | 122 | token := r.URL.Query().Get("token") |
| 119 | 123 | if token == "" { |
| 120 | s.renderLogin(w, "", false) | |
| 124 | s.renderLogin(w, "", false, s.peekNext(r)) | |
| 121 | 125 | return |
| 122 | 126 | } |
| 123 | 127 | userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) |
| 124 | 128 | if err != nil { |
| 125 | s.renderLogin(w, badLoginToken, false) | |
| 129 | s.renderLogin(w, badLoginToken, false, "") | |
| 126 | 130 | return |
| 127 | 131 | } |
| 128 | 132 | // 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) { | ||
| 130 | 134 | // read. Checking here covers every mint path. The message is the one a |
| 131 | 135 | // bad token gets: a distinct one would confirm the account exists. |
| 132 | 136 | if u, err := s.st.UserByID(userID); err != nil || u.Disabled { |
| 133 | s.renderLogin(w, badLoginToken, false) | |
| 137 | s.renderLogin(w, badLoginToken, false, "") | |
| 134 | 138 | return |
| 135 | 139 | } |
| 136 | 140 | sessTok, sessHash, err := store.NewToken() |
| @@ -143,7 +147,11 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { | ||
| 143 | 147 | return |
| 144 | 148 | } |
| 145 | 149 | 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) | |
| 147 | 155 | } |
| 148 | 156 | |
| 149 | 157 | // sessionCookieFor is the cookie a new session ships in. Secure follows TLS |
internal/httpd/flash.go +46
| @@ -3,6 +3,7 @@ package httpd | ||
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | 5 | "net/url" |
| 6 | "strings" | |
| 6 | 7 | ) |
| 7 | 8 | |
| 8 | 9 | // 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 { | ||
| 43 | 44 | return msg |
| 44 | 45 | } |
| 45 | 46 | |
| 47 | const 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. | |
| 52 | func (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. | |
| 65 | func (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. | |
| 80 | func (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 | ||
| 46 | 92 | // clearCookie is the expiring twin of a Set-Cookie, carrying the same |
| 47 | 93 | // attributes the setting call used. |
| 48 | 94 | // |
internal/web/templates/login.html +1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {{define "title"}}login · {{.Site}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <h1>Log in</h1> |
| 4 | {{if .Next}}<p class="meta">Log in to continue to <code>{{.Next}}</code>.</p>{{end}} | |
| 4 | 5 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| 5 | 6 | {{if .Sent}} |
| 6 | 7 | <p>If that account exists, a login link is on its way. It works once and |