v0.12.0 dccd128

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	"bnema.dev/ermite/internal/domain"
20	"bnema.dev/ermite/internal/ports"
21)
22
23// The owner's session on the web (ADR 0018).
24//
25// A browser signs in by opening the one-time link `ermite web login` printed:
26// GET /-/login/<nonce> shows a confirmation and consumes nothing, so link
27// previews and prefetchers cannot use it; POST /-/login/<nonce> consumes the
28// link and sets the cookie. The cookie holds a signed token; every request
29// that carries a valid one runs as the owner, every other as anonymous.
30//
31// State changes are POST only and pass one gate (ServeHTTP): an Origin equal to
32// the origin of the public URL, and a valid session, except the login POST,
33// which needs the link instead. The routes read the cookie and never the
34// Authorization header; the API does the opposite, it reads the header and no cookie.
35
36// CookieName is the session cookie. The __Host- prefix makes browsers accept
37// it only with Secure, Path=/ and no Domain, and refuse it from a sibling
38// subdomain.
39const CookieName = "__Host-ermite"
40
41// loginPrefix is the path of login links, up to the nonce.
42const loginPrefix = "/-/login/"
43
44type signedInKey struct{}
45
46// SignedIn reports whether the request carries a valid owner session. The
47// page is then private: it was rendered for the owner and must not be cached
48// by anyone else. Handlers of POST routes can rely on it: the gate refused
49// the request otherwise.
50func SignedIn(ctx context.Context) bool {
51	_, ok := ctx.Value(signedInKey{}).(struct{})
52	return ok
53}
54
55// sessionCache is the Cache-Control of a page rendered for the owner.
56const sessionCache = "private, no-store"
57
58// cacheHeaders sets Cache-Control: public for a visitor, and for the owner
59// the private variant with Vary: Cookie.
60//
61// A response that already carries no-store is kept as it is: withSession sets
62// it when it clears an invalid cookie, and a later public Cache-Control must
63// not undo it.
64func cacheHeaders(ctx context.Context, h http.Header, public, private string) {
65	if SignedIn(ctx) {
66		h.Set("Cache-Control", private)
67		varyCookie(h)
68		return
69	}
70	if h.Get("Cache-Control") == noStore {
71		return
72	}
73	h.Set("Cache-Control", public)
74}
75
76// noStore is the Cache-Control of an answer no cache may keep.
77const noStore = "no-store"
78
79// varyCookie adds Vary: Cookie unless the header already says so.
80func varyCookie(h http.Header) {
81	for _, v := range h.Values("Vary") {
82		for f := range strings.SplitSeq(v, ",") {
83			if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, "Cookie") {
84				return
85			}
86		}
87	}
88	h.Add("Vary", "Cookie")
89}
90
91// logPath is the path of a request as the log may show it: a login link is
92// a secret. The decoded path is given, so the check is wide on purpose: a
93// path that only reaches the link after cleaning ("/x/../-/login/…",
94// "//-/login/…") or that holds the prefix further in is cut too.
95func logPath(p string) string {
96	if strings.HasPrefix(path.Clean(p), loginPrefix) || strings.Contains(p, loginPrefix) {
97		return loginPrefix + "…"
98	}
99	return p
100}
101
102// withSession resolves the session of the request: the cookie, when valid,
103// makes the request the owner's. A cookie that is not valid is cleared. A
104// request without a cookie is untouched.
105func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request {
106	if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.EscapedPath(), StaticPrefix) {
107		return r
108	}
109	c, err := r.Cookie(CookieName)
110	if err != nil {
111		return r
112	}
113	ctx := r.Context()
114	owner, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
115	switch {
116	case err != nil:
117		// Fail closed, and keep the cookie: the failure may be passing.
118		log := zerowrap.FromCtx(ctx)
119		log.Error().Err(err).Msg("check web session")
120		return r
121	case !ok:
122		// The answer depends on a cookie that is gone now: no cache keeps it
123		// for another visitor, and none serves it for a request with a cookie.
124		http.SetCookie(w, s.cookie("", -1))
125		h := w.Header()
126		h.Set("Cache-Control", noStore)
127		varyCookie(h)
128		return r
129	}
130	ctx = domain.ContextWithPrincipal(ctx, owner)
131	return r.WithContext(context.WithValue(ctx, signedInKey{}, struct{}{}))
132}
133
134// principal is the caller of the request, the one every use case call carries:
135// the owner when the request has a valid session (withSession), anonymous
136// otherwise.
137func principal(ctx context.Context) domain.Principal {
138	p, ok := domain.PrincipalFromContext(ctx)
139	if !ok {
140		return domain.Anonymous()
141	}
142	return p
143}
144
145// sessionStillValid checks again the cookie a long-lived request started
146// with. It fails closed: a check that cannot be made ends the session of the
147// stream.
148func (s *Server) sessionStillValid(ctx context.Context, r *http.Request) bool {
149	c, err := r.Cookie(CookieName)
150	if err != nil {
151		return false
152	}
153	_, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
154	return err == nil && ok
155}
156
157// cookie builds the session cookie. maxAge < 0 deletes it.
158func (s *Server) cookie(token string, maxAge int) *http.Cookie {
159	// SameSite=Strict, as ADR 0018 says: the cookie is not sent when another
160	// site links to a page (that first page shows as signed out, the next
161	// navigation on the site is signed in), and not on any cross-site request,
162	// on top of the Origin check of the POST gate.
163	return &http.Cookie{
164		Name: CookieName, Value: token, Path: "/", MaxAge: maxAge,
165		Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode,
166	}
167}
168
169// gate refuses a state change that does not come from a page of this site
170// with a session. It answers 403 and returns false. GET and HEAD pass: they
171// never change anything.
172func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
173	if r.Method == http.MethodGet || r.Method == http.MethodHead {
174		return true
175	}
176	// The Origin is compared with the configured public URL, never with the
177	// Host header, which the client chooses. A missing header, "null" and any
178	// other origin are refused.
179	if r.Header.Get("Origin") != s.origin {
180		denyForbidden(w)
181		return false
182	}
183	if !SignedIn(r.Context()) && !s.anonymousPost(r) {
184		denyForbidden(w)
185		return false
186	}
187	return true
188}
189
190// anonymousPost tells whether a POST without a session may pass the gate: the
191// requests that start a session. They are the confirmation of a login link and,
192// when passkeys are on, the two endpoints of a passkey sign-in; each is matched
193// exactly.
194func (s *Server) anonymousPost(r *http.Request) bool {
195	if r.Method != http.MethodPost {
196		return false
197	}
198	return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
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", []Crumb{{Label: "sign in", Current: true}})
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 := ports.ErrLinkRejected
279	if validNonce(nonce) {
280		token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce)
281	}
282	switch {
283	case errors.Is(err, ports.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, []Crumb{{Label: crumb, Current: true}})
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