oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "errors"
5 "net"
6 "net/http"
7 "net/url"
8 "strings"
9 "time"
10
11 "amadan.net/oficina/estilo.git/chrome"
12 "amadan.net/rastrillo/rastrillo/sessions"
13 "github.com/keymaildev/signin"
14)
15
16// Origin is the credential origin: the host that renders sign-in, holds
17// the session cookie and is the WebAuthn RP. §4 splits it from the
18// directory origin deliberately, and both the CSRF check and the cookie
19// attributes are keyed on this exact string.
20const Origin = "https://" + credentialHost
21
22// linkPath is where magic links land. It must match the route mounted
23// below; signin's own default is /auth/verify, which this app does not
24// serve.
25const linkPath = "/signin/verify"
26
27// callbackPath is where keymail returns. signin.Keymail defaults to
28// /auth/callback, so setting this is not optional — mounting the route
29// without it leaves the two disagreeing and every keymail sign-in
30// failing at the last hop.
31const callbackPath = "/signin/callback"
32
33// pendingCookie carries the sealed keymail pending blob between Begin
34// and the callback.
35//
36// Every attribute here is load-bearing, and they are the reason the
37// callback is allowed to mint a session on a GET where the magic link is
38// not. __Host- pins it to this exact origin with no Domain and Path=/,
39// so no sibling host can set or read it. HttpOnly keeps script out.
40// SameSite=Lax rather than Strict because Strict would break the keymail
41// return itself, which is a top-level navigation from another origin.
42// Lax does NOT stop this attack shape — it sends the cookie on exactly
43// that navigation — and an earlier version of this comment claimed it
44// did. What stops an attacker is that the blob is HMAC'd under a config
45// key they do not have, and that the sealed state must match.
46//
47// It is cleared on read. That is worth doing and it is NOT what makes a
48// blob single-use: clearing touches one browser's cookie jar and the
49// blob stays a valid self-contained value until it expires. An earlier
50// version of this comment said "cleared on read, so a blob is good for
51// exactly one callback", and a test written to check it found the
52// replay went through. What actually makes the round trip single-use is
53// the authorization code, which the federation server redeems once —
54// standard OAuth, and the test stub models it because a stub that did
55// not was how the false claim survived.
56const pendingCookie = "__Host-oficina-signin"
57
58// pendingTTL bounds how long a half-finished keymail round trip stays
59// usable. signin's own PendingTTL defaults to 10 minutes; the cookie is
60// given the same life so the two expire together rather than leaving a
61// cookie that outlives the blob it carries.
62const pendingTTL = 10 * time.Minute
63
64// classifierTimeout bounds the classifier's outbound calls.
65//
66// The exposure is wider than "slow", and worth naming precisely. The
67// classifier resolves _keymail.<domain> for whatever domain was typed
68// and then fetches from the host that TXT record names — a value the
69// submitter's DNS controls, unvalidated, which may be a port or an
70// internal address. So this is an outbound fetch to an attacker-chosen
71// host, and the same host becomes the token exchange's Base. It is
72// bounded by the fact that csrf.Protect blocks the cross-origin POST,
73// so a victim has to type the address themselves; the timeout bounds
74// how long one costs us. Neither makes the host safe, and a future
75// change that widens who can reach this endpoint should read this
76// first.
77const classifierTimeout = 5 * time.Second
78
79// newFlow wires signin.Flow. Exactly one per process: the rate limiter's
80// state lives on the value, so one per request would hand every caller a
81// fresh empty budget and rate-limit nothing, silently.
82func newFlow(secrets Secrets, links signin.LinkStore, origin string) *signin.Flow {
83 http := &http.Client{Timeout: classifierTimeout}
84 return &signin.Flow{
85 Classifier: &signin.Classifier{HTTP: http, Scheme: "https"},
86 Keymail: func(server string) *signin.Keymail {
87 return &signin.Keymail{
88 Base: "https://" + server,
89 Origin: origin,
90 RedirectPath: callbackPath,
91 HTTP: http,
92 }
93 },
94 Links: links,
95 Mailer: secrets.Mailer,
96 Key: secrets.SigninKey,
97 Origin: origin,
98 LinkPath: linkPath,
99 // The suite's subject and body (estilo chrome.English), so the
100 // email says how long the link lasts rather than keymaildev's
101 // default "expires shortly", and names Oficina, never "Home".
102 Subject: chrome.EmailSubject(suiteName),
103 Body: chrome.EmailBody(suiteName),
104 }
105}
106
107// clientIP is the rate limiter's per-IP key, and getting it wrong is a
108// suite-wide outage rather than a missing nicety.
109//
110// Over a Linux unix socket r.RemoteAddr is "@". net.SplitHostPort("@")
111// fails, so a naive implementation passes "@" through — and signin skips
112// the per-IP budget only when the key is exactly "". Every request in
113// the world would then share the key "@" with a budget of 20 per 15
114// minutes: twenty POSTs would lock sign-in out for the whole suite for a
115// quarter of an hour, and §2.1 already names this service the suite's
116// single point of failure.
117//
118// Taking the LAST element is the carlos edge's documented contract, not
119// a guess. Verified in carlos/platform at 97eb53f:
120//
121// - stripForwardedHeaders deletes inbound Forwarded and
122// X-Forwarded-*, and ReverseProxy's Rewrite deletes them from the
123// outbound request before Rewrite runs, so a client cannot forge
124// this header at all.
125// - SetXForwarded then writes a single address: the peer that dialled.
126// - On a verified edge-to-edge hop the inbound chain is restored
127// VERBATIM rather than appended to, and setForwarded says why in as
128// many words: "the last element has to stay the client." It cites
129// the platform's own consumer, internal/console/authcli.go's
130// clientIP — "the LAST element is the one to take".
131//
132// Appending would have rate-limited every backhauled request as one
133// bucket per region, which is the same collapse this function exists to
134// avoid, one layer up. Falling back to RemoteAddr covers a direct local
135// connection.
136//
137// Returning "" is deliberate where no IP is knowable: no per-IP budget
138// is strictly better than one global budget shared by everyone, and the
139// per-address budget still applies.
140//
141// The residual, stated because nothing in the code checks it: all of
142// the above is true BEHIND THE CARLOS EDGE. Run directly — local
143// development, an internal caller, a misconfigured unit — any client
144// can set X-Forwarded-For per request and hand itself a fresh budget
145// every time. The per-address budget is what still holds there.
146func clientIP(r *http.Request) string {
147 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
148 parts := strings.Split(xff, ",")
149 if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" && ip != "@" {
150 return ip
151 }
152 }
153 if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil && host != "" && host != "@" {
154 return host
155 }
156 if r.RemoteAddr != "" && r.RemoteAddr != "@" {
157 return r.RemoteAddr
158 }
159 return ""
160}
161
162// handleSigninForm renders the address form.
163func (a *App) handleSigninForm(w http.ResponseWriter, r *http.Request) {
164 rememberReturn(w, r)
165 a.render(w, r, http.StatusOK, "signin.html", map[string]any{})
166}
167
168// handleSigninSent is the check-your-mail page. It is reached by
169// redirect and asserts nothing about the address, so it is the same page
170// whether or not anybody has an account here.
171func (a *App) handleSigninSent(w http.ResponseWriter, r *http.Request) {
172 a.render(w, r, http.StatusOK, "check_your_mail.html", map[string]any{"Again": againHref(r)})
173}
174
175// againHref is the way back to the form that keeps where the sign-in was
176// going: "Use a different address" and "Start again" (r3 C1). A bare
177// /signin forgets return_to (rememberReturn clears it), so the next link
178// ended at id.* "/" and the person on the signed-out front page.
179func againHref(r *http.Request) string {
180 if target := readReturn(r); target != "/" {
181 return "/signin?return_to=" + url.QueryEscape(target)
182 }
183 return "/signin"
184}
185
186// handleSigninBegin starts a sign-in.
187//
188// It branches on what Begin decided. A magic link has already been sent
189// by the time Begin returns; a keymail upgrade has sent nothing and
190// hands back a redirect and a pending blob instead. Both answers are
191// decided by the address's domain, which the submitter already knows, so
192// neither discloses whether that person has an account here.
193func (a *App) handleSigninBegin(w http.ResponseWriter, r *http.Request) {
194 if err := r.ParseForm(); err != nil {
195 a.render(w, r, http.StatusBadRequest, "signin.html",
196 map[string]any{"Error": "That did not look like a form."})
197 return
198 }
199 address := strings.TrimSpace(r.PostFormValue("address"))
200
201 // The force override carries a failed keymail round trip back to the
202 // magic link, so a failed approval is not a dead end.
203 var force signin.Method
204 intent, freshIntent := a.readAssertionIntent(r, time.Now())
205 if r.URL.Query().Get("force") == "1" || (freshIntent && intent.MaxAge == 300) {
206 force = signin.MethodMagicLink
207 }
208
209 next, err := a.flow.Begin(r.Context(), address, clientIP(r), force)
210 switch {
211 case errors.Is(err, signin.ErrBadAddress):
212 a.render(w, r, http.StatusBadRequest, "signin.html",
213 map[string]any{"Error": "That does not look like an email address."})
214 return
215 case errors.Is(err, signin.ErrRateLimited):
216 // 429 without saying which budget: which one was hit is itself
217 // information about whether that address has been tried.
218 a.render(w, r, http.StatusTooManyRequests, "signin.html",
219 map[string]any{"Error": "Too many attempts. Try again shortly."})
220 return
221 case err != nil:
222 a.logger.Error("signin begin", "err", err)
223 a.render(w, r, http.StatusInternalServerError, "signin.html",
224 map[string]any{"Error": "Something went wrong. Try again."})
225 return
226 }
227
228 if next.Kind == signin.NextKeymail {
229 a.setPending(w, next.Pending)
230 a.renderContinuation(w, r, next.Redirect, "Continue to Keymail")
231 return
232 }
233 http.Redirect(w, r, "/signin/sent", http.StatusSeeOther)
234}
235
236// handleVerifyForm renders the confirmation page for a magic link. It
237// TOUCHES NO STATE, and that is the whole point.
238//
239// Redeeming on GET breaks twice. Mail security scanners prefetch links,
240// which burns the single-use token before the person clicks it — routine,
241// not hypothetical. And it is login CSRF: any page the victim loads can
242// embed <img src=".../signin/verify?token=…"> and silently sign them into
243// an account the attacker controls, after which everything they do
244// happens in the attacker's session.
245//
246// It must not look the token up either. A lookup would reintroduce the
247// oracle a prefetch could probe: a valid token would render differently
248// from an invalid one, which is exactly the difference this page exists
249// not to have.
250func (a *App) handleVerifyForm(w http.ResponseWriter, r *http.Request) {
251 a.render(w, r, http.StatusOK, "signin_confirm.html",
252 map[string]any{"Token": r.URL.Query().Get("token")})
253}
254
255// handleVerify redeems a magic link. Behind csrf.Protect, on POST.
256func (a *App) handleVerify(w http.ResponseWriter, r *http.Request) {
257 if err := r.ParseForm(); err != nil {
258 a.signinFailed(w, r)
259 return
260 }
261 id, err := a.flow.CompleteMagicLink(r.Context(), r.PostFormValue("token"))
262 if err != nil {
263 // Unknown, expired and already-used are one answer. TakeLink
264 // makes them one answer at the store; this keeps them one
265 // answer at the door.
266 a.signinFailed(w, r)
267 return
268 }
269 a.completeSignIn(w, r, id)
270}
271
272// handleCallback completes a keymail round trip.
273//
274// This one may mint on a GET where the magic link may not, and the
275// difference is not arbitrary: the return is bound to the browser that
276// began the flow, by the __Host- pending cookie plus state. An attacker
277// cannot make a victim's browser complete a flow it never started. A
278// magic link has no such binding — anyone holding the URL can replay it
279// — which is why that one needs the form.
280//
281// It never renders anything derived from the return: the host is chosen
282// by the address, and putting an account-influenced string on the
283// credential origin is what §4 forbids.
284//
285// Both the success and the ordinary failure are redirects — a failed
286// round trip goes to /signin?force=1. Rendering happens only on a
287// downstream failure inside completeSignIn, of which there are three:
288// find-or-mint, the passkey Gate, and minting the session. All three
289// show the same fixed page, carrying nothing from the round trip.
290//
291// This sentence is on its third revision. It said "never renders",
292// which was false; then implied rendering was the normal failure, which
293// was also wrong; then listed two of the three paths as though that
294// were all of them.
295func (a *App) handleCallback(w http.ResponseWriter, r *http.Request) {
296 blob := a.takePending(w, r)
297 q := r.URL.Query()
298
299 id, err := a.flow.CompleteKeymail(r.Context(), blob, q.Get("code"), q.Get("state"))
300 if err != nil {
301 // Not a dead end: back to the form with the override set, so the
302 // next attempt takes the magic link instead.
303 http.Redirect(w, r, "/signin?force=1", http.StatusSeeOther)
304 return
305 }
306 a.completeSignIn(w, r, id)
307}
308
309// completeSignIn turns a verified identity into a session.
310//
311// The session's subject is the person ref, never the address.
312// sessions.Session.Subject is an opaque string the app chooses, and it
313// is stored in the clear — putting an address there would put every
314// signed-in person's address at rest in a plaintext column, which is
315// precisely what rastrillo/auth does and why this app does not use it.
316func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.Identity) {
317 ref, err := a.findOrMintPerson(r.Context(), id.Address)
318 if err != nil {
319 a.logger.Error("find or mint person", "err", err)
320 a.signinFailed(w, r)
321 return
322 }
323 sess := sessions.Session{
324 Subject: ref,
325 Method: string(id.Method),
326 AuthTime: id.AuthTime,
327 At: id.At,
328 }
329
330 // The second factor, on BOTH sign-in paths — and on both because
331 // there is one place to put it, not because two call sites were
332 // remembered. handleVerify (the magic link) and handleCallback (the
333 // keymail return) both funnel through here, so an enrolled person
334 // cannot obtain a full session from one factor down either door.
335 // The bug this guards against is real: it is what happens when only
336 // the magic-link path is gated and a keymail-upgraded account walks
337 // in through the other one.
338 //
339 // Gate redirects and reports true when it has taken over. An account
340 // with no passkey passes through untouched.
341 target := readReturn(r)
342 writeReturn(w, "/")
343 gated, err := a.passkeys.Gate(w, requestWithReturn(r, target), sess)
344 if err != nil {
345 a.logger.Error("passkey gate", "person", ref, "err", err)
346 a.signinFailed(w, r)
347 return
348 }
349 if gated {
350 return
351 }
352
353 if err := a.sessions.SignIn(w, r, sess); err != nil {
354 a.logger.Error("mint session", "person", ref, "err", err)
355 a.signinFailed(w, r)
356 return
357 }
358 http.Redirect(w, r, target, http.StatusSeeOther)
359}
360
361// signinFailed is the one answer every failed redemption gets. One page,
362// one status, no detail: unknown, expired, already used and
363// wrong-purpose must be indistinguishable from outside.
364func (a *App) signinFailed(w http.ResponseWriter, r *http.Request) {
365 a.render(w, r, http.StatusBadRequest, "signin_failed.html", map[string]any{"Again": againHref(r)})
366}
367
368func (a *App) setPending(w http.ResponseWriter, blob string) {
369 http.SetCookie(w, &http.Cookie{
370 Name: pendingCookie,
371 Value: blob,
372 Path: "/",
373 MaxAge: int(pendingTTL.Seconds()),
374 HttpOnly: true,
375 Secure: true,
376 SameSite: http.SameSiteLaxMode,
377 })
378}
379
380// takePending reads the pending blob and clears the cookie in the same
381// breath.
382//
383// The clearing is hygiene, NOT single use — see the note on
384// pendingCookie. It stops this browser re-submitting; it does not make
385// the blob itself spent, because the blob is a self-contained value
386// that stays valid until it expires. The authorization code is what is
387// single use, at the federation server. An earlier version of this
388// comment said "so one blob completes at most one callback", which is
389// the claim the pendingCookie note retracts — and it survived here,
390// verbatim, through the commit that retracted it.
391func (a *App) takePending(w http.ResponseWriter, r *http.Request) string {
392 http.SetCookie(w, &http.Cookie{
393 Name: pendingCookie,
394 Value: "",
395 Path: "/",
396 MaxAge: -1,
397 HttpOnly: true,
398 Secure: true,
399 SameSite: http.SameSiteLaxMode,
400 })
401 c, err := r.Cookie(pendingCookie)
402 if err != nil {
403 return ""
404 }
405 return c.Value
406}
407