v0.33.0 cf26365

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

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