Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 26m ago 32290114
history blame raw 25.8 KiB
1package web
2
3// Browser authentication: passkey ceremonies, sessions and CSRF.
4//
5// The browser is a second credential for an account that already exists; it is
6// not a second account. A passkey assertion resolves to the same account name an
7// SSH key does, and every write still goes through that name.
8
9import (
10 "context"
11 "crypto/rand"
12 "crypto/subtle"
13 "encoding/base64"
14 "encoding/json"
15 "errors"
16 "fmt"
17 "net"
18 "net/http"
19 "net/url"
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/go-webauthn/webauthn/protocol"
25 "github.com/go-webauthn/webauthn/webauthn"
26
27 "rodent.software/vole/forge/access"
28 "rodent.software/vole/forge/config"
29 "rodent.software/vole/forge/identity"
30 "rodent.software/vole/forge/session"
31 "rodent.software/vole/internal/compress"
32)
33
34const (
35 sessionCookieName = "__Host-vole_session"
36 ceremonyCookieName = "__Host-vole_ceremony"
37 ceremonyTTL = 5 * time.Minute
38
39 // Development login uses its own cookie because __Host- requires Secure,
40 // and a loopback HTTP preview cannot set a Secure cookie. The name is
41 // deliberately not a __Host- name: the browser must be willing to store it.
42 devSessionCookieName = "vole_dev_session"
43 devOffCookieName = "vole_dev_login_off"
44)
45
46type Auth struct {
47 WA *webauthn.WebAuthn
48 RPID string
49 Origin string
50 Disabled string
51}
52
53func (a *Auth) Enabled() bool { return a != nil && a.WA != nil }
54
55// newAuth enables passkeys only when the operator has told Vole the canonical
56// HTTPS origin. WebAuthn is bound to that origin; guessing it from Host would
57// make a misconfigured proxy an authentication bypass.
58func newAuth(cfg *config.Config) (*Auth, error) {
59 if strings.TrimSpace(cfg.PublicURL) == "" {
60 return &Auth{Disabled: "set public_url to an https URL to enable browser sign-in"}, nil
61 }
62 u, err := url.Parse(cfg.PublicURL)
63 if err != nil || u.Scheme != "https" || u.Host == "" {
64 return &Auth{Disabled: "browser sign-in needs public_url to be an https URL"}, nil
65 }
66 rpID := strings.ToLower(u.Hostname())
67 if rpID == "" {
68 return &Auth{Disabled: "public_url has no hostname"}, nil
69 }
70 origin := u.Scheme + "://" + u.Host
71 wa, err := webauthn.New(&webauthn.Config{
72 RPID: rpID,
73 RPDisplayName: "Vole",
74 RPOrigins: []string{origin},
75 AttestationPreference: protocol.PreferNoAttestation,
76 AuthenticatorSelection: protocol.AuthenticatorSelection{
77 UserVerification: protocol.VerificationRequired,
78 ResidentKey: protocol.ResidentKeyRequirementRequired,
79 },
80 })
81 if err != nil {
82 return nil, fmt.Errorf("webauthn: %w", err)
83 }
84 return &Auth{WA: wa, RPID: rpID, Origin: origin}, nil
85}
86
87type principalKey struct{}
88
89type principal struct {
90 account *identity.Account
91 session *session.Session
92}
93
94func (w *Web) principal(r *http.Request) *principal {
95 p, _ := r.Context().Value(principalKey{}).(*principal)
96 return p
97}
98
99// statusWriter records whether the response has started. A recover can still
100// answer with the styled 500 page only while the header is unsent; once bytes
101// are on the wire the connection is the only thing left to close.
102type statusWriter struct {
103 http.ResponseWriter
104 wroteHeader bool
105 status int
106}
107
108func (s *statusWriter) WriteHeader(code int) {
109 if !s.wroteHeader {
110 s.status = code
111 }
112 s.wroteHeader = true
113 s.ResponseWriter.WriteHeader(code)
114}
115
116func (s *statusWriter) Write(b []byte) (int, error) {
117 if !s.wroteHeader {
118 s.wroteHeader = true
119 s.status = http.StatusOK
120 }
121 return s.ResponseWriter.Write(b)
122}
123
124// Flush keeps streaming handlers (git's side-band progress) working through
125// the wrapper.
126func (s *statusWriter) Flush() {
127 if f, ok := s.ResponseWriter.(http.Flusher); ok {
128 f.Flush()
129 }
130}
131
132func (s *statusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }
133
134// Middleware resolves the session cookie once per request and sets the browser
135// security headers every response carries. An invalid or expired cookie is
136// cleared rather than left to be retried on every page.
137func (w *Web) Middleware(next http.Handler) http.Handler {
138 return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
139 w.securityHeaders(rw, r)
140 r = withMemo(r)
141 if w.Sessions != nil {
142 sess, cookieName := w.sessionFromCookie(r)
143 if sess != nil {
144 if acct, err := w.Ident.Find(sess.Name); err == nil && acct.Active() {
145 r = w.withPrincipal(r, acct, sess)
146 } else {
147 w.clearCookieFor(rw, cookieName, cookieName != devSessionCookieName)
148 }
149 } else if cookieName != "" {
150 w.clearCookieFor(rw, cookieName, cookieName != devSessionCookieName)
151 }
152 // Development login: only when configured, only from loopback, and
153 // only after an explicit logout has not opted out for this browser.
154 if w.principal(r) == nil && w.devLoginRequest(r) {
155 if sess := w.ensureDevSession(); sess != nil {
156 if acct, err := w.Ident.Find(sess.Name); err == nil && acct.Active() {
157 w.setDevSessionCookie(rw, sess)
158 r = w.withPrincipal(r, acct, sess)
159 }
160 }
161 }
162 }
163 sw := &statusWriter{ResponseWriter: rw}
164 // gzip is per request: the status recorder under it is what records the
165 // status and the page-view metric, and the compressor only shapes bytes.
166 gw := compress.New(sw, r)
167 defer func() {
168 v := recover()
169 if v == nil {
170 return
171 }
172 if w.Logger != nil {
173 w.Logger("panic %s %s: %v", r.Method, loggablePath(r.URL.Path), v)
174 }
175 if sw.wroteHeader {
176 // The reply is already partial; let net/http close the connection
177 // rather than append a page to an archive or a git stream.
178 panic(v)
179 }
180 w.serverError(sw, r, "")
181 }()
182 next.ServeHTTP(gw, r)
183 gw.Close()
184 w.recordPageView(r, sw)
185 })
186}
187
188// recordPageView counts a successfully rendered HTML page. Static files, Git
189// traffic, redirects, HTMX fragments and the admin surface itself are excluded, so
190// the metric follows reading rather than polling or asset loads. It is
191// best-effort: analytics must never turn a page into an error.
192func (w *Web) recordPageView(r *http.Request, sw *statusWriter) {
193 if w.PageViews == nil || r.Method != http.MethodGet || sw.status != http.StatusOK {
194 return
195 }
196 if r.Header.Get("HX-Request") == "true" || adminPath(r.URL.Path) {
197 return
198 }
199 if !strings.HasPrefix(sw.Header().Get("Content-Type"), "text/html") {
200 return
201 }
202 // A module discovery probe is answered with HTML but is not a page.
203 if r.URL.Query().Get("go-get") != "" {
204 return
205 }
206 account := ""
207 if pr := w.principal(r); pr != nil {
208 account = pr.account.Name
209 }
210 if err := w.PageViews.Record(account, time.Now().UTC()); err != nil && w.Logger != nil {
211 w.Logger("page view: %v", err)
212 }
213}
214
215// adminPath reports whether a path belongs to the instance-admin surface. Those
216// pages are read by the same few people watching the instance, so counting them
217// would measure administration rather than readership.
218func adminPath(path string) bool {
219 return path == "/-/admin" || path == "/-/status" || path == "/-/users" || strings.HasPrefix(path, "/-/users/")
220}
221
222// sessionFromCookie returns the first valid session cookie and the name it came
223// from. The dev cookie is only considered when dev login is enabled, so a stray
224// cookie on a normal instance is never a credential.
225func (w *Web) sessionFromCookie(r *http.Request) (*session.Session, string) {
226 names := []string{sessionCookieName}
227 if w.devLoginRequest(r) {
228 names = append(names, devSessionCookieName)
229 }
230 for _, name := range names {
231 c, err := r.Cookie(name)
232 if err != nil || c.Value == "" {
233 continue
234 }
235 sess, err := w.Sessions.Lookup(c.Value)
236 if err == nil {
237 return sess, name
238 }
239 return nil, name
240 }
241 return nil, ""
242}
243
244func (w *Web) withPrincipal(r *http.Request, acct *identity.Account, sess *session.Session) *http.Request {
245 return r.WithContext(context.WithValue(r.Context(), principalKey{}, &principal{account: acct, session: sess}))
246}
247
248// enableDevLogin resolves the configured account once. It refuses to start when
249// the listener is not loopback: a typo must not turn a shared instance into one
250// that signs everybody in as the same person. Config.Validate makes the same
251// checks; this one keeps the Web honest when it is built directly.
252func (w *Web) enableDevLogin(cfg *config.Config) error {
253 name := strings.TrimSpace(cfg.DevLogin)
254 if name == "" {
255 return nil
256 }
257 if !loopbackListen(cfg.HTTPAddr) {
258 return fmt.Errorf("dev_login requires http_addr on a loopback address, got %q", cfg.HTTPAddr)
259 }
260 if strings.TrimSpace(cfg.HTTPSAddr) != "" {
261 return fmt.Errorf("dev_login cannot be combined with https_addr")
262 }
263 if public, err := config.ParsePublicURL(cfg.PublicURL); err == nil && public != nil && !loopbackHost(public.Hostname()) {
264 return fmt.Errorf("dev_login cannot be combined with a public_url that is not loopback (%q)", cfg.PublicURL)
265 }
266 acct, err := w.Ident.Find(name)
267 if err != nil {
268 return fmt.Errorf("dev_login %q: %w", name, err)
269 }
270 if !acct.Active() {
271 return fmt.Errorf("dev_login %q: account is not active", name)
272 }
273 w.devAccount = acct
274 return nil
275}
276
277func (w *Web) devLoginRequest(r *http.Request) bool {
278 if w.devAccount == nil || !w.loopbackBrowser(r) {
279 return false
280 }
281 if c, err := r.Cookie(devOffCookieName); err == nil && c.Value != "" {
282 return false
283 }
284 return true
285}
286
287func (w *Web) ensureDevSession() *session.Session {
288 w.devMu.Lock()
289 defer w.devMu.Unlock()
290 if w.devSess != nil {
291 if sess, err := w.Sessions.Lookup(w.devSess.Token); err == nil {
292 return sess
293 }
294 }
295 sess, err := w.Sessions.Create(w.devAccount.Name, nil, "dev-login")
296 if err != nil {
297 w.Logger("dev login: create session: %v", err)
298 return nil
299 }
300 w.devSess = sess
301 return sess
302}
303
304func loopbackListen(addr string) bool {
305 host, _, err := net.SplitHostPort(addr)
306 return err == nil && loopbackHost(host)
307}
308
309func loopbackHost(host string) bool {
310 if strings.EqualFold(host, "localhost") {
311 return true
312 }
313 ip := net.ParseIP(host)
314 return ip != nil && ip.IsLoopback()
315}
316
317// loopbackBrowser checks both ends. A loopback listener behind a reverse proxy
318// sees loopback as RemoteAddr, so the Host check is what stops a public request
319// from inheriting a development session.
320//
321// The Host header alone is not enough: a proxy is free to rewrite it to
322// 127.0.0.1, which is exactly the request that must not sign anybody in. So the
323// host has to equal the configured listener address, and a request carrying a
324// forwarding header is refused outright — a proxy that does not set one is
325// unusual, and this check errs toward no session.
326func (w *Web) loopbackBrowser(r *http.Request) bool {
327 remote, _, err := net.SplitHostPort(r.RemoteAddr)
328 if err != nil || !loopbackHost(remote) {
329 return false
330 }
331 if w.Cfg == nil {
332 return false // no configured address to compare against
333 }
334 for _, h := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP", "X-Real-Ip"} {
335 if r.Header.Get(h) != "" {
336 return false
337 }
338 }
339 wantHost, wantPort, err := net.SplitHostPort(strings.TrimSpace(w.Cfg.HTTPAddr))
340 if err != nil || wantHost == "" {
341 return false
342 }
343 host, port := splitHostPort(r.Host)
344 if port != wantPort {
345 return false
346 }
347 // A development browser may type localhost where the config says 127.0.0.1.
348 // A proxy rewriting Host to a loopback name still has to guess the exact port,
349 // and it would also have to strip every forwarding header it normally adds.
350 return strings.EqualFold(host, wantHost) || (loopbackHost(host) && loopbackHost(wantHost))
351}
352
353func splitHostPort(addr string) (host, port string) {
354 if h, p, err := net.SplitHostPort(addr); err == nil {
355 return h, p
356 }
357 return addr, ""
358}
359
360func (w *Web) securityHeaders(rw http.ResponseWriter, r *http.Request) {
361 rw.Header().Set("X-Content-Type-Options", "nosniff")
362 rw.Header().Set("X-Frame-Options", "DENY")
363 rw.Header().Set("Referrer-Policy", "same-origin")
364 // There is no inline style in any template and none is generated, so the
365 // stylesheet is the only style source. object-src is closed because nothing here
366 // embeds a plugin, and a page that never loads one cannot be made to. The
367 // syntax highlighter runs in a worker of its own, hence worker-src.
368 rw.Header().Set("Content-Security-Policy",
369 "default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'")
370 // Nothing on this page needs a device, a payment API or an interest cohort, and
371 // the site runs beside its own pages on a pages subdomain: COOP/CORP keep a
372 // document from being opened or read as somebody else's resource.
373 rw.Header().Set("Permissions-Policy",
374 "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()")
375 rw.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
376 rw.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
377 // HSTS belongs to the transport, not to passkeys: an instance behind a TLS
378 // proxy with no browser sign-in is just as much an HTTPS instance, and a
379 // browser that learns that here cannot be downgraded later.
380 if w.servesHTTPS(r) {
381 rw.Header().Set("Strict-Transport-Security", "max-age=31536000")
382 }
383}
384
385// servesHTTPS reports whether browsers reach this instance over TLS: either this
386// request arrived over it, or the canonical URL says a proxy terminates it. The
387// configured https_addr is deliberately not enough on its own, because the same
388// process may be answering ACME challenges on plain HTTP.
389func (w *Web) servesHTTPS(r *http.Request) bool {
390 if r.TLS != nil {
391 return true
392 }
393 if w.Cfg == nil {
394 return false
395 }
396 if u, err := config.ParsePublicURL(w.Cfg.PublicURL); err == nil && u != nil {
397 return u.Scheme == "https"
398 }
399 return false
400}
401
402// noStore marks a page that must not be kept anywhere: an enrollment link, a
403// sign-in form, an account page. The referrer is dropped as well, because the
404// page's URL may itself be a secret a click would carry to somebody else.
405func (w *Web) noStore(rw http.ResponseWriter) {
406 rw.Header().Set("Cache-Control", "no-store")
407 rw.Header().Set("Pragma", "no-cache")
408 rw.Header().Set("Referrer-Policy", "no-referrer")
409}
410
411func (w *Web) enabled() bool { return w.Auth != nil && w.Auth.Enabled() }
412
413// registrationEnabled is a policy switch on top of passkey availability: an
414// instance can have browser sign-in without accepting new accounts.
415func (w *Web) registrationEnabled() bool {
416 return w.enabled() && w.Cfg != nil && w.Cfg.OpenRegistration
417}
418
419func (w *Web) accountName(r *http.Request) string {
420 if p := w.principal(r); p != nil {
421 return p.account.Name
422 }
423 return ""
424}
425
426func (w *Web) canDiscuss(r *http.Request, acl *access.ACL) bool {
427 return canDiscuss(acl, w.accountName(r))
428}
429
430func (w *Web) page(r *http.Request, title string) Page {
431 p := Page{Title: title, Section: pageSection(r.URL.Path), memo: memoOf(r), InstanceName: w.instanceName()}
432 if pr := w.principal(r); pr != nil {
433 p.LoggedIn = true
434 p.User = pr.account.Name
435 p.AccountName = pr.account.Name
436 p.CSRF = pr.session.CSRF
437 }
438 if w.devAccount != nil {
439 p.DevLogin = w.devAccount.Name
440 }
441 if p.LoggedIn && w.Ident != nil {
442 p.InstanceAdmin = w.Ident.IsAdmin(p.AccountName)
443 }
444 if w.InstanceRepo != nil {
445 if snap := w.InstanceRepo.Current(); snap != nil {
446 v := snap.Commit
447 if len(v) > 12 {
448 v = v[:12]
449 }
450 if len(snap.CSS) > 0 {
451 p.InstanceCSS = "/-/static/custom/profile/custom.css?v=" + v
452 }
453 if len(snap.Favicon) > 0 {
454 p.InstanceFavicon = "/-/static/custom/profile/favicon.svg?v=" + v
455 }
456 for _, t := range snap.Themes {
457 p.InstanceThemes = append(p.InstanceThemes, instanceThemeView{
458 ID: t.ID,
459 Name: t.Name,
460 URL: "/-/static/custom/themes/" + t.ID + ".css?v=" + v,
461 })
462 }
463 }
464 }
465 if w.Repos != nil {
466 // A path outside /-/ names a repository in its first segment. A profile
467 // (/-/u/{name}) and the admin account page (/-/users/{name}) share the
468 // `{name}` wildcard but are the forge's own pages, not repositories.
469 if name := r.PathValue("name"); name != "" && !strings.HasPrefix(r.URL.Path, "/-/") {
470 p.RepoName = name
471 if m, err := w.repo(p.memo, name); err == nil {
472 p.CloneURL = w.cloneURL(r, name)
473 p.SSHURL = w.sshURL(name)
474 if p.LoggedIn || (w.Cfg != nil && strings.TrimSpace(w.Cfg.PagesDomain) != "") {
475 if acl, err := m.ACL(); err == nil {
476 if p.LoggedIn {
477 // Instance admins get the Settings tab for its access section,
478 // even when the repository has no role for them.
479 p.CanAdmin = p.InstanceAdmin || acl.Can(p.AccountName, access.Admin)
480 // A maintainer sees the tab for the repository's secrets,
481 // which are the credentials the jobs they approve will use.
482 p.CanMaintain = acl.CanTriage(p.AccountName)
483 }
484 if w.siteDeployed(m, acl) {
485 p.Site = w.siteURL(r, name)
486 }
487 }
488 }
489 }
490 }
491 }
492 return p
493}
494
495func (w *Web) setSessionCookie(rw http.ResponseWriter, sess *session.Session) {
496 http.SetCookie(rw, &http.Cookie{
497 Name: sessionCookieName,
498 Value: sess.Token,
499 Path: "/",
500 HttpOnly: true,
501 Secure: true,
502 SameSite: http.SameSiteLaxMode,
503 Expires: sess.Expires,
504 MaxAge: int(time.Until(sess.Expires).Seconds()),
505 })
506}
507
508func (w *Web) setDevSessionCookie(rw http.ResponseWriter, sess *session.Session) {
509 http.SetCookie(rw, &http.Cookie{
510 Name: devSessionCookieName,
511 Value: sess.Token,
512 Path: "/",
513 HttpOnly: true,
514 // Secure is false on purpose: dev login exists for a loopback HTTP
515 // preview. It is never set unless dev_login is configured.
516 SameSite: http.SameSiteLaxMode,
517 Expires: sess.Expires,
518 MaxAge: int(time.Until(sess.Expires).Seconds()),
519 })
520}
521
522func (w *Web) clearCookie(rw http.ResponseWriter, name string) {
523 w.clearCookieFor(rw, name, true)
524}
525
526func (w *Web) clearCookieFor(rw http.ResponseWriter, name string, secure bool) {
527 http.SetCookie(rw, &http.Cookie{
528 Name: name,
529 Value: "",
530 Path: "/",
531 HttpOnly: true,
532 Secure: secure,
533 SameSite: http.SameSiteLaxMode,
534 MaxAge: -1,
535 })
536}
537
538// checkCSRF is the JSON endpoints' check: the token must arrive in the
539// X-Vole-CSRF header. It never parses the body, so a handler that has not bounded
540// the request cannot be made to parse one here.
541func (w *Web) checkCSRF(r *http.Request) bool {
542 if !w.sameOriginRequest(r) {
543 return false
544 }
545 return w.csrfToken(r, r.Header.Get("X-Vole-CSRF"))
546}
547
548// csrfOK is the form endpoints' check: the header, or the csrf field of a body
549// that readForm has already bounded and parsed.
550func (w *Web) csrfOK(r *http.Request) bool {
551 if !w.sameOriginRequest(r) {
552 return false
553 }
554 got := r.Header.Get("X-Vole-CSRF")
555 if got == "" {
556 got = r.PostFormValue("csrf")
557 }
558 return w.csrfToken(r, got)
559}
560
561// sameOriginRequest is the second lock on the same door as the CSRF token: when the
562// browser says where a request came from, that answer has to be this instance. A
563// missing Origin and Referer is not evidence against a request — some clients and
564// privacy settings send neither — so this can only refuse a request that named
565// somewhere else.
566func (w *Web) sameOriginRequest(r *http.Request) bool {
567 origin := strings.TrimSpace(r.Header.Get("Origin"))
568 if origin == "" {
569 origin = strings.TrimSpace(r.Header.Get("Referer"))
570 }
571 if origin == "" {
572 return true
573 }
574 u, err := url.Parse(origin)
575 if err != nil || u.Scheme == "" || u.Host == "" {
576 return false
577 }
578 have := u.Scheme + "://" + u.Host
579 want := w.canonicalBase(r)
580 if have == want {
581 return true
582 }
583 // Behind a proxy the browser's origin is the public one while the request's own
584 // origin is the internal one; public_url is then the only correct comparison,
585 // which canonicalBase already used when it is set.
586 return w.Cfg != nil && w.Cfg.BaseURL() != "" && strings.EqualFold(have, want)
587}
588
589func (w *Web) csrfToken(r *http.Request, got string) bool {
590 pr := w.principal(r)
591 if pr == nil || pr.session == nil || pr.session.CSRF == "" || got == "" {
592 return false
593 }
594 return subtle.ConstantTimeCompare([]byte(got), []byte(pr.session.CSRF)) == 1
595}
596
597func (w *Web) requireHTMLUser(rw http.ResponseWriter, r *http.Request) (*principal, bool) {
598 pr := w.principal(r)
599 if pr == nil {
600 next := url.QueryEscape(r.URL.RequestURI())
601 http.Redirect(rw, r, "/-/login?next="+next, http.StatusSeeOther)
602 return nil, false
603 }
604 return pr, true
605}
606
607func (w *Web) requireAPIUser(rw http.ResponseWriter, r *http.Request) (*principal, bool) {
608 pr := w.principal(r)
609 if pr == nil {
610 writeJSONError(rw, http.StatusUnauthorized, "sign in first")
611 return nil, false
612 }
613 return pr, true
614}
615
616func writeJSON(rw http.ResponseWriter, status int, v any) {
617 rw.Header().Set("Content-Type", "application/json; charset=utf-8")
618 rw.WriteHeader(status)
619 _ = json.NewEncoder(rw).Encode(v)
620}
621
622func writeJSONError(rw http.ResponseWriter, status int, msg string) {
623 writeJSON(rw, status, map[string]string{"error": msg})
624}
625
626// ---------- ceremony state ----------
627
628type ceremony struct {
629 Kind string // login | invite | passkey | register
630 Data webauthn.SessionData
631 Name string
632 InviteToken string
633 Label string
634 Expires time.Time
635}
636
637// maxCeremonies bounds the in-flight ceremony map. Without a bound the map is
638// memory an anonymous client can grow: each begin is a few hundred bytes, and a
639// client that never finishes one never releases it. Five minutes of TTL and a cap
640// mean the store is a fixed-size window, and a begin past the cap is refused
641// rather than accepted into an unbounded table.
642const maxCeremonies = 4096
643
644type ceremonyStore struct {
645 mu sync.Mutex
646 m map[string]ceremony
647}
648
649func newCeremonyStore() *ceremonyStore { return &ceremonyStore{m: map[string]ceremony{}} }
650
651func (s *ceremonyStore) put(c ceremony) (string, error) {
652 id := make([]byte, 32)
653 if _, err := rand.Read(id); err != nil {
654 return "", err
655 }
656 key := base64.RawURLEncoding.EncodeToString(id)
657 c.Expires = time.Now().UTC().Add(ceremonyTTL)
658 now := time.Now().UTC()
659 s.mu.Lock()
660 defer s.mu.Unlock()
661 // Sweep on every put: expired ceremonies are dropped the moment they are no
662 // longer usable, so a slow client cannot keep the table full.
663 for k, v := range s.m {
664 if now.After(v.Expires) {
665 delete(s.m, k)
666 }
667 }
668 if len(s.m) >= maxCeremonies {
669 return "", fmt.Errorf("too many sign-in attempts in progress; try again in a few minutes")
670 }
671 s.m[key] = c
672 return key, nil
673}
674
675func (s *ceremonyStore) take(key string) (ceremony, bool) {
676 if key == "" {
677 return ceremony{}, false
678 }
679 s.mu.Lock()
680 defer s.mu.Unlock()
681 c, ok := s.m[key]
682 if !ok {
683 return ceremony{}, false
684 }
685 delete(s.m, key)
686 if time.Now().UTC().After(c.Expires) {
687 return ceremony{}, false
688 }
689 return c, true
690}
691
692func (w *Web) beginCeremony(rw http.ResponseWriter, c ceremony, v any) error {
693 id, err := w.Ceremonies.put(c)
694 if err != nil {
695 return err
696 }
697 http.SetCookie(rw, &http.Cookie{
698 Name: ceremonyCookieName,
699 Value: id,
700 Path: "/",
701 HttpOnly: true,
702 Secure: true,
703 SameSite: http.SameSiteLaxMode,
704 MaxAge: int(ceremonyTTL.Seconds()),
705 })
706 writeJSON(rw, http.StatusOK, v)
707 return nil
708}
709
710func (w *Web) takeCeremony(rw http.ResponseWriter, r *http.Request) (ceremony, bool) {
711 c, err := r.Cookie(ceremonyCookieName)
712 if err != nil {
713 writeJSONError(rw, http.StatusBadRequest, "the ceremony expired; try again")
714 return ceremony{}, false
715 }
716 w.clearCookie(rw, ceremonyCookieName)
717 state, ok := w.Ceremonies.take(c.Value)
718 if !ok {
719 writeJSONError(rw, http.StatusBadRequest, "the ceremony expired; try again")
720 return ceremony{}, false
721 }
722 return state, true
723}
724
725// ---------- WebAuthn user adapter ----------
726
727type waUser struct {
728 account *identity.Account
729 creds []webauthn.Credential
730}
731
732func (u *waUser) WebAuthnID() []byte { return []byte(u.account.Name) }
733func (u *waUser) WebAuthnName() string { return u.account.Name }
734func (u *waUser) WebAuthnDisplayName() string { return u.account.Name }
735func (u *waUser) WebAuthnCredentials() []webauthn.Credential { return u.creds }
736
737func (w *Web) waUser(a *identity.Account) (*waUser, error) {
738 creds, err := w.Ident.Credentials(a.Name)
739 if err != nil {
740 return nil, err
741 }
742 out := make([]webauthn.Credential, 0, len(creds))
743 for _, c := range creds {
744 out = append(out, toWebAuthn(c))
745 }
746 return &waUser{account: a, creds: out}, nil
747}
748
749func toWebAuthn(c identity.Credential) webauthn.Credential {
750 transports := make([]protocol.AuthenticatorTransport, 0, len(c.Transport))
751 for _, t := range c.Transport {
752 transports = append(transports, protocol.AuthenticatorTransport(t))
753 }
754 return webauthn.Credential{
755 ID: c.ID,
756 PublicKey: c.PublicKey,
757 AttestationType: c.AttestationType,
758 Transport: transports,
759 Flags: webauthn.CredentialFlags{
760 UserPresent: c.Flags.UserPresent,
761 UserVerified: c.Flags.UserVerified,
762 BackupEligible: c.Flags.BackupEligible,
763 BackupState: c.Flags.BackupState,
764 },
765 Authenticator: webauthn.Authenticator{
766 AAGUID: c.Authenticator.AAGUID,
767 SignCount: c.Authenticator.SignCount,
768 CloneWarning: c.Authenticator.CloneWarning,
769 Attachment: protocol.AuthenticatorAttachment(c.Authenticator.Attachment),
770 },
771 }
772}
773
774func fromWebAuthn(c *webauthn.Credential, label string) identity.Credential {
775 transports := make([]string, 0, len(c.Transport))
776 for _, t := range c.Transport {
777 transports = append(transports, string(t))
778 }
779 return identity.Credential{
780 ID: c.ID,
781 PublicKey: c.PublicKey,
782 AttestationType: c.AttestationType,
783 Transport: transports,
784 Flags: identity.CredentialFlags{
785 UserPresent: c.Flags.UserPresent,
786 UserVerified: c.Flags.UserVerified,
787 BackupEligible: c.Flags.BackupEligible,
788 BackupState: c.Flags.BackupState,
789 },
790 Authenticator: identity.Authenticator{
791 AAGUID: c.Authenticator.AAGUID,
792 SignCount: c.Authenticator.SignCount,
793 CloneWarning: c.Authenticator.CloneWarning,
794 Attachment: string(c.Authenticator.Attachment),
795 },
796 Label: strings.TrimSpace(label),
797 }
798}
799
800// decodeJSONBody bounds every auth request before it reaches a parser.
801func decodeJSONBody(r *http.Request, dst any) error {
802 defer r.Body.Close()
803 dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
804 if err := dec.Decode(dst); err != nil {
805 return errors.New("invalid request body")
806 }
807 return nil
808}
809