v0.31.0 d5e0505

passkeys.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"encoding/base64"
8	"encoding/json"
9	"errors"
10	"io"
11	"mime"
12	"net/http"
13	"strconv"
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 passkeys on the web (ADR 0026).
24//
25// The pages are GET: /-/login (sign in, anonymous) and /-/passkeys (the
26// owner's list). The ceremonies are JSON POSTs the script static/js/passkey.js
27// makes, each in two steps (begin, finish). Every POST passes the gate: an
28// Origin equal to the public origin, and a session, except the two login
29// endpoints, which are how a session starts. The finish endpoints also need
30// "Content-Type: application/json", which a cross-site form cannot send.
31
32const (
33	passkeyLoginPath = "/-/login"
34	passkeysPath     = "/-/passkeys"
35
36	loginBeginPath  = passkeysPath + "/login/begin"
37	loginFinishPath = passkeysPath + "/login/finish"
38
39	// maxCeremonyBytes bounds the body of a finish: a response of an
40	// authenticator is well under 10 KiB.
41	maxCeremonyBytes = 64 << 10
42)
43
44// passkeysOn tells that the passkey routes exist.
45func (s *Server) passkeysOn() bool { return s.cfg.Passkeys != nil && s.cfg.Sessions != nil }
46
47func (s *Server) passkeyRoutes(mux *http.ServeMux) {
48	mux.HandleFunc("GET "+passkeyLoginPath, s.passkeyLoginPage)
49	mux.HandleFunc("POST "+loginBeginPath, s.passkeyLoginBegin)
50	mux.HandleFunc("POST "+loginFinishPath, s.passkeyLoginFinish)
51	mux.HandleFunc("GET "+passkeysPath, s.page(s.passkeysPage))
52	mux.HandleFunc("POST "+passkeysPath+"/register/begin", s.passkeyRegisterBegin)
53	mux.HandleFunc("POST "+passkeysPath+"/register/finish", s.passkeyRegisterFinish)
54	mux.HandleFunc("POST "+passkeysPath+"/{id}/remove", s.passkeyRemove)
55}
56
57// isPasskeyLoginPath tells whether u is exactly one of the two endpoints an
58// anonymous browser may post to. Like isLoginPath it looks at the escaped path,
59// which is what the router matches.
60func isPasskeyLoginPath(path string) bool {
61	return path == loginBeginPath || path == loginFinishPath
62}
63
64// jsonReply writes a JSON answer that no cache keeps.
65func jsonReply(w http.ResponseWriter, status int, v any) {
66	h := w.Header()
67	h.Set("Content-Type", "application/json")
68	h.Set("Cache-Control", noStore)
69	w.WriteHeader(status)
70	_ = json.NewEncoder(w).Encode(v)
71}
72
73type jsonMessage struct {
74	Error string `json:"error"`
75}
76
77func jsonError(w http.ResponseWriter, status int, msg string) {
78	jsonReply(w, status, jsonMessage{Error: msg})
79}
80
81// jsonInternal answers a failure that is not the caller's to fix: an id for the
82// log, and no detail.
83func (s *Server) jsonInternal(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
84	id := requestID()
85	log := zerowrap.FromCtx(ctx)
86	log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web passkey request failed")
87	jsonError(w, http.StatusInternalServerError, "internal error, request id "+id)
88}
89
90// jsonBody checks the content type of a finish and reads its body, bounded. It
91// answers 415 or 413 and returns false when it cannot.
92func jsonBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
93	mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
94	if err != nil || mt != "application/json" {
95		jsonError(w, http.StatusUnsupportedMediaType, "send application/json")
96		return nil, false
97	}
98	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCeremonyBytes))
99	if err != nil {
100		var tooBig *http.MaxBytesError
101		if errors.As(err, &tooBig) {
102			jsonError(w, http.StatusRequestEntityTooLarge, "request too large")
103		} else {
104			jsonError(w, http.StatusBadRequest, "request could not be read")
105		}
106		return nil, false
107	}
108	return body, true
109}
110
111// ceremonyReply is the answer of a begin.
112type ceremonyReply struct {
113	Ceremony string          `json:"ceremony"`
114	Options  json.RawMessage `json:"options"`
115}
116
117// blocked answers 429 when the client has failed too many sign-ins.
118func (s *Server) blocked(w http.ResponseWriter, r *http.Request) (addr string, blocked bool) {
119	addr = clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
120	if is, wait := s.cfg.Limiter.Blocked(r.Context(), addr); is {
121		w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
122		jsonError(w, http.StatusTooManyRequests, "too many attempts")
123		return addr, true
124	}
125	return addr, false
126}
127
128// passkeyLoginPage is GET /-/login: the sign-in page. A signed-in visitor goes
129// home.
130func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) {
131	if SignedIn(r.Context()) {
132		seeOther(w, r, "/")
133		return
134	}
135	l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}})
136	s.secretPage(w, r, http.StatusOK, PasskeyLogin(l, PasskeyLoginPage{
137		Title:  "sign in",
138		Button: "Sign in with a passkey",
139		Lines:  []string{"or run `ermite web login` in a terminal."},
140		Begin:  loginBeginPath,
141		Finish: loginFinishPath,
142	}))
143}
144
145// passkeyLoginBegin is POST /-/passkeys/login/begin.
146func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
147	addr, blocked := s.blocked(w, r)
148	if blocked {
149		return
150	}
151	ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
152	defer cancel()
153	ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx, clientlimit.Key(addr))
154	if err != nil {
155		s.jsonInternal(ctx, w, r, err)
156		return
157	}
158	jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
159}
160
161// finishRequest is the body of a finish. The name of a new passkey is given at
162// the begin of its registration, not here.
163type finishRequest struct {
164	Ceremony   string          `json:"ceremony"`
165	Credential json.RawMessage `json:"credential"`
166}
167
168// readFinish reads the body of a finish: JSON, bounded, with a ceremony and a
169// credential. It answers 415, 413 or 400 and returns false when it cannot. A
170// body that fails here is a bug of the client, not a guess at a passkey: the
171// callers do not count it as a failed sign-in.
172func readFinish(w http.ResponseWriter, r *http.Request) (finishRequest, bool) {
173	body, ok := jsonBody(w, r)
174	if !ok {
175		return finishRequest{}, false
176	}
177	var req finishRequest
178	if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
179		jsonError(w, http.StatusBadRequest, "malformed request")
180		return finishRequest{}, false
181	}
182	return req, true
183}
184
185// passkeyLoginFinish is POST /-/passkeys/login/finish: it checks the response
186// of the authenticator and sets the session cookie, the same as a login link.
187// A refusal counts as a failed authentication of the client, whatever the
188// reason.
189func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
190	// The limiter first: a client that is blocked gets 429 whatever it sends.
191	addr, blocked := s.blocked(w, r)
192	if blocked {
193		return
194	}
195	req, ok := readFinish(w, r)
196	if !ok {
197		return
198	}
199	ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
200	defer cancel()
201	token, lifetime, err := s.cfg.Passkeys.FinishLogin(ctx, req.Ceremony, req.Credential)
202	switch {
203	case errors.Is(err, ports.ErrLinkRejected):
204		s.cfg.Limiter.Fail(ctx, addr)
205		jsonError(w, http.StatusUnauthorized, "passkey not accepted")
206		return
207	case err != nil:
208		s.jsonInternal(ctx, w, r, err)
209		return
210	}
211	h := w.Header()
212	h.Del("Set-Cookie") // a stale cookie cleared by withSession
213	http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
214	h.Set("Cache-Control", noStore)
215	w.WriteHeader(http.StatusNoContent)
216}
217
218// passkeysPage is GET /-/passkeys: the owner's list. Anybody else gets the 404
219// page, as for any page that does not exist for them.
220func (s *Server) passkeysPage(ctx context.Context, _ *http.Request) (templ.Component, int, error) {
221	if !SignedIn(ctx) {
222		return nil, 0, errNotFound
223	}
224	list, err := s.cfg.Passkeys.List(ctx, principal(ctx))
225	if err != nil {
226		return nil, 0, err
227	}
228	now := s.cfg.Now()
229	rows := make([]PasskeyRow, 0, len(list))
230	for _, p := range list {
231		rows = append(rows, PasskeyRow{
232			Name:    p.Name,
233			Created: ago(now, p.CreatedAt), CreatedAt: rfc3339(p.CreatedAt),
234			LastUsed: ago(now, p.LastUsedAt), LastUsedAt: rfc3339(p.LastUsedAt),
235			Synced: p.BackupState,
236			Remove: passkeysPath + "/" + base64.RawURLEncoding.EncodeToString(p.ID) + "/remove",
237		})
238	}
239	l := s.base()
240	l.Title = "passkeys · " + s.instance
241	l.Crumbs = []Crumb{{Label: "passkeys", Current: true}}
242	l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
243	return Passkeys(l, PasskeysPage{
244		Rows: rows, Full: len(list) >= domain.MaxPasskeys, Max: domain.MaxPasskeys,
245		Begin: passkeysPath + "/register/begin", Finish: passkeysPath + "/register/finish",
246	}), http.StatusOK, nil
247}
248
249// registerError answers a refused registration step.
250func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
251	switch {
252	case errors.Is(err, context.Canceled):
253		// The browser is gone.
254	case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated) && !SignedIn(ctx):
255		jsonError(w, http.StatusForbidden, "forbidden")
256	case errors.Is(err, domain.ErrAlreadyExists):
257		jsonError(w, http.StatusConflict, "this passkey is already registered")
258	case errors.Is(err, domain.ErrResourceExhausted):
259		jsonError(w, http.StatusTooManyRequests, rule(err.Error(), "a limit is reached"))
260	case domain.IsInvalid(err):
261		jsonError(w, http.StatusBadRequest, rule(err.Error(), "the name is not valid"))
262	case errors.Is(err, domain.ErrUnauthenticated):
263		jsonError(w, http.StatusBadRequest, "passkey not accepted, try again")
264	default:
265		s.jsonInternal(ctx, w, r, err)
266	}
267}
268
269// passkeyRegisterBegin is POST /-/passkeys/register/begin. Like the finish
270// endpoints it needs "Content-Type: application/json".
271func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
272	body, ok := jsonBody(w, r)
273	if !ok {
274		return
275	}
276	// {"name": "..."}; an empty body is no name. The name is checked now, before
277	// the browser prompts the owner for a passkey that would then be refused.
278	var req struct {
279		Name string `json:"name"`
280	}
281	if len(body) > 0 {
282		if err := json.Unmarshal(body, &req); err != nil {
283			jsonError(w, http.StatusBadRequest, "malformed request")
284			return
285		}
286	}
287	ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
288	defer cancel()
289	ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx), req.Name)
290	if err != nil {
291		s.registerError(ctx, w, r, err)
292		return
293	}
294	jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
295}
296
297// passkeyRegisterFinish is POST /-/passkeys/register/finish.
298func (s *Server) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
299	req, ok := readFinish(w, r)
300	if !ok {
301		return
302	}
303	ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
304	defer cancel()
305	if err := s.cfg.Passkeys.FinishRegistration(ctx, principal(ctx), req.Ceremony, req.Credential); err != nil {
306		s.registerError(ctx, w, r, err)
307		return
308	}
309	w.Header().Set("Cache-Control", noStore)
310	w.WriteHeader(http.StatusNoContent)
311}
312
313// passkeyRemove is POST /-/passkeys/{id}/remove: it removes the passkey, which
314// signs every browser out, this one included: the answer sends it to the
315// sign-in page.
316func (s *Server) passkeyRemove(w http.ResponseWriter, r *http.Request) {
317	ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
318	defer cancel()
319	id, err := base64.RawURLEncoding.DecodeString(r.PathValue("id"))
320	if err != nil || len(id) == 0 {
321		s.actionFailed(ctx, w, r, errNotFound, passkeysPath)
322		return
323	}
324	if err := s.cfg.Passkeys.Remove(ctx, principal(ctx), id); err != nil {
325		s.actionFailed(ctx, w, r, err, passkeysPath)
326		return
327	}
328	seeOther(w, r, passkeyLoginPath)
329}

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