| 1 | package home |
| 2 | |
| 3 | import ( |
| 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. |
| 20 | const 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. |
| 25 | const 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. |
| 31 | const 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. |
| 56 | const 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. |
| 62 | const 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. |
| 77 | const 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. |
| 82 | func 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. |
| 146 | func 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. |
| 163 | func (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. |
| 171 | func (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. |
| 179 | func 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. |
| 193 | func (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. |
| 250 | func (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. |
| 256 | func (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. |
| 295 | func (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. |
| 316 | func (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. |
| 364 | func (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 | |
| 368 | func (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. |
| 391 | func (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 | |