carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "crypto/sha256"
6 "crypto/subtle"
7 "log"
8 "net/http"
9 netmail "net/mail"
10 "net/url"
11 "strings"
12 "sync"
13 "time"
14)
15
16// nextCookie carries a safe return path across the Keymail round trip, beside
17// the auth cookie. (An email sign-in keeps its next on the challenge row.)
18const nextCookie = "carlos_console_next"
19
20// flowCookie holds this browser's sign-in flow: a random token, set whenever
21// a sign-in form renders and kept for ConfirmationTTL from the last one. A
22// sign-in challenge stores the flow's hash, so a code typed here is checked
23// against this flow's newest live challenge and a second request from here
24// replaces it. Its value never depends on how /auth/continue answered.
25const flowCookie = "carlos_console_flow"
26
27// linkConfirmCookie holds the nonce the emailed link's confirm page also puts
28// in its form (spec 5.1). A cross-site POST carries no Lax cookie, so an
29// attacker cannot submit their own link into someone else's browser.
30const linkConfirmCookie = "carlos_console_linkconfirm"
31
32// Sign-in sends run off the request (spec 4.2): at most challengeSendSlots at
33// once, each with challengeSendTimeout to get a slot and finish.
34const (
35 challengeSendSlots = 4
36 challengeSendTimeout = 20 * time.Second
37)
38
39// signinView is the approved front-page and "Check your email" copy the
40// templates show; render fills it on every page.
41type signinView struct {
42 EmailLabel, EmailPlaceholder, EmailButton, Small, NoMail string
43 CheckTitle, CodeLabel, CodeButton, Nothing, AgainButton string
44 LinkButton string
45 // The email step-up's code box and its link's confirm button.
46 StepUpCodeLabel, StepUpCodeButton, StepUpLinkButton string
47}
48
49var approvedSigninView = signinView{
50 EmailLabel: copyText("home.email_label"),
51 EmailPlaceholder: copyText("home.email_placeholder"),
52 EmailButton: copyText("home.email_button"),
53 Small: copyText("home.small"),
54 NoMail: copyText("home.no_mail"),
55 CheckTitle: copyText("check.title"),
56 CodeLabel: copyText("check.code_label"),
57 CodeButton: copyText("check.code_button"),
58 Nothing: copyText("check.nothing"),
59 AgainButton: copyText("check.again_button"),
60 LinkButton: copyText("link.button"),
61 StepUpCodeLabel: copyText("stepup.code_label"),
62 StepUpCodeButton: copyText("stepup.code_button"),
63 StepUpLinkButton: copyText("stepup.link_button"),
64}
65
66// sameOrigin reports whether a form POST came from this console's own pages:
67// an Origin header must name the configured origin's host; without one,
68// Sec-Fetch-Site must be same-origin or none; a request carrying neither (an
69// old browser, a script) passes, as every other form here does.
70func (s *Server) sameOrigin(r *http.Request) bool {
71 if o := r.Header.Get("Origin"); o != "" {
72 got, err := url.Parse(o)
73 if err != nil || got.Host == "" {
74 return false
75 }
76 want, err := url.Parse(s.cfg.Origin)
77 return err == nil && want.Host != "" && strings.EqualFold(got.Host, want.Host)
78 }
79 switch r.Header.Get("Sec-Fetch-Site") {
80 case "", "same-origin", "none":
81 return true
82 }
83 return false
84}
85
86// canonicalAddress is the one form of a typed address used everywhere after
87// the front door (spec 11): trimmed and lower-cased, and accepted only when it
88// is a bare address, so display-name and list forms are refused before any
89// challenge, rate-limit key or user row exists.
90func canonicalAddress(raw string) (string, bool) {
91 a := strings.ToLower(strings.TrimSpace(raw))
92 if !ValidAddress(a) {
93 return "", false
94 }
95 if parsed, err := netmail.ParseAddress(a); err != nil || parsed.Address != a {
96 return "", false
97 }
98 return a, true
99}
100
101// keymailAddress is discovery (spec 4.1): the configured Keymail host's own
102// addresses, or, when mayLookup, any address Keymail's public lookup says is
103// a mailbox. Only those public answers decide; any lookup failure means email.
104func (s *Server) keymailAddress(ctx context.Context, addr string, mayLookup bool) bool {
105 if s.km == nil {
106 return false
107 }
108 if h := s.km.Host(); h != "" && addr[strings.LastIndex(addr, "@")+1:] == h {
109 return true
110 }
111 if !mayLookup {
112 return false
113 }
114 ok, err := s.km.HasMailbox(ctx, addr)
115 if err != nil {
116 log.Printf("console: keymail lookup for %s: %v (sending email)", addr, err)
117 return false
118 }
119 return ok
120}
121
122// mailReady reports whether the console mailer can send now. No mailer is
123// not ready.
124func (s *Server) mailReady(ctx context.Context) bool {
125 return s.consoleMail != nil && s.consoleMail.Ready(ctx)
126}
127
128// sendChallengeAsync sends a sign-in or step-up message through the console
129// mailer on its own goroutine, so the page renders at once and send latency
130// is no oracle. A send that cannot get one of the challengeSendSlots within
131// its context is dropped; every failure is logged.
132func (s *Server) sendChallengeAsync(to, subject, body string) {
133 mailer := s.consoleMail
134 go func() {
135 defer func() {
136 if s.challengeSent != nil {
137 s.challengeSent <- struct{}{}
138 }
139 }()
140 ctx, cancel := context.WithTimeout(context.Background(), challengeSendTimeout)
141 defer cancel()
142 select {
143 case s.challengeSem <- struct{}{}:
144 defer func() { <-s.challengeSem }()
145 case <-ctx.Done():
146 log.Printf("console: sign-in email to %s dropped: no send slot free", to)
147 return
148 }
149 if mailer == nil {
150 log.Printf("console: sign-in email to %s dropped: no console mailer", to)
151 return
152 }
153 if err := mailer.Send(ctx, to, subject, body); err != nil {
154 log.Printf("console: sign-in email to %s: %v", to, err)
155 }
156 }()
157}
158
159// requestFlow is this browser's sign-in flow token, or a fresh one when it
160// sent none we could have minted. It is "" only when no randomness was to be
161// had.
162func (s *Server) requestFlow(r *http.Request) string {
163 if c, err := r.Cookie(flowCookie); err == nil && len(c.Value) >= 32 && len(c.Value) <= 128 {
164 return c.Value
165 }
166 t, err := randomToken()
167 if err != nil {
168 log.Printf("console: sign-in flow token: %v", err)
169 return ""
170 }
171 return t
172}
173
174// setFlow (re)sets the flow cookie for another ConfirmationTTL.
175func (s *Server) setFlow(w http.ResponseWriter, flow string) {
176 if flow != "" {
177 s.setCookie(w, flowCookie, flow, int(ConfirmationTTL.Seconds()))
178 }
179}
180
181// flowLock serialises /auth/continue's limits and challenge writes for one
182// flow, so overlapping submissions from one browser (a double-clicked
183// Continue) cannot both see "no challenge yet". Striped by the flow's hash:
184// bounded memory, and two flows rarely share a stripe.
185func (s *Server) flowLock(flow string) *sync.Mutex {
186 sum := sha256.Sum256([]byte(flow))
187 return &s.flowLocks[sum[0]]
188}
189
190// homeForm is what the front page's one form starts with. flow, when set, is
191// the flow the caller already holds for this request.
192type homeForm struct {
193 Prefill, Next, Banner, Error string
194 NoMail bool
195 flow string
196}
197
198// renderHome renders the signed-out front page and (re)sets the browser's
199// sign-in flow.
200func (s *Server) renderHome(w http.ResponseWriter, r *http.Request, f homeForm) {
201 if f.flow == "" {
202 f.flow = s.requestFlow(r)
203 }
204 s.setFlow(w, f.flow)
205 s.render(w, http.StatusOK, "home", page{
206 Title: "Sign in", Prefill: f.Prefill, Next: safeNext(f.Next),
207 Banner: f.Banner, Error: f.Error, NoMail: f.NoMail,
208 })
209}
210
211// renderCheck renders "Check your email" for addr and (re)sets the flow
212// cookie. Every email-bound outcome of the front door renders exactly this,
213// so the page never says whether a message was sent, throttled or refused.
214func (s *Server) renderCheck(w http.ResponseWriter, flow, addr, next string) {
215 s.renderCheckError(w, flow, addr, next, "")
216}
217
218// renderCheckError is the same page with a refusal of a typed code under the
219// code box. Its address and next are what the request carried, never the
220// stored challenge's, so a refused request's page and a sent one's stay the
221// same through every later code.
222func (s *Server) renderCheckError(w http.ResponseWriter, flow, addr, next, codeErr string) {
223 s.setFlow(w, flow)
224 sent := ""
225 if addr != "" {
226 sent = copyText("check.sent", "address", addr)
227 }
228 s.render(w, http.StatusOK, "check", page{
229 Title: copyText("check.title"),
230 CheckSent: sent,
231 Prefill: addr,
232 Next: safeNext(next),
233 CodeError: codeErr,
234 })
235}
236
237// safeNext returns raw when it is a local path and "" otherwise: it starts
238// with "/", not "//", has no backslash, and parses with no scheme or host.
239// Anything else is dropped silently and the usual landing applies.
240func safeNext(raw string) string {
241 if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.Contains(raw, `\`) {
242 return ""
243 }
244 u, err := url.Parse(raw)
245 if err != nil || u.Scheme != "" || u.Host != "" {
246 return ""
247 }
248 return raw
249}
250
251// finishSignin is what signing in does once a door has proved an address,
252// shared by the Keymail callback and the email link and code handlers: record
253// the user, store the Keymail grant (Keymail only), open a session that
254// remembers its method, set the cookies, then land. An email sign-in also
255// records a step-up at the moment of use, the same proof a step-up would ask
256// for minutes later. The current account is resolved (minted for a new user)
257// before any redirect, so onboarding runs before next.
258func (s *Server) finishSignin(w http.ResponseWriter, r *http.Request, addr, keymailToken, method, next string) {
259 now := time.Now()
260 created, err := s.store.EnsureUser(addr, now)
261 if err != nil {
262 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not record your account. Please try again.")
263 return
264 }
265 if keymailToken != "" {
266 if err := s.store.SetGrantToken(addr, keymailToken, now); err != nil {
267 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not store your sign-in grant. Please try again.")
268 return
269 }
270 }
271 raw, err := s.store.CreateSessionMethod(addr, keymailToken, method, now)
272 if err != nil {
273 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create your session. Please try again.")
274 return
275 }
276 s.clearCookie(w, authCookie)
277 s.setCookie(w, sessionCookie, raw, int(SessionTTL.Seconds()))
278 if created {
279 s.setCookie(w, welcomeCookie, "1", 300)
280 }
281 if method == MethodEmail {
282 if err := s.store.RecordStepUp(raw, addr, now, now); err != nil {
283 // Not fatal: the session is good, and the first sensitive action
284 // will ask for a fresh proof.
285 log.Printf("console: step-up record for %s: %v", addr, err)
286 }
287 }
288 if n := safeNext(next); n != "" {
289 if _, err := s.currentAccount(r.Context(), addr); err != nil {
290 log.Printf("console: landing for %s: %v", addr, err)
291 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not open your account. Please try again.")
292 return
293 }
294 http.Redirect(w, r, n, http.StatusSeeOther)
295 return
296 }
297 s.redirectToCurrentAccount(w, r, addr, http.StatusSeeOther)
298}
299
300// renderLinkExpired is the answer to an unknown, expired or spent link.
301func (s *Server) renderLinkExpired(w http.ResponseWriter, r *http.Request) {
302 s.renderMessage(w, r, http.StatusGone, "Link expired", copyText("link.expired"))
303}
304
305// handleEmailLinkPage is GET /auth/email/link?t= (spec 5.1). It never spends
306// the link, so a mail scanner's fetch leaves it good: it renders a confirm
307// page naming the address, whose form POSTs the token with a fresh nonce that
308// is also set in linkConfirmCookie.
309func (s *Server) handleEmailLinkPage(w http.ResponseWriter, r *http.Request) {
310 link := r.URL.Query().Get("t")
311 c, ok := s.store.PeekEmailLink(link, time.Now())
312 if link == "" || !ok {
313 s.renderLinkExpired(w, r)
314 return
315 }
316 switch c.Purpose {
317 case ChallengeSignIn:
318 case ChallengeStepUp:
319 s.stepUpLinkPage(w, r, link, c)
320 return
321 default:
322 s.renderLinkExpired(w, r)
323 return
324 }
325 nonce, err := randomToken()
326 if err != nil {
327 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.")
328 return
329 }
330 s.setCookie(w, linkConfirmCookie, nonce, int(ConfirmationTTL.Seconds()))
331 s.render(w, http.StatusOK, "email_link", page{
332 Title: copyText("link.title", "address", c.Address),
333 LinkToken: link,
334 LinkNonce: nonce,
335 })
336}
337
338// handleEmailLink is POST /auth/email/link (spec 5.1): it spends the link and
339// signs this browser in, whichever browser it is. Login CSRF is refused
340// first, before anything is spent, so a refused POST leaves the link good: the
341// request must be same-origin and carry linkConfirmCookie equal to the form's
342// nonce.
343func (s *Server) handleEmailLink(w http.ResponseWriter, r *http.Request) {
344 nonce := r.PostFormValue("nonce")
345 ck, err := r.Cookie(linkConfirmCookie)
346 if !s.sameOrigin(r) || err != nil || ck.Value == "" || nonce == "" ||
347 subtle.ConstantTimeCompare([]byte(ck.Value), []byte(nonce)) != 1 {
348 s.renderMessage(w, r, http.StatusForbidden, "Refused", copyText("link.wrong_site"))
349 return
350 }
351 s.clearCookie(w, linkConfirmCookie)
352 now := time.Now()
353 link := r.PostFormValue("t")
354 peek, ok := s.store.PeekEmailLink(link, now)
355 if link == "" || !ok {
356 s.renderLinkExpired(w, r)
357 return
358 }
359 switch peek.Purpose {
360 case ChallengeSignIn:
361 case ChallengeStepUp:
362 // Never a sign-in: a step-up link only confirms the session it was
363 // sent for.
364 s.stepUpLink(w, r, link, peek, now)
365 return
366 default:
367 s.renderLinkExpired(w, r)
368 return
369 }
370 // Spending it also ends the flow's other sign-in challenges.
371 c, ok := s.store.TakeEmailLink(link, now)
372 if !ok || c.Purpose != ChallengeSignIn {
373 s.renderLinkExpired(w, r)
374 return
375 }
376 s.finishSignin(w, r, c.Address, "", MethodEmail, c.Next)
377}
378
379// handleEmailCode is POST /auth/email/code (spec 5.2): the typed code is
380// checked only against the newest live sign-in challenge of this browser's
381// flow. A challenge that was sent and one a refused request recorded unsent
382// answer alike, so the code box says nothing about throttling. Wrong codes
383// count against the challenge's address and the client IP across every
384// challenge (spec 5); past either cap the code is refused unchecked and
385// nothing is burned, so the emailed link still works.
386func (s *Server) handleEmailCode(w http.ResponseWriter, r *http.Request) {
387 if !s.sameOrigin(r) {
388 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
389 return
390 }
391 now := time.Now()
392 flow := s.requestFlow(r)
393 // The page shows the address and next the form carried, never the stored
394 // challenge's: shown only, never used as a key.
395 addr, _ := canonicalAddress(r.PostFormValue("address"))
396 next := r.PostFormValue("next")
397 c, ok := s.store.LiveEmailChallenge(ChallengeSignIn, hashToken(flow), now)
398 if flow == "" || !ok {
399 s.renderCheckError(w, flow, addr, next, copyText("check.code_used_up"))
400 return
401 }
402 // The cap is keyed by the stored challenge's address, never by the form.
403 // One guess is reserved before the check, so concurrent guesses cannot all
404 // pass on the same count; only a right code takes it back.
405 ip := s.clientIP(r)
406 done, allowed := s.limits.reserveGuess(c.Address, ip)
407 if !allowed {
408 log.Printf("console: code for %s from %s refused: too many wrong codes", c.Address, ip)
409 s.renderCheckError(w, flow, addr, next, copyText("check.code_throttled"))
410 return
411 }
412 got, res := s.store.TakeEmailCode(c, strings.TrimSpace(r.PostFormValue("code")), now)
413 done(res == CodeOK)
414 switch res {
415 case CodeOK:
416 s.finishSignin(w, r, got.Address, "", MethodEmail, got.Next)
417 case CodeWrong:
418 s.renderCheckError(w, flow, addr, next, copyText("check.code_wrong"))
419 default:
420 // CodeBurned (this wrong code was the last) or CodeGone (the challenge
421 // went meanwhile, or predates a restart); both keep the reservation.
422 s.renderCheckError(w, flow, addr, next, copyText("check.code_used_up"))
423 }
424}
425
426// ---- re-confirmation by email (spec 6) --------------------------------------
427//
428// A session that signed in by email proves it is still there the way it
429// signed in: an emailed link or code. The step-up gate's properties hold as
430// for Keymail (stepup.go): the challenge is bound to the session that asked
431// and to the held action's hash, the held action is redeemed only through
432// takePendingActionByHash, and requireFreshAuth still runs after every
433// membership and CSRF gate. A step-up challenge never signs anyone in.
434
435// emailStepUp is requireFreshAuth's answer for an email session that is not
436// fresh: hold the action, email a link and code bound to this session and
437// that action, and show "Confirm it's you". With no mailer ready it fails
438// closed, holding nothing. Over the send limit, a session that already has a
439// live step-up challenge sees that one's page again (the earlier email still
440// works); any other fails closed.
441func (s *Server) emailStepUp(w http.ResponseWriter, r *http.Request, addr, raw string, now time.Time) {
442 if !s.mailReady(r.Context()) {
443 log.Printf("console: step-up for %s: the console mailer is not ready", addr)
444 s.renderMessage(w, r, http.StatusServiceUnavailable, "Error", stepUpHoldFailed)
445 return
446 }
447 if !s.limits.stepUpSend(addr) {
448 if c, ok := s.liveStepUp(raw, now); ok {
449 s.renderStepUp(w, r, c.Address, "")
450 return
451 }
452 log.Printf("console: step-up email to %s over its limits; not sent", addr)
453 s.renderMessage(w, r, http.StatusServiceUnavailable, "Error", stepUpHoldFailed)
454 return
455 }
456 pending, ok := s.stashRequest(w, r, addr, raw, now)
457 if !ok {
458 return
459 }
460 // It replaces this session's earlier step-up challenge, if any.
461 link, code, err := s.store.CreateEmailChallenge(EmailChallenge{
462 Purpose: ChallengeStepUp,
463 Address: addr,
464 SessionHash: hashToken(raw),
465 PendingHash: hashToken(pending),
466 ExpiresAt: now.Add(stepUpFlowTTL),
467 }, now)
468 if err != nil {
469 log.Printf("console: step-up challenge for %s: %v", addr, err)
470 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the confirmation. Please try again.")
471 return
472 }
473 console := s.consoleName()
474 s.sendChallengeAsync(addr,
475 copyText("email.stepup.subject", "console", console),
476 copyText("email.stepup.body", "console", console, "address", addr,
477 "link", s.cfg.Origin+"/auth/email/link?t="+link, "code", code))
478 s.renderStepUp(w, r, addr, "")
479}
480
481// stepUpFor reports whether c is a step-up challenge made for the session raw.
482func stepUpFor(c *EmailChallenge, raw string) bool {
483 return c != nil && c.Purpose == ChallengeStepUp && c.SessionHash != "" &&
484 subtle.ConstantTimeCompare([]byte(c.SessionHash), []byte(hashToken(raw))) == 1
485}
486
487// liveStepUp is the newest live step-up challenge made for the session raw.
488func (s *Server) liveStepUp(raw string, now time.Time) (*EmailChallenge, bool) {
489 c, ok := s.store.LiveEmailChallenge(ChallengeStepUp, hashToken(raw), now)
490 if !ok || !stepUpFor(c, raw) {
491 return nil, false
492 }
493 return c, true
494}
495
496// renderStepUp renders "Confirm it's you" for addr, with a refusal of a typed
497// code under the code box when codeErr is set.
498func (s *Server) renderStepUp(w http.ResponseWriter, r *http.Request, addr, codeErr string) {
499 p := s.basePage(r)
500 p.Title = copyText("stepup.title")
501 p.CheckSent = copyText("stepup.sent", "address", addr)
502 p.CodeError = codeErr
503 s.render(w, http.StatusOK, "stepup_email", p)
504}
505
506// renderStepUpExpired answers a step-up that has expired, was used, or has no
507// held action left to resume.
508func (s *Server) renderStepUpExpired(w http.ResponseWriter, r *http.Request) {
509 s.renderMessage(w, r, http.StatusGone, "Confirmation expired", copyText("stepup.expired"))
510}
511
512// renderStepUpWrongBrowser answers a step-up link opened anywhere but the
513// session that asked for it.
514func (s *Server) renderStepUpWrongBrowser(w http.ResponseWriter, r *http.Request) {
515 s.renderMessage(w, r, http.StatusForbidden, copyText("stepup.link_title"), copyText("stepup.wrong_browser"))
516}
517
518// stepUpLinkPage is GET /auth/email/link for a step-up challenge. It spends
519// nothing. Only the session the challenge was made for gets the confirm page
520// (with its login-CSRF nonce, as for sign-in); anyone else is told to use the
521// browser where they started.
522func (s *Server) stepUpLinkPage(w http.ResponseWriter, r *http.Request, link string, c *EmailChallenge) {
523 _, raw, ok := s.session(r)
524 if !ok || !stepUpFor(c, raw) {
525 s.renderStepUpWrongBrowser(w, r)
526 return
527 }
528 nonce, err := randomToken()
529 if err != nil {
530 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the confirmation. Please try again.")
531 return
532 }
533 s.setCookie(w, linkConfirmCookie, nonce, int(ConfirmationTTL.Seconds()))
534 p := s.basePage(r)
535 p.Title = copyText("stepup.link_title")
536 p.LinkToken = link
537 p.LinkNonce = nonce
538 p.StepUpLink = true
539 s.render(w, http.StatusOK, "email_link", p)
540}
541
542// stepUpLink is POST /auth/email/link for a step-up challenge, after the
543// nonce and origin checks: a session other than the one the challenge was
544// made for is refused with the link unspent; the right one spends it and
545// finishes the step-up.
546func (s *Server) stepUpLink(w http.ResponseWriter, r *http.Request, link string, peek *EmailChallenge, now time.Time) {
547 addr, raw, ok := s.session(r)
548 if !ok || !stepUpFor(peek, raw) {
549 s.renderStepUpWrongBrowser(w, r)
550 return
551 }
552 c, ok := s.store.TakeEmailLink(link, now)
553 if !ok {
554 s.renderStepUpExpired(w, r)
555 return
556 }
557 s.finishStepUp(w, r, addr, raw, c, now)
558}
559
560// handleStepUpEmailCode is POST /auth/email/stepup/code: the typed code is
561// checked only against the newest live step-up challenge made for this
562// browser's session. Wrong codes count against the
563// challenge's address and the client IP, as for sign-in codes.
564func (s *Server) handleStepUpEmailCode(w http.ResponseWriter, r *http.Request) {
565 addr, raw, ok := s.session(r)
566 if !ok {
567 http.Redirect(w, r, "/", http.StatusFound)
568 return
569 }
570 if !s.sameOrigin(r) {
571 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
572 return
573 }
574 now := time.Now()
575 c, ok := s.liveStepUp(raw, now)
576 if !ok {
577 s.renderStepUpExpired(w, r)
578 return
579 }
580 ip := s.clientIP(r)
581 done, allowed := s.limits.reserveGuess(c.Address, ip)
582 if !allowed {
583 log.Printf("console: step-up code for %s from %s refused: too many wrong codes", c.Address, ip)
584 s.renderStepUp(w, r, c.Address, copyText("check.code_throttled"))
585 return
586 }
587 got, res := s.store.TakeEmailCode(c, strings.TrimSpace(r.PostFormValue("code")), now)
588 done(res == CodeOK)
589 switch res {
590 case CodeOK:
591 s.finishStepUp(w, r, addr, raw, got, now)
592 case CodeWrong:
593 s.renderStepUp(w, r, c.Address, copyText("check.code_wrong"))
594 default:
595 s.renderStepUpExpired(w, r)
596 }
597}
598
599// finishStepUp is the tail both step-up doors share, the same as
600// handleStepUpCallback's: record presence at the moment the link or code was
601// used, redeem the held action this challenge names, and resume it through
602// the router. A spent challenge that was not this session's, or names another
603// address, records nothing.
604func (s *Server) finishStepUp(w http.ResponseWriter, r *http.Request, addr, raw string, c *EmailChallenge, now time.Time) {
605 if !stepUpFor(c, raw) || c.Address != addr {
606 s.renderStepUpExpired(w, r)
607 return
608 }
609 if err := s.store.RecordStepUp(raw, addr, now, now); err != nil {
610 log.Printf("console: record step-up: %v", err)
611 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not record your confirmation. Please try the action again.")
612 return
613 }
614 pending, ok := s.store.takePendingActionByHash(c.PendingHash, raw, now)
615 if !ok {
616 s.renderStepUpExpired(w, r)
617 return
618 }
619 s.resume(w, r, pending)
620}
621