Go · 24117 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package auth_test
4
5 import (
6 "context"
7 "html"
8 "io"
9 "io/fs"
10 "log/slog"
11 "net/http"
12 "net/http/cookiejar"
13 "net/http/httptest"
14 "net/url"
15 "regexp"
16 "strings"
17 "sync"
18 "testing"
19 "testing/fstest"
20 "time"
21
22 "github.com/go-chi/chi/v5"
23 "github.com/jackc/pgx/v5/pgxpool"
24 "github.com/justinas/nosurf"
25
26 "github.com/tenseleyFlow/shithub/internal/auth/email"
27 "github.com/tenseleyFlow/shithub/internal/auth/password"
28 "github.com/tenseleyFlow/shithub/internal/auth/secretbox"
29 "github.com/tenseleyFlow/shithub/internal/auth/session"
30 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
31 "github.com/tenseleyFlow/shithub/internal/infra/storage"
32 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
33 authh "github.com/tenseleyFlow/shithub/internal/web/handlers/auth"
34 "github.com/tenseleyFlow/shithub/internal/web/middleware"
35 "github.com/tenseleyFlow/shithub/internal/web/render"
36 )
37
38 // captureSender records every Send call. Used by tests to assert what
39 // would have been emailed and to extract verification/reset tokens.
40 type captureSender struct {
41 mu sync.Mutex
42 out []email.Message
43 }
44
45 func (c *captureSender) Send(_ context.Context, m email.Message) error {
46 c.mu.Lock()
47 defer c.mu.Unlock()
48 c.out = append(c.out, m)
49 return nil
50 }
51
52 func (c *captureSender) all() []email.Message {
53 c.mu.Lock()
54 defer c.mu.Unlock()
55 out := make([]email.Message, len(c.out))
56 copy(out, c.out)
57 return out
58 }
59
60 func (c *captureSender) reset() {
61 c.mu.Lock()
62 defer c.mu.Unlock()
63 c.out = nil
64 }
65
66 // fastArgon keeps tests under a few seconds. The full-cost defaults are
67 // exercised by the unit test in internal/auth/password.
68 var fastArgon = password.Params{Memory: 16 * 1024, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
69
70 func newTestServer(t *testing.T, requireVerify bool) (*httptest.Server, *captureSender) {
71 srv, _, captor := newTestServerWithPool(t, requireVerify)
72 return srv, captor
73 }
74
75 // newTestServerWithPool is identical to newTestServer but also exposes
76 // the underlying pool so tests that need to manipulate DB state (e.g.
77 // backdating timestamps) can do so against the SAME database the server
78 // is reading from. Use the simpler newTestServer when no DB poking is
79 // needed.
80 func newTestServerWithPool(t *testing.T, requireVerify bool) (*httptest.Server, *pgxpool.Pool, *captureSender) {
81 t.Helper()
82 pool := dbtest.NewTestDB(t)
83
84 tmplFS := authTemplatesFS()
85 rr, err := render.New(tmplFS, render.Options{})
86 if err != nil {
87 t.Fatalf("render.New: %v", err)
88 }
89
90 storeKey, err := session.GenerateKey()
91 if err != nil {
92 t.Fatalf("GenerateKey: %v", err)
93 }
94 store, err := session.NewCookieStore(session.CookieStoreConfig{
95 Key: storeKey, MaxAge: 24 * time.Hour, Secure: false,
96 })
97 if err != nil {
98 t.Fatalf("NewCookieStore: %v", err)
99 }
100
101 cap := &captureSender{}
102 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
103
104 totpKey, err := secretbox.GenerateKey()
105 if err != nil {
106 t.Fatalf("secretbox key: %v", err)
107 }
108 box, err := secretbox.FromBytes(totpKey)
109 if err != nil {
110 t.Fatalf("secretbox: %v", err)
111 }
112
113 h, err := authh.New(authh.Deps{
114 Logger: logger,
115 Render: rr,
116 Pool: pool,
117 SessionStore: store,
118 Email: cap,
119 Branding: email.Branding{
120 SiteName: "shithub", BaseURL: "http://test.invalid",
121 From: "noreply@shithub.test",
122 },
123 Argon2: fastArgon,
124 Limiter: throttle.NewLimiter(),
125 RequireEmailVerification: requireVerify,
126 SecretBox: box,
127 ObjectStore: storage.NewMemoryStore(),
128 })
129 if err != nil {
130 t.Fatalf("authh.New: %v", err)
131 }
132
133 r := chi.NewRouter()
134 r.Use(middleware.RequestID)
135 r.Use(middleware.RealIP(middleware.RealIPConfig{}))
136 r.Use(middleware.SessionLoader(store, logger))
137 r.Use(middleware.OptionalUser(func(ctx context.Context, id int64) (string, int32, error) {
138 // Cheap lookup against the test pool — settings handlers use the
139 // username, and the epoch comparison enforces log-out-everywhere
140 // across the same suite.
141 u, err := pool.Acquire(ctx)
142 if err != nil {
143 return "", 0, err
144 }
145 defer u.Release()
146 var name string
147 var epoch int32
148 err = u.QueryRow(ctx,
149 "SELECT username, session_epoch FROM users WHERE id = $1", id,
150 ).Scan(&name, &epoch)
151 return name, epoch, err
152 }))
153 csrf := middleware.CSRF(middleware.CSRFConfig{
154 FailureHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155 http.Error(w, "csrf: "+nosurfReason(r), http.StatusForbidden)
156 }),
157 })
158 r.Group(func(r chi.Router) {
159 r.Use(csrf)
160 h.Mount(r)
161 })
162
163 srv := httptest.NewServer(r)
164 t.Cleanup(srv.Close)
165 return srv, pool, cap
166 }
167
168 // authTemplatesFS returns a minimal templates FS sufficient for the auth
169 // handlers to render successfully. Each form wraps the CSRF token in
170 // `<<<CSRF:...:CSRF>>>` markers so the test client can extract it
171 // unambiguously regardless of the token's base64 alphabet.
172 func authTemplatesFS() fs.FS {
173 layout := `{{ define "layout" }}<!DOCTYPE html><html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`
174 signup := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<input name=username value="{{.Form.Username}}"><input name=csrf_token value="{{.CSRFToken}}"></form>{{ end }}`
175 login := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Notice }}<p class=notice>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}"></form>{{ end }}`
176 resetReq := `{{ define "page" }}<form>{{ with .Notice }}<p class=notice>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}"></form>{{ end }}`
177 resetConf := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">{{.Token}}</form>{{ end }}`
178 verifyResend := `{{ define "page" }}<form>{{ with .Notice }}<p class=notice>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}"></form>{{ end }}`
179 tfaChallenge := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}"><input name=next value="{{.Next}}"></form>{{ end }}`
180 tfaEnable := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">SECRET={{.Secret}}</form>{{ end }}`
181 tfaDisable := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}"></form>{{ end }}`
182 tfaRecovery := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">{{ if .RecoveryCodes }}CODES={{ range .RecoveryCodes }}{{.}};{{ end }}{{ end }}</form>{{ end }}`
183 keysTpl := `{{ define "page" }}<form>{{ with .AddError }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">KEYS={{ range .Keys }}{{.ID}}:{{.FingerprintSha256}};{{ end }}</form>{{ end }}`
184 //nolint:gosec // G101 false positive: test fixture, not a hardcoded credential.
185 tokensTpl := `{{ define "page" }}<form>{{ with .CreateError }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">{{ if .JustCreatedRaw }}RAW={{.JustCreatedRaw}}{{ end }}TOKENS={{ range .Tokens }}{{.ID}}:{{.TokenPrefix}}{{ if .RevokedAt.Valid }}:revoked{{ end }};{{ end }}</form>{{ end }}`
186 profileTpl := `{{ define "page" }}<h1>Public profile</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form><input name=csrf_token value="{{.CSRFToken}}">DISPLAY={{.Form.DisplayName}};BIO={{.Form.Bio}};LOCATION={{.Form.Location}};WEBSITE={{.Form.Website}};COMPANY={{.Form.Company}};PRONOUNS={{.Form.Pronouns}};</form>{{ if .HasAvatar }}<form action="/settings/profile/avatar/remove" method=POST><input name=csrf_token value="{{.CSRFToken}}"><button>Remove</button></form>{{ end }}{{ end }}`
187 accountTpl := `{{ define "page" }}<h1>Account</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/account/username" method=POST><input name=csrf_token value="{{.CSRFToken}}">USERNAME={{.CurrentUsername}};USED={{.RecentRenames}}/{{.MaxRenames}};</form>{{ end }}`
188 //nolint:gosec // G101 false positive: HTML fixture, not a credential.
189 pwTpl := `{{ define "page" }}<h1>Password</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/password" method=POST><input name=csrf_token value="{{.CSRFToken}}">RECENT={{.RecentAuthOK}};</form>{{ end }}`
190 apprTpl := `{{ define "page" }}<h1>Appearance</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/appearance" method=POST><input name=csrf_token value="{{.CSRFToken}}">THEME={{.CurrentTheme}};</form>{{ end }}`
191 emailsTpl := `{{ define "page" }}<h1>Emails</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/emails" method=POST><input name=csrf_token value="{{.CSRFToken}}"></form>EMAILS={{ range .Emails }}{{.ID}}:{{.Email}}:p={{.IsPrimary}}:v={{.Verified}};{{ end }}{{ end }}`
192 notifTpl := `{{ define "page" }}<h1>Notifications</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/notifications" method=POST><input name=csrf_token value="{{.CSRFToken}}">CHANNELS={{ range .Channels }}{{.Key}}:e={{.Enabled}}:r={{.Required}};{{ end }}</form>{{ end }}`
193 sessTpl := `{{ define "page" }}<h1>Sessions</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/sessions/logout-everywhere" method=POST><input name=csrf_token value="{{.CSRFToken}}">UA={{.UserAgent}};</form>{{ end }}`
194 dangerTpl := `{{ define "page" }}<h1>Delete</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<form action="/settings/danger" method=POST><input name=csrf_token value="{{.CSRFToken}}">USER={{.Username}};GRACE={{.GraceWindowDays}};</form>{{ end }}`
195 errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
196 return fstest.MapFS{
197 "_layout.html": {Data: []byte(layout)},
198 "hello.html": {Data: []byte(`{{ define "page" }}home{{ end }}`)},
199 "auth/signup.html": {Data: []byte(signup)},
200 "auth/login.html": {Data: []byte(login)},
201 "auth/reset_request.html": {Data: []byte(resetReq)},
202 "auth/reset_confirm.html": {Data: []byte(resetConf)},
203 "auth/verify_resend.html": {Data: []byte(verifyResend)},
204 "auth/2fa_challenge.html": {Data: []byte(tfaChallenge)},
205 "settings/2fa_enable.html": {Data: []byte(tfaEnable)},
206 "settings/2fa_disable.html": {Data: []byte(tfaDisable)},
207 "settings/2fa_recovery.html": {Data: []byte(tfaRecovery)},
208 "settings/keys.html": {Data: []byte(keysTpl)},
209 "settings/tokens.html": {Data: []byte(tokensTpl)},
210 "settings/profile.html": {Data: []byte(profileTpl)},
211 "settings/account.html": {Data: []byte(accountTpl)},
212 "settings/password.html": {Data: []byte(pwTpl)},
213 "settings/appearance.html": {Data: []byte(apprTpl)},
214 "settings/emails.html": {Data: []byte(emailsTpl)},
215 "settings/notifications.html": {Data: []byte(notifTpl)},
216 "settings/sessions.html": {Data: []byte(sessTpl)},
217 "settings/danger.html": {Data: []byte(dangerTpl)},
218 "errors/404.html": {Data: []byte(errorPage)},
219 "errors/403.html": {Data: []byte(errorPage)},
220 "errors/429.html": {Data: []byte(errorPage)},
221 "errors/500.html": {Data: []byte(errorPage)},
222 }
223 }
224
225 // client wraps http.Client with a cookie jar so session/CSRF cookies persist.
226 type client struct {
227 c *http.Client
228 srv *httptest.Server
229 }
230
231 func newClient(t *testing.T, srv *httptest.Server) *client {
232 t.Helper()
233 jar, err := cookiejar.New(nil)
234 if err != nil {
235 t.Fatalf("cookiejar: %v", err)
236 }
237 return &client{
238 c: &http.Client{
239 Jar: jar,
240 CheckRedirect: func(req *http.Request, via []*http.Request) error {
241 return http.ErrUseLastResponse
242 },
243 },
244 srv: srv,
245 }
246 }
247
248 func (c *client) get(t *testing.T, path string) *http.Response {
249 t.Helper()
250 resp, err := c.c.Get(c.srv.URL + path)
251 if err != nil {
252 t.Fatalf("GET %s: %v", path, err)
253 }
254 return resp
255 }
256
257 func (c *client) post(t *testing.T, path string, form url.Values) *http.Response {
258 t.Helper()
259 // nosurf enforces same-origin on POST via Origin/Referer (browsers set
260 // these for form submissions; http.Client.PostForm does not).
261 req, err := http.NewRequest(http.MethodPost, c.srv.URL+path, strings.NewReader(form.Encode()))
262 if err != nil {
263 t.Fatalf("new request: %v", err)
264 }
265 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
266 req.Header.Set("Referer", c.srv.URL+path)
267 resp, err := c.c.Do(req)
268 if err != nil {
269 t.Fatalf("POST %s: %v", path, err)
270 }
271 return resp
272 }
273
274 // extractCSRF GETs path and returns the CSRF token the form would carry.
275 // The CSRF middleware sets the token cookie on first GET; the test
276 // templates wrap the printed token in `<<<CSRF:...:CSRF>>>` markers so
277 // extraction is unambiguous (nosurf uses base64 with `+/=` characters
278 // that a generic alphanumeric regex would mishandle).
279 func (c *client) extractCSRF(t *testing.T, path string) string {
280 t.Helper()
281 resp := c.get(t, path)
282 defer func() { _ = resp.Body.Close() }()
283 if resp.StatusCode != 200 {
284 t.Fatalf("GET %s: status %d", path, resp.StatusCode)
285 }
286 body, _ := io.ReadAll(resp.Body)
287 m := csrfMarkerRE.FindStringSubmatch(string(body))
288 if m == nil {
289 t.Fatalf("no CSRF marker in body of %s: %s", path, body)
290 }
291 // html/template HTML-escapes `+` to `&#43;` (and similar) in attribute
292 // values; browsers decode these transparently when reading form values,
293 // so the test client must mirror that decoding.
294 return html.UnescapeString(m[1])
295 }
296
297 var csrfMarkerRE = regexp.MustCompile(`name=csrf_token value="([^"]*)"`)
298
299 func nosurfReason(r *http.Request) string {
300 if err := nosurf.Reason(r); err != nil {
301 return err.Error()
302 }
303 return "no reason"
304 }
305
306 // extractTokenFromMessage pulls the URL-encoded token out of a verify or
307 // reset email body. The link shape is /<path>/<token>, where token is the
308 // b64url-encoded 32-byte payload from internal/auth/token.
309 func extractTokenFromMessage(t *testing.T, m email.Message, prefix string) string {
310 t.Helper()
311 re := regexp.MustCompile(prefix + `/([A-Za-z0-9_\-]{30,})`)
312 for _, body := range []string{m.Text, m.HTML} {
313 if mm := re.FindStringSubmatch(body); mm != nil {
314 return mm[1]
315 }
316 }
317 t.Fatalf("no token in message under prefix %s\nbodies:\n%s\n%s", prefix, m.Text, m.HTML)
318 return ""
319 }
320
321 // ============================== tests ==================================
322
323 func TestSignup_Verify_Login_Logout(t *testing.T) {
324 t.Parallel()
325 srv, sender := newTestServer(t, true)
326 cli := newClient(t, srv)
327
328 csrf := cli.extractCSRF(t, "/signup")
329 resp := cli.post(t, "/signup", url.Values{
330 "csrf_token": {csrf},
331 "username": {"alice"},
332 "email": {"alice@example.com"},
333 "password": {"correct horse battery staple"},
334 })
335 if resp.StatusCode != http.StatusSeeOther {
336 body, _ := io.ReadAll(resp.Body)
337 t.Fatalf("signup: status %d body=%s", resp.StatusCode, body)
338 }
339 _ = resp.Body.Close()
340
341 // login while unverified should be rejected.
342 csrf = cli.extractCSRF(t, "/login")
343 resp = cli.post(t, "/login", url.Values{
344 "csrf_token": {csrf},
345 "username": {"alice"},
346 "password": {"correct horse battery staple"},
347 })
348 if resp.StatusCode != http.StatusOK {
349 body, _ := io.ReadAll(resp.Body)
350 t.Fatalf("unverified login: status %d body=%s", resp.StatusCode, body)
351 }
352 body, _ := io.ReadAll(resp.Body)
353 if !strings.Contains(string(body), "verify your email") {
354 t.Fatalf("expected verify-required message, got: %s", body)
355 }
356 _ = resp.Body.Close()
357
358 // Use the captured email's token to verify.
359 msgs := sender.all()
360 if len(msgs) == 0 {
361 t.Fatal("expected verification email")
362 }
363 tok := extractTokenFromMessage(t, msgs[0], "/verify-email")
364
365 resp = cli.get(t, "/verify-email/"+tok)
366 if resp.StatusCode != http.StatusSeeOther {
367 body, _ := io.ReadAll(resp.Body)
368 t.Fatalf("verify: status %d body=%s", resp.StatusCode, body)
369 }
370 _ = resp.Body.Close()
371
372 // Now log in successfully.
373 csrf = cli.extractCSRF(t, "/login")
374 resp = cli.post(t, "/login", url.Values{
375 "csrf_token": {csrf},
376 "username": {"alice"},
377 "password": {"correct horse battery staple"},
378 })
379 if resp.StatusCode != http.StatusSeeOther {
380 body, _ := io.ReadAll(resp.Body)
381 t.Fatalf("verified login: status %d body=%s", resp.StatusCode, body)
382 }
383 if loc := resp.Header.Get("Location"); loc != "/" {
384 t.Fatalf("login redirect: %q, want /", loc)
385 }
386 _ = resp.Body.Close()
387
388 // Logout — POST /logout with CSRF.
389 csrf = cli.extractCSRF(t, "/login")
390 resp = cli.post(t, "/logout", url.Values{"csrf_token": {csrf}})
391 if resp.StatusCode != http.StatusSeeOther {
392 t.Fatalf("logout: status %d", resp.StatusCode)
393 }
394 _ = resp.Body.Close()
395 }
396
397 func TestPasswordReset_EndToEnd(t *testing.T) {
398 t.Parallel()
399 srv, sender := newTestServer(t, false)
400 cli := newClient(t, srv)
401
402 // Seed a verified user.
403 mustSignup(t, cli, "bob", "bob@example.com", "original-password-1")
404 tok := extractTokenFromMessage(t, sender.all()[0], "/verify-email")
405 resp := cli.get(t, "/verify-email/"+tok)
406 _ = resp.Body.Close()
407
408 // Request a reset.
409 csrf := cli.extractCSRF(t, "/password/reset")
410 resp = cli.post(t, "/password/reset", url.Values{
411 "csrf_token": {csrf},
412 "email": {"bob@example.com"},
413 })
414 if resp.StatusCode != http.StatusOK {
415 t.Fatalf("reset request: status %d", resp.StatusCode)
416 }
417 _ = resp.Body.Close()
418
419 all := sender.all()
420 resetTok := extractTokenFromMessage(t, all[len(all)-1], "/password/reset")
421
422 // Confirm.
423 csrf = cli.extractCSRF(t, "/password/reset/"+resetTok)
424 resp = cli.post(t, "/password/reset/"+resetTok, url.Values{
425 "csrf_token": {csrf},
426 "password": {"brand-new-password-2"},
427 })
428 if resp.StatusCode != http.StatusSeeOther {
429 body, _ := io.ReadAll(resp.Body)
430 t.Fatalf("reset confirm: status %d body=%s", resp.StatusCode, body)
431 }
432 _ = resp.Body.Close()
433
434 // Sign in with the new password.
435 csrf = cli.extractCSRF(t, "/login")
436 resp = cli.post(t, "/login", url.Values{
437 "csrf_token": {csrf},
438 "username": {"bob"},
439 "password": {"brand-new-password-2"},
440 })
441 if resp.StatusCode != http.StatusSeeOther {
442 body, _ := io.ReadAll(resp.Body)
443 t.Fatalf("post-reset login: status %d body=%s", resp.StatusCode, body)
444 }
445 _ = resp.Body.Close()
446 }
447
448 func TestPasswordReset_UnknownEmail_GenericResponse(t *testing.T) {
449 t.Parallel()
450 srv, sender := newTestServer(t, false)
451 cli := newClient(t, srv)
452
453 csrf := cli.extractCSRF(t, "/password/reset")
454 resp := cli.post(t, "/password/reset", url.Values{
455 "csrf_token": {csrf},
456 "email": {"nobody@nowhere.example"},
457 })
458 if resp.StatusCode != http.StatusOK {
459 t.Fatalf("reset for unknown: status %d", resp.StatusCode)
460 }
461 body, _ := io.ReadAll(resp.Body)
462 _ = resp.Body.Close()
463 if !strings.Contains(string(body), "If an account is registered") {
464 t.Fatalf("expected generic notice, got: %s", body)
465 }
466 if len(sender.all()) != 0 {
467 t.Fatalf("expected no email sent for unknown address, got %d", len(sender.all()))
468 }
469 }
470
471 func TestLogin_BruteForceThrottled(t *testing.T) {
472 t.Parallel()
473 srv, sender := newTestServer(t, false)
474 cli := newClient(t, srv)
475
476 mustSignup(t, cli, "carol", "carol@example.com", "original-password-1")
477 tok := extractTokenFromMessage(t, sender.all()[0], "/verify-email")
478 _ = cli.get(t, "/verify-email/"+tok).Body.Close()
479
480 for i := 0; i < 6; i++ {
481 csrf := cli.extractCSRF(t, "/login")
482 resp := cli.post(t, "/login", url.Values{
483 "csrf_token": {csrf},
484 "username": {"carol"},
485 "password": {"wrong-password"},
486 })
487 if resp.StatusCode != http.StatusOK {
488 body, _ := io.ReadAll(resp.Body)
489 t.Fatalf("attempt %d: status %d body=%s", i+1, resp.StatusCode, body)
490 }
491 _ = resp.Body.Close()
492 }
493
494 // 7th attempt should be throttled.
495 csrf := cli.extractCSRF(t, "/login")
496 resp := cli.post(t, "/login", url.Values{
497 "csrf_token": {csrf},
498 "username": {"carol"},
499 "password": {"wrong-password"},
500 })
501 if resp.StatusCode != http.StatusTooManyRequests {
502 body, _ := io.ReadAll(resp.Body)
503 t.Fatalf("7th attempt: status %d, want 429; body=%s", resp.StatusCode, body)
504 }
505 if ra := resp.Header.Get("Retry-After"); ra == "" {
506 t.Fatal("missing Retry-After on throttled response")
507 }
508 _ = resp.Body.Close()
509 }
510
511 func TestLogin_ConstantTime(t *testing.T) {
512 t.Parallel()
513 srv, sender := newTestServer(t, false)
514 cli := newClient(t, srv)
515
516 mustSignup(t, cli, "dave", "dave@example.com", "original-password-1")
517 tok := extractTokenFromMessage(t, sender.all()[0], "/verify-email")
518 _ = cli.get(t, "/verify-email/"+tok).Body.Close()
519
520 const trials = 10
521 measure := func(username string) time.Duration {
522 var total time.Duration
523 for i := 0; i < trials; i++ {
524 cli2 := newClient(t, srv)
525 csrf := cli2.extractCSRF(t, "/login")
526 start := time.Now()
527 resp := cli2.post(t, "/login", url.Values{
528 "csrf_token": {csrf},
529 "username": {username},
530 "password": {"any-wrong-password"},
531 })
532 total += time.Since(start)
533 _ = resp.Body.Close()
534 }
535 return total / trials
536 }
537 existing := measure("dave")
538 missing := measure("does-not-exist")
539 delta := existing - missing
540 if delta < 0 {
541 delta = -delta
542 }
543 // On the test argon params (~5–15ms) any user-existence shortcut would
544 // shave off most of the time; allow generous slack for CI noise but
545 // reject a 5x divergence.
546 if existing > missing*5 || missing > existing*5 {
547 t.Fatalf("login timing diverges too much: existing=%v missing=%v delta=%v", existing, missing, delta)
548 }
549 }
550
551 func TestSignup_ReservedNameRejected(t *testing.T) {
552 t.Parallel()
553 srv, _ := newTestServer(t, false)
554 cli := newClient(t, srv)
555
556 csrf := cli.extractCSRF(t, "/signup")
557 resp := cli.post(t, "/signup", url.Values{
558 "csrf_token": {csrf},
559 "username": {"login"},
560 "email": {"x@example.com"},
561 "password": {"correct horse battery staple"},
562 })
563 if resp.StatusCode != http.StatusOK {
564 t.Fatalf("status %d, want 200 with form re-render", resp.StatusCode)
565 }
566 body, _ := io.ReadAll(resp.Body)
567 _ = resp.Body.Close()
568 if !strings.Contains(string(body), "reserved") {
569 t.Fatalf("expected reserved-name error, got: %s", body)
570 }
571 }
572
573 func TestSignup_CommonPasswordRejected(t *testing.T) {
574 t.Parallel()
575 srv, _ := newTestServer(t, false)
576 cli := newClient(t, srv)
577
578 csrf := cli.extractCSRF(t, "/signup")
579 resp := cli.post(t, "/signup", url.Values{
580 "csrf_token": {csrf},
581 "username": {"erin"},
582 "email": {"erin@example.com"},
583 "password": {"qwertyuiop"},
584 })
585 if resp.StatusCode != http.StatusOK {
586 t.Fatalf("status %d", resp.StatusCode)
587 }
588 body, _ := io.ReadAll(resp.Body)
589 _ = resp.Body.Close()
590 if !strings.Contains(string(body), "common") {
591 t.Fatalf("expected common-password error, got: %s", body)
592 }
593 }
594
595 func TestSignup_HoneypotSilent(t *testing.T) {
596 t.Parallel()
597 srv, _ := newTestServer(t, false)
598 cli := newClient(t, srv)
599 csrf := cli.extractCSRF(t, "/signup")
600 resp := cli.post(t, "/signup", url.Values{
601 "csrf_token": {csrf},
602 "username": {"frank"},
603 "email": {"frank@example.com"},
604 "password": {"correct horse battery staple"},
605 "company": {"oops, a bot filled this"},
606 })
607 if resp.StatusCode != http.StatusSeeOther {
608 body, _ := io.ReadAll(resp.Body)
609 t.Fatalf("honeypot: expected redirect, got %d body=%s", resp.StatusCode, body)
610 }
611 _ = resp.Body.Close()
612 }
613
614 // mustSignup is a convenience for tests that need a seeded user.
615 func mustSignup(t *testing.T, cli *client, username, em, pw string) {
616 t.Helper()
617 csrf := cli.extractCSRF(t, "/signup")
618 resp := cli.post(t, "/signup", url.Values{
619 "csrf_token": {csrf},
620 "username": {username},
621 "email": {em},
622 "password": {pw},
623 })
624 defer func() { _ = resp.Body.Close() }()
625 if resp.StatusCode != http.StatusSeeOther {
626 body, _ := io.ReadAll(resp.Body)
627 t.Fatalf("seed signup %s: status %d body=%s", username, resp.StatusCode, body)
628 }
629 }
630