package console import ( "context" "crypto/sha256" "crypto/subtle" "log" "net/http" netmail "net/mail" "net/url" "strings" "sync" "time" ) // nextCookie carries a safe return path across the Keymail round trip, beside // the auth cookie. (An email sign-in keeps its next on the challenge row.) const nextCookie = "carlos_console_next" // flowCookie holds this browser's sign-in flow: a random token, set whenever // a sign-in form renders and kept for ConfirmationTTL from the last one. A // sign-in challenge stores the flow's hash, so a code typed here is checked // against this flow's newest live challenge and a second request from here // replaces it. Its value never depends on how /auth/continue answered. const flowCookie = "carlos_console_flow" // linkConfirmCookie holds the nonce the emailed link's confirm page also puts // in its form (spec 5.1). A cross-site POST carries no Lax cookie, so an // attacker cannot submit their own link into someone else's browser. const linkConfirmCookie = "carlos_console_linkconfirm" // Sign-in sends run off the request (spec 4.2): at most challengeSendSlots at // once, each with challengeSendTimeout to get a slot and finish. const ( challengeSendSlots = 4 challengeSendTimeout = 20 * time.Second ) // signinView is the approved front-page and "Check your email" copy the // templates show; render fills it on every page. type signinView struct { EmailLabel, EmailPlaceholder, EmailButton, Small, NoMail string CheckTitle, CodeLabel, CodeButton, Nothing, AgainButton string LinkButton string // The email step-up's code box and its link's confirm button. StepUpCodeLabel, StepUpCodeButton, StepUpLinkButton string } var approvedSigninView = signinView{ EmailLabel: copyText("home.email_label"), EmailPlaceholder: copyText("home.email_placeholder"), EmailButton: copyText("home.email_button"), Small: copyText("home.small"), NoMail: copyText("home.no_mail"), CheckTitle: copyText("check.title"), CodeLabel: copyText("check.code_label"), CodeButton: copyText("check.code_button"), Nothing: copyText("check.nothing"), AgainButton: copyText("check.again_button"), LinkButton: copyText("link.button"), StepUpCodeLabel: copyText("stepup.code_label"), StepUpCodeButton: copyText("stepup.code_button"), StepUpLinkButton: copyText("stepup.link_button"), } // sameOrigin reports whether a form POST came from this console's own pages: // an Origin header must name the configured origin's host; without one, // Sec-Fetch-Site must be same-origin or none; a request carrying neither (an // old browser, a script) passes, as every other form here does. func (s *Server) sameOrigin(r *http.Request) bool { if o := r.Header.Get("Origin"); o != "" { got, err := url.Parse(o) if err != nil || got.Host == "" { return false } want, err := url.Parse(s.cfg.Origin) return err == nil && want.Host != "" && strings.EqualFold(got.Host, want.Host) } switch r.Header.Get("Sec-Fetch-Site") { case "", "same-origin", "none": return true } return false } // canonicalAddress is the one form of a typed address used everywhere after // the front door (spec 11): trimmed and lower-cased, and accepted only when it // is a bare address, so display-name and list forms are refused before any // challenge, rate-limit key or user row exists. func canonicalAddress(raw string) (string, bool) { a := strings.ToLower(strings.TrimSpace(raw)) if !ValidAddress(a) { return "", false } if parsed, err := netmail.ParseAddress(a); err != nil || parsed.Address != a { return "", false } return a, true } // keymailAddress is discovery (spec 4.1): the configured Keymail host's own // addresses, or, when mayLookup, any address Keymail's public lookup says is // a mailbox. Only those public answers decide; any lookup failure means email. func (s *Server) keymailAddress(ctx context.Context, addr string, mayLookup bool) bool { if s.km == nil { return false } if h := s.km.Host(); h != "" && addr[strings.LastIndex(addr, "@")+1:] == h { return true } if !mayLookup { return false } ok, err := s.km.HasMailbox(ctx, addr) if err != nil { log.Printf("console: keymail lookup for %s: %v (sending email)", addr, err) return false } return ok } // mailReady reports whether the console mailer can send now. No mailer is // not ready. func (s *Server) mailReady(ctx context.Context) bool { return s.consoleMail != nil && s.consoleMail.Ready(ctx) } // sendChallengeAsync sends a sign-in or step-up message through the console // mailer on its own goroutine, so the page renders at once and send latency // is no oracle. A send that cannot get one of the challengeSendSlots within // its context is dropped; every failure is logged. func (s *Server) sendChallengeAsync(to, subject, body string) { mailer := s.consoleMail go func() { defer func() { if s.challengeSent != nil { s.challengeSent <- struct{}{} } }() ctx, cancel := context.WithTimeout(context.Background(), challengeSendTimeout) defer cancel() select { case s.challengeSem <- struct{}{}: defer func() { <-s.challengeSem }() case <-ctx.Done(): log.Printf("console: sign-in email to %s dropped: no send slot free", to) return } if mailer == nil { log.Printf("console: sign-in email to %s dropped: no console mailer", to) return } if err := mailer.Send(ctx, to, subject, body); err != nil { log.Printf("console: sign-in email to %s: %v", to, err) } }() } // requestFlow is this browser's sign-in flow token, or a fresh one when it // sent none we could have minted. It is "" only when no randomness was to be // had. func (s *Server) requestFlow(r *http.Request) string { if c, err := r.Cookie(flowCookie); err == nil && len(c.Value) >= 32 && len(c.Value) <= 128 { return c.Value } t, err := randomToken() if err != nil { log.Printf("console: sign-in flow token: %v", err) return "" } return t } // setFlow (re)sets the flow cookie for another ConfirmationTTL. func (s *Server) setFlow(w http.ResponseWriter, flow string) { if flow != "" { s.setCookie(w, flowCookie, flow, int(ConfirmationTTL.Seconds())) } } // flowLock serialises /auth/continue's limits and challenge writes for one // flow, so overlapping submissions from one browser (a double-clicked // Continue) cannot both see "no challenge yet". Striped by the flow's hash: // bounded memory, and two flows rarely share a stripe. func (s *Server) flowLock(flow string) *sync.Mutex { sum := sha256.Sum256([]byte(flow)) return &s.flowLocks[sum[0]] } // homeForm is what the front page's one form starts with. flow, when set, is // the flow the caller already holds for this request. type homeForm struct { Prefill, Next, Banner, Error string NoMail bool flow string } // renderHome renders the signed-out front page and (re)sets the browser's // sign-in flow. func (s *Server) renderHome(w http.ResponseWriter, r *http.Request, f homeForm) { if f.flow == "" { f.flow = s.requestFlow(r) } s.setFlow(w, f.flow) s.render(w, http.StatusOK, "home", page{ Title: "Sign in", Prefill: f.Prefill, Next: safeNext(f.Next), Banner: f.Banner, Error: f.Error, NoMail: f.NoMail, }) } // renderCheck renders "Check your email" for addr and (re)sets the flow // cookie. Every email-bound outcome of the front door renders exactly this, // so the page never says whether a message was sent, throttled or refused. func (s *Server) renderCheck(w http.ResponseWriter, flow, addr, next string) { s.renderCheckError(w, flow, addr, next, "") } // renderCheckError is the same page with a refusal of a typed code under the // code box. Its address and next are what the request carried, never the // stored challenge's, so a refused request's page and a sent one's stay the // same through every later code. func (s *Server) renderCheckError(w http.ResponseWriter, flow, addr, next, codeErr string) { s.setFlow(w, flow) sent := "" if addr != "" { sent = copyText("check.sent", "address", addr) } s.render(w, http.StatusOK, "check", page{ Title: copyText("check.title"), CheckSent: sent, Prefill: addr, Next: safeNext(next), CodeError: codeErr, }) } // safeNext returns raw when it is a local path and "" otherwise: it starts // with "/", not "//", has no backslash, and parses with no scheme or host. // Anything else is dropped silently and the usual landing applies. func safeNext(raw string) string { if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.Contains(raw, `\`) { return "" } u, err := url.Parse(raw) if err != nil || u.Scheme != "" || u.Host != "" { return "" } return raw } // finishSignin is what signing in does once a door has proved an address, // shared by the Keymail callback and the email link and code handlers: record // the user, store the Keymail grant (Keymail only), open a session that // remembers its method, set the cookies, then land. An email sign-in also // records a step-up at the moment of use, the same proof a step-up would ask // for minutes later. The current account is resolved (minted for a new user) // before any redirect, so onboarding runs before next. func (s *Server) finishSignin(w http.ResponseWriter, r *http.Request, addr, keymailToken, method, next string) { now := time.Now() created, err := s.store.EnsureUser(addr, now) if err != nil { s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not record your account. Please try again.") return } if keymailToken != "" { if err := s.store.SetGrantToken(addr, keymailToken, now); err != nil { s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not store your sign-in grant. Please try again.") return } } raw, err := s.store.CreateSessionMethod(addr, keymailToken, method, now) if err != nil { s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create your session. Please try again.") return } s.clearCookie(w, authCookie) s.setCookie(w, sessionCookie, raw, int(SessionTTL.Seconds())) if created { s.setCookie(w, welcomeCookie, "1", 300) } if method == MethodEmail { if err := s.store.RecordStepUp(raw, addr, now, now); err != nil { // Not fatal: the session is good, and the first sensitive action // will ask for a fresh proof. log.Printf("console: step-up record for %s: %v", addr, err) } } if n := safeNext(next); n != "" { if _, err := s.currentAccount(r.Context(), addr); err != nil { log.Printf("console: landing for %s: %v", addr, err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not open your account. Please try again.") return } http.Redirect(w, r, n, http.StatusSeeOther) return } s.redirectToCurrentAccount(w, r, addr, http.StatusSeeOther) } // renderLinkExpired is the answer to an unknown, expired or spent link. func (s *Server) renderLinkExpired(w http.ResponseWriter, r *http.Request) { s.renderMessage(w, r, http.StatusGone, "Link expired", copyText("link.expired")) } // handleEmailLinkPage is GET /auth/email/link?t= (spec 5.1). It never spends // the link, so a mail scanner's fetch leaves it good: it renders a confirm // page naming the address, whose form POSTs the token with a fresh nonce that // is also set in linkConfirmCookie. func (s *Server) handleEmailLinkPage(w http.ResponseWriter, r *http.Request) { link := r.URL.Query().Get("t") c, ok := s.store.PeekEmailLink(link, time.Now()) if link == "" || !ok { s.renderLinkExpired(w, r) return } switch c.Purpose { case ChallengeSignIn: case ChallengeStepUp: s.stepUpLinkPage(w, r, link, c) return default: s.renderLinkExpired(w, r) return } nonce, err := randomToken() if err != nil { s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.") return } s.setCookie(w, linkConfirmCookie, nonce, int(ConfirmationTTL.Seconds())) s.render(w, http.StatusOK, "email_link", page{ Title: copyText("link.title", "address", c.Address), LinkToken: link, LinkNonce: nonce, }) } // handleEmailLink is POST /auth/email/link (spec 5.1): it spends the link and // signs this browser in, whichever browser it is. Login CSRF is refused // first, before anything is spent, so a refused POST leaves the link good: the // request must be same-origin and carry linkConfirmCookie equal to the form's // nonce. func (s *Server) handleEmailLink(w http.ResponseWriter, r *http.Request) { nonce := r.PostFormValue("nonce") ck, err := r.Cookie(linkConfirmCookie) if !s.sameOrigin(r) || err != nil || ck.Value == "" || nonce == "" || subtle.ConstantTimeCompare([]byte(ck.Value), []byte(nonce)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", copyText("link.wrong_site")) return } s.clearCookie(w, linkConfirmCookie) now := time.Now() link := r.PostFormValue("t") peek, ok := s.store.PeekEmailLink(link, now) if link == "" || !ok { s.renderLinkExpired(w, r) return } switch peek.Purpose { case ChallengeSignIn: case ChallengeStepUp: // Never a sign-in: a step-up link only confirms the session it was // sent for. s.stepUpLink(w, r, link, peek, now) return default: s.renderLinkExpired(w, r) return } // Spending it also ends the flow's other sign-in challenges. c, ok := s.store.TakeEmailLink(link, now) if !ok || c.Purpose != ChallengeSignIn { s.renderLinkExpired(w, r) return } s.finishSignin(w, r, c.Address, "", MethodEmail, c.Next) } // handleEmailCode is POST /auth/email/code (spec 5.2): the typed code is // checked only against the newest live sign-in challenge of this browser's // flow. A challenge that was sent and one a refused request recorded unsent // answer alike, so the code box says nothing about throttling. Wrong codes // count against the challenge's address and the client IP across every // challenge (spec 5); past either cap the code is refused unchecked and // nothing is burned, so the emailed link still works. func (s *Server) handleEmailCode(w http.ResponseWriter, r *http.Request) { if !s.sameOrigin(r) { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } now := time.Now() flow := s.requestFlow(r) // The page shows the address and next the form carried, never the stored // challenge's: shown only, never used as a key. addr, _ := canonicalAddress(r.PostFormValue("address")) next := r.PostFormValue("next") c, ok := s.store.LiveEmailChallenge(ChallengeSignIn, hashToken(flow), now) if flow == "" || !ok { s.renderCheckError(w, flow, addr, next, copyText("check.code_used_up")) return } // The cap is keyed by the stored challenge's address, never by the form. // One guess is reserved before the check, so concurrent guesses cannot all // pass on the same count; only a right code takes it back. ip := s.clientIP(r) done, allowed := s.limits.reserveGuess(c.Address, ip) if !allowed { log.Printf("console: code for %s from %s refused: too many wrong codes", c.Address, ip) s.renderCheckError(w, flow, addr, next, copyText("check.code_throttled")) return } got, res := s.store.TakeEmailCode(c, strings.TrimSpace(r.PostFormValue("code")), now) done(res == CodeOK) switch res { case CodeOK: s.finishSignin(w, r, got.Address, "", MethodEmail, got.Next) case CodeWrong: s.renderCheckError(w, flow, addr, next, copyText("check.code_wrong")) default: // CodeBurned (this wrong code was the last) or CodeGone (the challenge // went meanwhile, or predates a restart); both keep the reservation. s.renderCheckError(w, flow, addr, next, copyText("check.code_used_up")) } } // ---- re-confirmation by email (spec 6) -------------------------------------- // // A session that signed in by email proves it is still there the way it // signed in: an emailed link or code. The step-up gate's properties hold as // for Keymail (stepup.go): the challenge is bound to the session that asked // and to the held action's hash, the held action is redeemed only through // takePendingActionByHash, and requireFreshAuth still runs after every // membership and CSRF gate. A step-up challenge never signs anyone in. // emailStepUp is requireFreshAuth's answer for an email session that is not // fresh: hold the action, email a link and code bound to this session and // that action, and show "Confirm it's you". With no mailer ready it fails // closed, holding nothing. Over the send limit, a session that already has a // live step-up challenge sees that one's page again (the earlier email still // works); any other fails closed. func (s *Server) emailStepUp(w http.ResponseWriter, r *http.Request, addr, raw string, now time.Time) { if !s.mailReady(r.Context()) { log.Printf("console: step-up for %s: the console mailer is not ready", addr) s.renderMessage(w, r, http.StatusServiceUnavailable, "Error", stepUpHoldFailed) return } if !s.limits.stepUpSend(addr) { if c, ok := s.liveStepUp(raw, now); ok { s.renderStepUp(w, r, c.Address, "") return } log.Printf("console: step-up email to %s over its limits; not sent", addr) s.renderMessage(w, r, http.StatusServiceUnavailable, "Error", stepUpHoldFailed) return } pending, ok := s.stashRequest(w, r, addr, raw, now) if !ok { return } // It replaces this session's earlier step-up challenge, if any. link, code, err := s.store.CreateEmailChallenge(EmailChallenge{ Purpose: ChallengeStepUp, Address: addr, SessionHash: hashToken(raw), PendingHash: hashToken(pending), ExpiresAt: now.Add(stepUpFlowTTL), }, now) if err != nil { log.Printf("console: step-up challenge for %s: %v", addr, err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the confirmation. Please try again.") return } console := s.consoleName() s.sendChallengeAsync(addr, copyText("email.stepup.subject", "console", console), copyText("email.stepup.body", "console", console, "address", addr, "link", s.cfg.Origin+"/auth/email/link?t="+link, "code", code)) s.renderStepUp(w, r, addr, "") } // stepUpFor reports whether c is a step-up challenge made for the session raw. func stepUpFor(c *EmailChallenge, raw string) bool { return c != nil && c.Purpose == ChallengeStepUp && c.SessionHash != "" && subtle.ConstantTimeCompare([]byte(c.SessionHash), []byte(hashToken(raw))) == 1 } // liveStepUp is the newest live step-up challenge made for the session raw. func (s *Server) liveStepUp(raw string, now time.Time) (*EmailChallenge, bool) { c, ok := s.store.LiveEmailChallenge(ChallengeStepUp, hashToken(raw), now) if !ok || !stepUpFor(c, raw) { return nil, false } return c, true } // renderStepUp renders "Confirm it's you" for addr, with a refusal of a typed // code under the code box when codeErr is set. func (s *Server) renderStepUp(w http.ResponseWriter, r *http.Request, addr, codeErr string) { p := s.basePage(r) p.Title = copyText("stepup.title") p.CheckSent = copyText("stepup.sent", "address", addr) p.CodeError = codeErr s.render(w, http.StatusOK, "stepup_email", p) } // renderStepUpExpired answers a step-up that has expired, was used, or has no // held action left to resume. func (s *Server) renderStepUpExpired(w http.ResponseWriter, r *http.Request) { s.renderMessage(w, r, http.StatusGone, "Confirmation expired", copyText("stepup.expired")) } // renderStepUpWrongBrowser answers a step-up link opened anywhere but the // session that asked for it. func (s *Server) renderStepUpWrongBrowser(w http.ResponseWriter, r *http.Request) { s.renderMessage(w, r, http.StatusForbidden, copyText("stepup.link_title"), copyText("stepup.wrong_browser")) } // stepUpLinkPage is GET /auth/email/link for a step-up challenge. It spends // nothing. Only the session the challenge was made for gets the confirm page // (with its login-CSRF nonce, as for sign-in); anyone else is told to use the // browser where they started. func (s *Server) stepUpLinkPage(w http.ResponseWriter, r *http.Request, link string, c *EmailChallenge) { _, raw, ok := s.session(r) if !ok || !stepUpFor(c, raw) { s.renderStepUpWrongBrowser(w, r) return } nonce, err := randomToken() if err != nil { s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the confirmation. Please try again.") return } s.setCookie(w, linkConfirmCookie, nonce, int(ConfirmationTTL.Seconds())) p := s.basePage(r) p.Title = copyText("stepup.link_title") p.LinkToken = link p.LinkNonce = nonce p.StepUpLink = true s.render(w, http.StatusOK, "email_link", p) } // stepUpLink is POST /auth/email/link for a step-up challenge, after the // nonce and origin checks: a session other than the one the challenge was // made for is refused with the link unspent; the right one spends it and // finishes the step-up. func (s *Server) stepUpLink(w http.ResponseWriter, r *http.Request, link string, peek *EmailChallenge, now time.Time) { addr, raw, ok := s.session(r) if !ok || !stepUpFor(peek, raw) { s.renderStepUpWrongBrowser(w, r) return } c, ok := s.store.TakeEmailLink(link, now) if !ok { s.renderStepUpExpired(w, r) return } s.finishStepUp(w, r, addr, raw, c, now) } // handleStepUpEmailCode is POST /auth/email/stepup/code: the typed code is // checked only against the newest live step-up challenge made for this // browser's session. Wrong codes count against the // challenge's address and the client IP, as for sign-in codes. func (s *Server) handleStepUpEmailCode(w http.ResponseWriter, r *http.Request) { addr, raw, ok := s.session(r) if !ok { http.Redirect(w, r, "/", http.StatusFound) return } if !s.sameOrigin(r) { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } now := time.Now() c, ok := s.liveStepUp(raw, now) if !ok { s.renderStepUpExpired(w, r) return } ip := s.clientIP(r) done, allowed := s.limits.reserveGuess(c.Address, ip) if !allowed { log.Printf("console: step-up code for %s from %s refused: too many wrong codes", c.Address, ip) s.renderStepUp(w, r, c.Address, copyText("check.code_throttled")) return } got, res := s.store.TakeEmailCode(c, strings.TrimSpace(r.PostFormValue("code")), now) done(res == CodeOK) switch res { case CodeOK: s.finishStepUp(w, r, addr, raw, got, now) case CodeWrong: s.renderStepUp(w, r, c.Address, copyText("check.code_wrong")) default: s.renderStepUpExpired(w, r) } } // finishStepUp is the tail both step-up doors share, the same as // handleStepUpCallback's: record presence at the moment the link or code was // used, redeem the held action this challenge names, and resume it through // the router. A spent challenge that was not this session's, or names another // address, records nothing. func (s *Server) finishStepUp(w http.ResponseWriter, r *http.Request, addr, raw string, c *EmailChallenge, now time.Time) { if !stepUpFor(c, raw) || c.Address != addr { s.renderStepUpExpired(w, r) return } if err := s.store.RecordStepUp(raw, addr, now, now); err != nil { log.Printf("console: record step-up: %v", err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not record your confirmation. Please try the action again.") return } pending, ok := s.store.takePendingActionByHash(c.PendingHash, raw, now) if !ok { s.renderStepUpExpired(w, r) return } s.resume(w, r, pending) }