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