v0.3.0 9fdf386

session.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"net/http"
9	"net/url"
10	"path"
11	"strconv"
12	"strings"
13	"time"
14
15	"github.com/a-h/templ"
16	"github.com/bnema/zerowrap"
17
18	"bnema.dev/ermite/internal/clientlimit"
19)
20
21// The owner's session on the web (ADR 0018).
22//
23// A browser signs in by opening the one-time link `ermite web login` printed:
24// GET /-/login/<nonce> shows a confirmation and consumes nothing, so link
25// previews and prefetchers cannot use it; POST /-/login/<nonce> consumes the
26// link and sets the cookie. The cookie holds a signed token; every request
27// that carries a valid one runs as the owner, every other as anonymous.
28//
29// State changes are POST only and pass one gate (ServeHTTP): an Origin equal to
30// the origin of the public URL, and a valid session, except the login POST,
31// which needs the link instead. The routes read the cookie and never the
32// Authorization header; the API does the opposite.
33
34// CookieName is the session cookie. The __Host- prefix makes browsers accept
35// it only with Secure, Path=/ and no Domain, and refuse it from a sibling
36// subdomain.
37const CookieName = "__Host-ermite"
38
39// loginPrefix is the path of login links, up to the nonce.
40const loginPrefix = "/-/login/"
41
42// ErrLinkRejected is what SessionAuth.Login returns for a link that is
43// unknown, expired or already used.
44var ErrLinkRejected = errors.New("login link rejected")
45
46// SessionAuth is the session side of the use cases, as the web needs it.
47type SessionAuth interface {
48	// LoginLinkValid reports whether the link is pending, without using it.
49	LoginLinkValid(nonce string) bool
50	// Login uses the link and returns the session token and how long it
51	// lasts, or ErrLinkRejected.
52	Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error)
53	// Verify checks a session token. When it is valid it returns the context
54	// to serve the request with, which carries the owner as the caller of the
55	// API; when it is not, ok is false and err nil. An error means the check
56	// could not be made (the request then runs as anonymous).
57	Verify(ctx context.Context, token string) (owner context.Context, ok bool, err error)
58}
59
60// FailureLimiter counts failed logins per client, with the failed
61// authentications of the other routes. *clientlimit.Limiter fits.
62type FailureLimiter interface {
63	Blocked(ctx context.Context, addr string) (bool, time.Duration)
64	Fail(ctx context.Context, addr string)
65}
66
67type signedInKey struct{}
68
69// SignedIn reports whether the request carries a valid owner session. The
70// page is then private: it was rendered for the owner and must not be cached
71// by anyone else. Handlers of POST routes can rely on it: the gate refused
72// the request otherwise.
73func SignedIn(ctx context.Context) bool {
74	_, ok := ctx.Value(signedInKey{}).(struct{})
75	return ok
76}
77
78// sessionCache is the Cache-Control of a page rendered for the owner.
79const sessionCache = "private, no-store"
80
81// cacheHeaders sets Cache-Control: public for a visitor, and for the owner
82// the private variant with Vary: Cookie.
83//
84// A response that already carries no-store is kept as it is: withSession sets
85// it when it clears an invalid cookie, and a later public Cache-Control must
86// not undo it.
87func cacheHeaders(ctx context.Context, h http.Header, public, private string) {
88	if SignedIn(ctx) {
89		h.Set("Cache-Control", private)
90		varyCookie(h)
91		return
92	}
93	if h.Get("Cache-Control") == noStore {
94		return
95	}
96	h.Set("Cache-Control", public)
97}
98
99// noStore is the Cache-Control of an answer no cache may keep.
100const noStore = "no-store"
101
102// varyCookie adds Vary: Cookie unless the header already says so.
103func varyCookie(h http.Header) {
104	for _, v := range h.Values("Vary") {
105		for f := range strings.SplitSeq(v, ",") {
106			if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, "Cookie") {
107				return
108			}
109		}
110	}
111	h.Add("Vary", "Cookie")
112}
113
114// logPath is the path of a request as the log may show it: a login link is
115// a secret. The decoded path is given, so the check is wide on purpose: a
116// path that only reaches the link after cleaning ("/x/../-/login/…",
117// "//-/login/…") or that holds the prefix further in is cut too.
118func logPath(p string) string {
119	if strings.HasPrefix(path.Clean(p), loginPrefix) || strings.Contains(p, loginPrefix) {
120		return loginPrefix + "…"
121	}
122	return p
123}
124
125// withSession resolves the session of the request: the cookie, when valid,
126// makes the request the owner's. A cookie that is not valid is cleared. A
127// request without a cookie is untouched.
128func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request {
129	if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.EscapedPath(), StaticPrefix) {
130		return r
131	}
132	c, err := r.Cookie(CookieName)
133	if err != nil {
134		return r
135	}
136	ctx := r.Context()
137	owner, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
138	switch {
139	case err != nil:
140		// Fail closed, and keep the cookie: the failure may be passing.
141		log := zerowrap.FromCtx(ctx)
142		log.Error().Err(err).Msg("check web session")
143		return r
144	case !ok:
145		// The answer depends on a cookie that is gone now: no cache keeps it
146		// for another visitor, and none serves it for a request with a cookie.
147		http.SetCookie(w, s.cookie("", -1))
148		h := w.Header()
149		h.Set("Cache-Control", noStore)
150		varyCookie(h)
151		return r
152	}
153	return r.WithContext(context.WithValue(owner, signedInKey{}, struct{}{}))
154}
155
156// sessionStillValid checks again the cookie a long-lived request started
157// with. It fails closed: a check that cannot be made ends the session of the
158// stream.
159func (s *Server) sessionStillValid(ctx context.Context, r *http.Request) bool {
160	c, err := r.Cookie(CookieName)
161	if err != nil {
162		return false
163	}
164	_, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
165	return err == nil && ok
166}
167
168// cookie builds the session cookie. maxAge < 0 deletes it.
169func (s *Server) cookie(token string, maxAge int) *http.Cookie {
170	// SameSite=Strict, as ADR 0018 says: the cookie is not sent when another
171	// site links to a page (that first page shows as signed out, the next
172	// navigation on the site is signed in), and not on any cross-site request,
173	// on top of the Origin check of the POST gate.
174	return &http.Cookie{
175		Name: CookieName, Value: token, Path: "/", MaxAge: maxAge,
176		Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode,
177	}
178}
179
180// gate refuses a state change that does not come from a page of this site
181// with a session. It answers 403 and returns false. GET and HEAD pass: they
182// never change anything.
183func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
184	if r.Method == http.MethodGet || r.Method == http.MethodHead {
185		return true
186	}
187	// The Origin is compared with the configured public URL, never with the
188	// Host header, which the client chooses. A missing header, "null" and any
189	// other origin are refused.
190	if r.Header.Get("Origin") != s.origin {
191		denyForbidden(w)
192		return false
193	}
194	if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) {
195		denyForbidden(w)
196		return false
197	}
198	return true
199}
200
201// isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
202// the right shape and nothing after it. It looks at the escaped path, which is
203// what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass
204// for a link while the router sends it to another route.
205func isLoginPath(u *url.URL) bool {
206	nonce, ok := strings.CutPrefix(u.EscapedPath(), loginPrefix)
207	return ok && validNonce(nonce)
208}
209
210func denyForbidden(w http.ResponseWriter) {
211	w.Header().Set("Cache-Control", "no-store")
212	http.Error(w, "forbidden", http.StatusForbidden)
213}
214
215// validNonce tells whether s has the shape of a login link secret, so that
216// anything else is refused before it reaches the store.
217func validNonce(s string) bool {
218	if len(s) != nonceLen {
219		return false
220	}
221	for _, c := range []byte(s) {
222		switch {
223		case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
224		default:
225			return false
226		}
227	}
228	return true
229}
230
231// nonceLen is the length of the secret of a login link: 32 bytes in unpadded
232// base64url.
233const nonceLen = 43
234
235// secretPage writes a page of the login flow: never cached, and its address,
236// which holds the link, is never sent along: strict-origin sends only the
237// origin. no-referrer would also turn the Origin of the sign-in POST into
238// "null" in browsers, which the POST gate refuses.
239func (s *Server) secretPage(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
240	h := w.Header()
241	h.Set("Cache-Control", "no-store")
242	h.Set("Referrer-Policy", "strict-origin")
243	s.write(r.Context(), w, r, status, c)
244}
245
246// loginConfirm is GET /-/login/<nonce>: it shows the confirmation and does not
247// use the link.
248func (s *Server) loginConfirm(w http.ResponseWriter, r *http.Request) {
249	nonce := r.PathValue("nonce")
250	if !validNonce(nonce) || !s.cfg.Sessions.LoginLinkValid(nonce) {
251		s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
252		return
253	}
254	l := s.stateLayout("sign in", "sign in · confirm", []Crumb{{Label: "-"}, {Label: "sign in"}})
255	s.secretPage(w, r, http.StatusOK, Login(l, LoginPage{
256		Title:  "sign in as the owner",
257		Lines:  []string{"this browser will be signed in for 12 hours.", "only confirm if you ran `ermite web login` just now."},
258		Action: loginPrefix + nonce,
259		Button: "[sign in]",
260	}))
261}
262
263// loginPost is POST /-/login/<nonce>: it uses the link, sets the cookie and
264// goes home. A link that does not work answers the same page whatever the
265// reason, and counts as a failed authentication of the client.
266func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
267	ctx := r.Context()
268	addr := clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
269	if blocked, wait := s.cfg.Limiter.Blocked(ctx, addr); blocked {
270		w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
271		s.secretPage(w, r, http.StatusTooManyRequests, s.stateOnly("sign in", "429", "too many attempts",
272			"too many failed sign-in attempts from this address.", "try again later."))
273		return
274	}
275	nonce := r.PathValue("nonce")
276	var token string
277	var lifetime time.Duration
278	err := ErrLinkRejected
279	if validNonce(nonce) {
280		token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce)
281	}
282	switch {
283	case errors.Is(err, ErrLinkRejected):
284		s.cfg.Limiter.Fail(ctx, addr)
285		s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
286		return
287	case err != nil:
288		id := requestID()
289		log := zerowrap.FromCtx(ctx)
290		log.Error().Err(err).Str("request_id", id).Msg("web login failed")
291		s.secretPage(w, r, http.StatusInternalServerError, s.errorPage(id))
292		return
293	}
294	h := w.Header()
295	h.Del("Set-Cookie") // a stale cookie cleared by withSession
296	http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
297	h.Set("Cache-Control", "no-store")
298	http.Redirect(w, r, "/", http.StatusSeeOther)
299}
300
301// logout is POST /-/logout: it ends the session of this browser. The token
302// itself stays valid until it expires, as a stateless token must; `ermite web
303// logout --all` ends them all.
304func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
305	w.Header().Del("Set-Cookie")
306	http.SetCookie(w, s.cookie("", -1))
307	w.Header().Set("Cache-Control", "no-store")
308	http.Redirect(w, r, "/", http.StatusSeeOther)
309}
310
311// stateOnly is a state page with a title and two lines, for the login flow.
312func (s *Server) stateOnly(crumb, code, title string, lines ...string) templ.Component {
313	l := s.stateLayout(title, code+" · "+title, []Crumb{{Label: "-"}, {Label: crumb}})
314	return State(l, StatePage{Code: code, Title: title, Lines: lines, Back: []Link{{Label: "home", Href: "/"}}})
315}
316
317// loginRejectedPage is the answer for a link that is unknown, expired or used.
318func (s *Server) loginRejectedPage() templ.Component {
319	return s.stateOnly("sign in", "404", "login link not valid",
320		"this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")
321}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close