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
3// Re-confirmation by email (spec 6): a session that signed in by email proves
4// it is still there by an emailed link or code; a Keymail session keeps its
5// passkey and never gets an email instead (anti-goal: no downgrade).
6
7import (
8 "net/http"
9 "net/url"
10 "regexp"
11 "strings"
12 "testing"
13 "time"
14)
15
16var (
17 stepUpCodeRe = regexp.MustCompile(`type this code there: (\d{6})\b`)
18 stepUpLinkRe = regexp.MustCompile(`/auth/email/link\?t=([A-Za-z0-9_-]+)`)
19)
20
21// emailOwnerWorld is bob@example.com signed in by an emailed code on browser
22// c, owning his personal account with kim@keymail.dev as a second member,
23// and with the sign-in's own step-up gone stale, so the next sensitive action
24// asks for a fresh one. It returns the session token and the account's
25// path base ("/a/<sqid>").
26func emailOwnerWorld(t *testing.T, fx *consoleFixture, m *fakeConsoleMail, c *http.Client) (raw, base string) {
27 t.Helper()
28 const addr = "bob@example.com"
29 lookupAnswers(fx, addr, http.StatusNotFound)
30 code, _ := askSignin(t, fx, m, c, addr, "")
31 if status, loc, _ := postCode(t, fx, c, code); status != http.StatusSeeOther {
32 t.Fatalf("sign-in code = %d -> %q", status, loc)
33 }
34 raw = emailSession(t, fx, c, addr)
35 accts, err := fx.st.AccountsFor(addr)
36 if err != nil || len(accts) == 0 {
37 t.Fatalf("bob's accounts = %v, %v", accts, err)
38 }
39 if err := fx.st.AddMember(accts[0].ID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
40 t.Fatal(err)
41 }
42 if err := fx.st.ClearStepUp(raw); err != nil {
43 t.Fatal(err)
44 }
45 return raw, "/a/" + accts[0].Sqid
46}
47
48// kimRole is kim's role in bob's account ("" once removed).
49func kimRole(t *testing.T, fx *consoleFixture) string {
50 t.Helper()
51 role, _ := fx.st.Role(bobAccount(t, fx).ID, "kim@keymail.dev")
52 return role
53}
54
55// askStepUp POSTs the removal of kim from browser c and expects the "Confirm
56// it's you" page; it returns the code and link token of the step-up email
57// that went out.
58func askStepUp(t *testing.T, fx *consoleFixture, m *fakeConsoleMail, c *http.Client, raw, base string) (code, link string) {
59 t.Helper()
60 before := len(m.sentCopy())
61 status, _, body := postForm(t, c, fx.ts.URL+base+"/members/remove",
62 url.Values{"csrf": {raw}, "address": {"kim@keymail.dev"}})
63 for _, want := range []string{esc(copyText("stepup.title")), esc(copyText("stepup.sent", "address", "bob@example.com")),
64 `action="/auth/email/stepup/code"`, esc(copyText("stepup.code_label")), esc(copyText("stepup.code_button"))} {
65 if status != http.StatusOK || !strings.Contains(body, want) {
66 t.Fatalf("gated removal = %d, missing %q:\n%s", status, want, body)
67 }
68 }
69 if kimRole(t, fx) != RoleMember {
70 t.Fatal("kim was removed before the confirmation")
71 }
72 waitChallengeSent(t, fx, 1)
73 sent := m.sentCopy()
74 if len(sent) != before+1 {
75 t.Fatalf("console mailer has %d messages, want %d", len(sent), before+1)
76 }
77 to, body, _ := strings.Cut(sent[len(sent)-1], "|")
78 cm, lm := stepUpCodeRe.FindStringSubmatch(body), stepUpLinkRe.FindStringSubmatch(body)
79 if to != "bob@example.com" || cm == nil || lm == nil {
80 t.Fatalf("step-up email to %q has no code or link:\n%s", to, body)
81 }
82 return cm[1], lm[1]
83}
84
85// pendingRows counts the held actions.
86func pendingRows(t *testing.T, fx *consoleFixture) int {
87 t.Helper()
88 var n int
89 if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&n); err != nil {
90 t.Fatal(err)
91 }
92 return n
93}
94
95func postStepUpCode(t *testing.T, fx *consoleFixture, c *http.Client, code string) (int, string, string) {
96 t.Helper()
97 return postFrom(t, fx, c, "/auth/email/stepup/code", fx.ts.URL, url.Values{"code": {code}})
98}
99
100func TestEmailSessionStepUpByCode(t *testing.T) {
101 fx, m := newEmailFixture(t, true)
102 c := newBrowser(t)
103 raw, base := emailOwnerWorld(t, fx, m, c)
104 code, _ := askStepUp(t, fx, m, c, raw, base)
105
106 // The held action is parked server-side, without the session token.
107 stashed := pendingRows(t, fx)
108 var leaked int
109 if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions WHERE form LIKE ?`, "%"+raw+"%").Scan(&leaked); err != nil {
110 t.Fatal(err)
111 }
112 if stashed != 1 || leaked != 0 {
113 t.Fatalf("pending actions: %d parked, %d holding the session token", stashed, leaked)
114 }
115
116 // Cross-site posts are refused before the code is looked at.
117 if status, _, _ := postFrom(t, fx, c, "/auth/email/stepup/code", "https://evil.example", url.Values{"code": {code}}); status != http.StatusForbidden {
118 t.Fatalf("cross-site step-up code = %d, want 403", status)
119 }
120 // A signed-out browser is sent to the front page.
121 if status, loc, _ := postStepUpCode(t, fx, newBrowser(t), code); status != http.StatusFound || loc != "/" {
122 t.Fatalf("signed-out step-up code = %d -> %q, want 302 -> /", status, loc)
123 }
124 status, _, body := postStepUpCode(t, fx, c, wrongCode(code))
125 if status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_wrong"))) ||
126 !strings.Contains(body, `action="/auth/email/stepup/code"`) {
127 t.Fatalf("wrong step-up code = %d:\n%s", status, body)
128 }
129 if kimRole(t, fx) != RoleMember {
130 t.Fatal("a wrong code removed kim")
131 }
132
133 status, loc, body := postStepUpCode(t, fx, c, code)
134 if status != http.StatusSeeOther || loc != base+"/team" {
135 t.Fatalf("right step-up code = %d -> %q, want the resumed removal's 303 to the team page:\n%s", status, loc, body)
136 }
137 if kimRole(t, fx) != "" {
138 t.Fatal("kim is still a member after a confirmed removal")
139 }
140 if !fx.st.FreshStepUp(raw, "bob@example.com", time.Now(), StepUpWindow) {
141 t.Fatal("no presence recorded after the step-up code")
142 }
143 if challengeRows(t, fx) != 0 {
144 t.Fatal("the step-up challenge is still standing")
145 }
146 // Used once.
147 if status, _, body := postStepUpCode(t, fx, c, code); status != http.StatusGone || !strings.Contains(body, esc(copyText("stepup.expired"))) {
148 t.Fatalf("the step-up code a second time = %d:\n%s", status, body)
149 }
150 // A step-up never signs anyone in: the session is the one that asked.
151 if got := browserCookie(t, fx, c, sessionCookie); got != raw {
152 t.Fatal("the step-up replaced the session")
153 }
154}
155
156func TestEmailSessionStepUpByLink(t *testing.T) {
157 fx, m := newEmailFixture(t, true)
158 c := newBrowser(t)
159 raw, base := emailOwnerWorld(t, fx, m, c)
160 _, link := askStepUp(t, fx, m, c, raw, base)
161
162 // A GET (twice, as a scanner might from this same browser) spends nothing.
163 var nonce string
164 for i := 0; i < 2; i++ {
165 status, body, n := openLink(t, fx, c, link)
166 if status != http.StatusOK || n == "" || !strings.Contains(body, esc(copyText("stepup.link_title"))) ||
167 !strings.Contains(body, esc(copyText("stepup.link_button"))) {
168 t.Fatalf("step-up link GET %d = %d:\n%s", i+1, status, body)
169 }
170 nonce = n
171 }
172 if _, ok := fx.st.PeekEmailLink(link, time.Now()); !ok || kimRole(t, fx) != RoleMember {
173 t.Fatal("opening the step-up link spent it or acted")
174 }
175 // The confirm page's form still needs its nonce.
176 if status, _, _ := postLink(t, fx, c, link, "forged"); status != http.StatusForbidden {
177 t.Fatalf("step-up link POST without its nonce = %d, want 403", status)
178 }
179 _, _, nonce = openLink(t, fx, c, link)
180 status, loc, body := postLink(t, fx, c, link, nonce)
181 if status != http.StatusSeeOther || loc != base+"/team" {
182 t.Fatalf("step-up link POST = %d -> %q:\n%s", status, loc, body)
183 }
184 if kimRole(t, fx) != "" {
185 t.Fatal("kim is still a member after a confirmed removal")
186 }
187 if !fx.st.FreshStepUp(raw, "bob@example.com", time.Now(), StepUpWindow) {
188 t.Fatal("no presence recorded after the step-up link")
189 }
190 if got := browserCookie(t, fx, c, sessionCookie); got != raw {
191 t.Fatal("the step-up link replaced the session")
192 }
193 if status, body, _ := openLink(t, fx, c, link); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) {
194 t.Fatalf("a used step-up link = %d:\n%s", status, body)
195 }
196}
197
198func TestStepUpLinkOtherBrowser(t *testing.T) {
199 fx, m := newEmailFixture(t, true)
200 c := newBrowser(t)
201 raw, base := emailOwnerWorld(t, fx, m, c)
202 code, link := askStepUp(t, fx, m, c, raw, base)
203 u, _ := url.Parse(fx.ts.URL)
204
205 // Signed out, and signed in as bob in another session: both get the
206 // wrong-browser page, no confirm form, and nothing is spent.
207 signedOutB := newBrowser(t)
208 otherSession, err := fx.st.CreateSessionMethod("bob@example.com", "", MethodEmail, time.Now())
209 if err != nil {
210 t.Fatal(err)
211 }
212 otherB := newBrowser(t)
213 otherB.Jar.SetCookies(u, []*http.Cookie{{Name: sessionCookie, Value: otherSession, Path: "/"}})
214 for name, b := range map[string]*http.Client{"signed out": signedOutB, "another session": otherB} {
215 status, body, nonce := openLink(t, fx, b, link)
216 if status != http.StatusForbidden || nonce != "" || !strings.Contains(body, esc(copyText("stepup.wrong_browser"))) {
217 t.Fatalf("%s: step-up link GET = %d (nonce %q):\n%s", name, status, nonce, body)
218 }
219 // Even a POST carrying a matching nonce of its own is refused unspent.
220 b.Jar.SetCookies(u, []*http.Cookie{{Name: linkConfirmCookie, Value: "n", Path: "/"}})
221 status, _, body = postLink(t, fx, b, link, "n")
222 if status != http.StatusForbidden || !strings.Contains(body, esc(copyText("stepup.wrong_browser"))) {
223 t.Fatalf("%s: step-up link POST = %d:\n%s", name, status, body)
224 }
225 }
226 // The code, typed in the other session, finds no step-up of that session
227 // and is refused without being checked.
228 if status, _, body := postStepUpCode(t, fx, otherB, code); status != http.StatusGone || !strings.Contains(body, esc(copyText("stepup.expired"))) {
229 t.Fatalf("step-up code from another session = %d:\n%s", status, body)
230 }
231 if kimRole(t, fx) != RoleMember {
232 t.Fatal("another browser's step-up attempt removed kim")
233 }
234 if _, ok := fx.st.PeekEmailLink(link, time.Now()); !ok {
235 t.Fatal("another browser spent the step-up link")
236 }
237 if fx.st.FreshStepUp(otherSession, "bob@example.com", time.Now(), StepUpWindow) {
238 t.Fatal("another session gained presence")
239 }
240 // The right browser still can.
241 _, _, nonce := openLink(t, fx, c, link)
242 if status, loc, _ := postLink(t, fx, c, link, nonce); status != http.StatusSeeOther || loc != base+"/team" {
243 t.Fatalf("step-up link in the right browser = %d -> %q", status, loc)
244 }
245 if kimRole(t, fx) != "" {
246 t.Fatal("the right browser's confirmation did not remove kim")
247 }
248}
249
250// Review Focus 1: a Keymail session re-confirms with its passkey only, even
251// with a mailer ready, and even when Keymail cannot do it.
252func TestKeymailSessionNeverEmailStepUp(t *testing.T) {
253 fx, m := newEmailFixture(t, true)
254 c := newBrowser(t)
255 signUp(t, fx, c, "code-keymail-stepup")
256 if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
257 t.Fatal(err)
258 }
259 csrf := sessionToken(t, fx, c)
260 if _, method, _ := fx.st.SessionInfo(csrf, time.Now()); method != MethodKeymail {
261 t.Fatalf("session method = %q, want keymail", method)
262 }
263 status, loc, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
264 if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
265 t.Fatalf("keymail session's gated removal = %d -> %q, want a redirect into Keymail", status, loc)
266 }
267 fx.fk.mu.Lock()
268 fx.fk.reauth = false
269 fx.fk.mu.Unlock()
270 if status, _, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}); status != http.StatusServiceUnavailable {
271 t.Fatalf("keymail session with no Keymail step-up = %d, want 503", status)
272 }
273 if n := len(m.sentCopy()); n != 0 || challengeRows(t, fx) != 0 {
274 t.Fatalf("a keymail session's step-up sent %d emails and left %d challenges", n, challengeRows(t, fx))
275 }
276 if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember {
277 t.Fatal("kim was removed without a passkey")
278 }
279}
280
281func TestStepUpChallengeExpiresWithPendingAction(t *testing.T) {
282 fx, m := newEmailFixture(t, true)
283 c := newBrowser(t)
284 raw, base := emailOwnerWorld(t, fx, m, c)
285 code, link := askStepUp(t, fx, m, c, raw, base)
286
287 // The challenge lives exactly as long as the action it holds: 10 minutes.
288 var cCreated, cExp, pCreated, pExp int64
289 if err := fx.st.db.QueryRow(`SELECT created_at, expires_at FROM email_challenges`).Scan(&cCreated, &cExp); err != nil {
290 t.Fatal(err)
291 }
292 if err := fx.st.db.QueryRow(`SELECT created_at, expires_at FROM pending_actions`).Scan(&pCreated, &pExp); err != nil {
293 t.Fatal(err)
294 }
295 if cExp-cCreated != int64(stepUpFlowTTL.Seconds()) || cExp != pExp {
296 t.Fatalf("challenge lives %ds to %d, pending action to %d; want %ds, together",
297 cExp-cCreated, cExp, pExp, int64(stepUpFlowTTL.Seconds()))
298 }
299 // Eleven minutes on.
300 shift := int64((11 * time.Minute).Seconds())
301 for _, table := range []string{"email_challenges", "pending_actions"} {
302 if _, err := fx.st.db.Exec(`UPDATE `+table+` SET created_at = created_at - ?, expires_at = expires_at - ?`, shift, shift); err != nil {
303 t.Fatal(err)
304 }
305 }
306 if status, _, body := postStepUpCode(t, fx, c, code); status != http.StatusGone || !strings.Contains(body, esc(copyText("stepup.expired"))) {
307 t.Fatalf("step-up code after 11 minutes = %d:\n%s", status, body)
308 }
309 if status, body, _ := openLink(t, fx, c, link); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) {
310 t.Fatalf("step-up link after 11 minutes = %d:\n%s", status, body)
311 }
312 if kimRole(t, fx) != RoleMember || fx.st.FreshStepUp(raw, "bob@example.com", time.Now(), StepUpWindow) {
313 t.Fatal("an expired step-up acted or recorded presence")
314 }
315
316 // A live challenge whose held action has gone: confirmed, but nothing to
317 // resume, and the page says so.
318 advanceLimits(fx, 2*time.Minute)
319 code, _ = askStepUp(t, fx, m, c, raw, base)
320 if _, err := fx.st.db.Exec(`DELETE FROM pending_actions`); err != nil {
321 t.Fatal(err)
322 }
323 if status, _, body := postStepUpCode(t, fx, c, code); status != http.StatusGone || !strings.Contains(body, esc(copyText("stepup.expired"))) {
324 t.Fatalf("step-up code with its action gone = %d:\n%s", status, body)
325 }
326 if kimRole(t, fx) != RoleMember {
327 t.Fatal("kim was removed with no held action")
328 }
329}
330
331// The mailer down, and the impatient second click: neither stashes a second
332// action or sends a second email.
333func TestEmailSessionStepUpFailsClosed(t *testing.T) {
334 t.Run("no mailer", func(t *testing.T) {
335 fx, m := newEmailFixture(t, true)
336 c := newBrowser(t)
337 raw, base := emailOwnerWorld(t, fx, m, c)
338 m.mu.Lock()
339 m.ready = false
340 m.mu.Unlock()
341 status, _, body := postForm(t, c, fx.ts.URL+base+"/members/remove",
342 url.Values{"csrf": {raw}, "address": {"kim@keymail.dev"}})
343 if status != http.StatusServiceUnavailable || !strings.Contains(body, "Could not hold on to that action. Please try again.") {
344 t.Fatalf("step-up with the mailer down = %d:\n%s", status, body)
345 }
346 stashed := pendingRows(t, fx)
347 if stashed != 0 || challengeRows(t, fx) != 0 || len(m.sentCopy()) != 1 || kimRole(t, fx) != RoleMember {
348 t.Fatalf("mailer down: %d stashed, %d challenges, %d emails", stashed, challengeRows(t, fx), len(m.sentCopy()))
349 }
350 })
351 t.Run("asked twice", func(t *testing.T) {
352 fx, m := newEmailFixture(t, true)
353 c := newBrowser(t)
354 raw, base := emailOwnerWorld(t, fx, m, c)
355 code, _ := askStepUp(t, fx, m, c, raw, base)
356 status, _, body := postForm(t, c, fx.ts.URL+base+"/members/remove",
357 url.Values{"csrf": {raw}, "address": {"kim@keymail.dev"}})
358 if status != http.StatusOK || !strings.Contains(body, esc(copyText("stepup.sent", "address", "bob@example.com"))) {
359 t.Fatalf("second ask inside the minute = %d:\n%s", status, body)
360 }
361 stashed := pendingRows(t, fx)
362 if stashed != 1 || challengeRows(t, fx) != 1 || len(m.sentCopy()) != 2 {
363 t.Fatalf("second ask: %d stashed, %d challenges, %d emails", stashed, challengeRows(t, fx), len(m.sentCopy()))
364 }
365 // The first email's code still finishes the action.
366 if status, loc, _ := postStepUpCode(t, fx, c, code); status != http.StatusSeeOther || loc != base+"/team" || kimRole(t, fx) != "" {
367 t.Fatalf("first code after a second ask = %d -> %q", status, loc)
368 }
369 // Still inside the minute, with no live step-up challenge left in this
370 // browser, the throttle fails closed.
371 if err := fx.st.ClearStepUp(raw); err != nil {
372 t.Fatal(err)
373 }
374 if err := fx.st.AddMember(bobAccount(t, fx).ID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
375 t.Fatal(err)
376 }
377 status, _, body = postForm(t, c, fx.ts.URL+base+"/members/remove",
378 url.Values{"csrf": {raw}, "address": {"kim@keymail.dev"}})
379 if status != http.StatusServiceUnavailable || !strings.Contains(body, "Could not hold on to that action. Please try again.") {
380 t.Fatalf("throttled with no live challenge = %d:\n%s", status, body)
381 }
382 })
383}
384
385func bobAccount(t *testing.T, fx *consoleFixture) Account {
386 t.Helper()
387 accts, err := fx.st.AccountsFor("bob@example.com")
388 if err != nil || len(accts) == 0 {
389 t.Fatalf("bob's accounts = %v, %v", accts, err)
390 }
391 return accts[0]
392}
393
394// The headline: a signed-out new email user logs a terminal in with exactly
395// one email, because the sign-in itself is a fresh step-up.
396func TestCLILoginByEmailUserOneEmail(t *testing.T) {
397 fx, m := newEmailFixture(t, true)
398 fx.srv.cliNotified = make(chan struct{}, 1)
399 const addr = "newbie@example.com"
400 lookupAnswers(fx, addr, http.StatusNotFound)
401 c := newBrowser(t)
402
403 status, _, body := get(t, c, fx.ts.URL+"/auth/cli")
404 if status != http.StatusOK || !strings.Contains(body, esc(copyText("home.cli_prompt"))) ||
405 !strings.Contains(body, `name="next" value="/auth/cli"`) {
406 t.Fatalf("signed-out /auth/cli = %d:\n%s", status, body)
407 }
408 code, _ := askSignin(t, fx, m, c, addr, "/auth/cli")
409 if status, loc, _ := postCode(t, fx, c, code); status != http.StatusSeeOther || loc != "/auth/cli" {
410 t.Fatalf("sign-in code = %d -> %q, want 303 -> /auth/cli", status, loc)
411 }
412 csrf := emailSession(t, fx, c, addr)
413 if status, _, _ := get(t, c, fx.ts.URL+"/auth/cli"); status != http.StatusOK {
414 t.Fatalf("signed-in /auth/cli = %d", status)
415 }
416
417 status, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "newbie@laptop"}, "")
418 if status != http.StatusOK {
419 t.Fatalf("start = %d %v", status, start)
420 }
421 userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code")
422 status, _, body = postForm(t, c, fx.ts.URL+"/auth/cli/approve",
423 url.Values{"code": {userCode}, "action": {"continue"}, "csrf": {csrf}})
424 if status != http.StatusOK || !strings.Contains(body, "newbie@laptop") {
425 t.Fatalf("continue = %d:\n%s", status, body)
426 }
427 status, loc, body := postForm(t, c, fx.ts.URL+"/auth/cli/approve",
428 url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}})
429 if status != http.StatusOK || !strings.Contains(body, "Approved") {
430 t.Fatalf("approve = %d -> %q, want approved at once:\n%s", status, loc, body)
431 }
432 // Approved with the sign-in email alone: no step-up challenge, and the
433 // mailer has seen exactly one message (the issuance notice the poll sends
434 // next is not a confirmation).
435 if sent := m.sentCopy(); len(sent) != 1 || challengeRows(t, fx) != 0 {
436 t.Fatalf("at approval the console mailer had %d messages and %d challenges stood, want only the sign-in email: %q",
437 len(sent), challengeRows(t, fx), sent)
438 }
439 _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "")
440 if str(t, poll, "status") != CLIAuthApproved || str(t, poll, "address") != addr || str(t, poll, "token") == "" {
441 t.Fatalf("poll after approval = %v", poll)
442 }
443 select {
444 case <-fx.srv.cliNotified:
445 case <-time.After(5 * time.Second):
446 t.Fatal("no issuance notice attempt")
447 }
448 for _, msg := range m.sentCopy() {
449 if stepUpCodeRe.MatchString(msg) {
450 t.Fatalf("a step-up email went out:\n%s", msg)
451 }
452 }
453}
454