tenseleyflow/shithub / edf2455

Browse files

Wire auth handlers (signup/login/logout/reset/verify) with constant-time login + honeypot

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
edf2455b2728caff09341fd7f897170c8eadd82d
Parents
68e4d5e
Tree
5b63415

3 changed files

StatusFile+-
A internal/web/handlers/auth/auth.go 775 0
A internal/web/handlers/auth/auth_test.go 553 0
M internal/web/handlers/handlers.go 7 0
internal/web/handlers/auth/auth.goadded
@@ -0,0 +1,775 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package auth wires the email/password auth handlers (signup, login,
4
+// logout, password reset, email verification) onto the chi router.
5
+//
6
+// Design points worth knowing:
7
+//   - Login is constant-time: when the username doesn't exist we still
8
+//     run a Verify against a pre-computed dummy hash so response time
9
+//     doesn't leak existence.
10
+//   - Password-reset returns the same generic notice whether or not the
11
+//     email maps to a real account (no enumeration via the reset flow).
12
+//   - Tokens (verification, reset) are 32-byte random, b64url-encoded for
13
+//     the URL, sha256-stored in the DB.
14
+//   - Rate limits are enforced via internal/auth/throttle (login, signup,
15
+//     password-reset). Login on success resets the counter for that key.
16
+//   - Honeypot: signup form has a hidden `company` field; non-empty
17
+//     submissions are silently dropped (200 + same notice as success so
18
+//     bots can't tell they were rejected).
19
+//   - 4 KiB request-body cap on every POST here so weaponized inputs
20
+//     can't push the argon2 hasher into a DoS spiral.
21
+package auth
22
+
23
+import (
24
+	"context"
25
+	"errors"
26
+	"fmt"
27
+	"log/slog"
28
+	"net/http"
29
+	"regexp"
30
+	"strconv"
31
+	"strings"
32
+	"time"
33
+
34
+	"github.com/go-chi/chi/v5"
35
+	"github.com/jackc/pgx/v5"
36
+	"github.com/jackc/pgx/v5/pgtype"
37
+	"github.com/jackc/pgx/v5/pgxpool"
38
+
39
+	authpkg "github.com/tenseleyFlow/shithub/internal/auth"
40
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
41
+	"github.com/tenseleyFlow/shithub/internal/auth/password"
42
+	"github.com/tenseleyFlow/shithub/internal/auth/session"
43
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
44
+	"github.com/tenseleyFlow/shithub/internal/auth/token"
45
+	"github.com/tenseleyFlow/shithub/internal/passwords"
46
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
47
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
48
+	"github.com/tenseleyFlow/shithub/internal/web/render"
49
+)
50
+
51
+// usernameRE mirrors the path whitelist used by RepoFS.
52
+var usernameRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$`)
53
+
54
+// Deps is everything the auth handlers need. Constructed by the web
55
+// package and injected at registration time.
56
+type Deps struct {
57
+	Logger                   *slog.Logger
58
+	Render                   *render.Renderer
59
+	Pool                     *pgxpool.Pool
60
+	SessionStore             session.Store
61
+	Email                    email.Sender
62
+	Branding                 email.Branding
63
+	Argon2                   password.Params
64
+	Limiter                  *throttle.Limiter
65
+	RequireEmailVerification bool
66
+}
67
+
68
+// Handlers is the registered handler set. Construct with New.
69
+type Handlers struct {
70
+	d Deps
71
+	q *usersdb.Queries
72
+}
73
+
74
+// New constructs the handler set, validating Deps.
75
+func New(d Deps) (*Handlers, error) {
76
+	if d.Render == nil {
77
+		return nil, errors.New("auth: nil Render")
78
+	}
79
+	if d.SessionStore == nil {
80
+		return nil, errors.New("auth: nil SessionStore")
81
+	}
82
+	if d.Email == nil {
83
+		return nil, errors.New("auth: nil Email sender")
84
+	}
85
+	if d.Limiter == nil {
86
+		d.Limiter = throttle.NewLimiter()
87
+	}
88
+	password.MustGenerateDummy(d.Argon2)
89
+	return &Handlers{d: d, q: usersdb.New()}, nil
90
+}
91
+
92
+// Mount registers every auth route on r. The 4 KiB body cap is applied
93
+// to POST endpoints inside this method.
94
+func (h *Handlers) Mount(r chi.Router) {
95
+	r.Group(func(r chi.Router) {
96
+		r.Use(middleware.MaxBodySize(4 * 1024))
97
+		r.Get("/signup", h.signupForm)
98
+		r.Post("/signup", h.signupSubmit)
99
+		r.Get("/login", h.loginForm)
100
+		r.Post("/login", h.loginSubmit)
101
+		r.Post("/logout", h.logoutSubmit)
102
+		r.Get("/password/reset", h.resetRequestForm)
103
+		r.Post("/password/reset", h.resetRequestSubmit)
104
+		r.Get("/password/reset/{token}", h.resetConfirmForm)
105
+		r.Post("/password/reset/{token}", h.resetConfirmSubmit)
106
+		r.Get("/verify-email/{token}", h.verifyEmail)
107
+		r.Get("/verify-email/resend", h.verifyResendForm)
108
+		r.Post("/verify-email/resend", h.verifyResendSubmit)
109
+	})
110
+}
111
+
112
+// ---------------------------- signup -----------------------------------
113
+
114
+type signupForm struct {
115
+	Username string
116
+	Email    string
117
+}
118
+
119
+func (h *Handlers) signupForm(w http.ResponseWriter, r *http.Request) {
120
+	h.renderPage(w, r, "auth/signup", map[string]any{
121
+		"Title":     "Sign up",
122
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
123
+		"Form":      signupForm{},
124
+	})
125
+}
126
+
127
+func (h *Handlers) signupSubmit(w http.ResponseWriter, r *http.Request) {
128
+	if err := r.ParseForm(); err != nil {
129
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
130
+		return
131
+	}
132
+	form := signupForm{
133
+		Username: strings.ToLower(strings.TrimSpace(r.PostFormValue("username"))),
134
+		Email:    strings.ToLower(strings.TrimSpace(r.PostFormValue("email"))),
135
+	}
136
+	password := r.PostFormValue("password")
137
+	honeypot := r.PostFormValue("company")
138
+
139
+	render := func(msg string) {
140
+		h.renderPage(w, r, "auth/signup", map[string]any{
141
+			"Title":     "Sign up",
142
+			"CSRFToken": middleware.CSRFTokenForRequest(r),
143
+			"Form":      form,
144
+			"Error":     msg,
145
+		})
146
+	}
147
+
148
+	// Honeypot: silently treat as success so bots can't probe.
149
+	if honeypot != "" {
150
+		http.Redirect(w, r, "/login?notice=signup-pending", http.StatusSeeOther)
151
+		return
152
+	}
153
+
154
+	if err := h.throttleSignup(r); err != nil {
155
+		h.writeRetryAfter(w, err)
156
+		render("Too many signup attempts. Please try again later.")
157
+		return
158
+	}
159
+
160
+	if msg := validateUsername(form.Username); msg != "" {
161
+		render(msg)
162
+		return
163
+	}
164
+	if !looksLikeEmail(form.Email) {
165
+		render("Please enter a valid email address.")
166
+		return
167
+	}
168
+	if len(password) < 10 {
169
+		render("Password must be at least 10 characters.")
170
+		return
171
+	}
172
+	if passwords.IsCommon(password) {
173
+		render("That password is too common. Please choose another.")
174
+		return
175
+	}
176
+
177
+	hash, err := hashPassword(password, h.d.Argon2)
178
+	if err != nil {
179
+		h.d.Logger.ErrorContext(r.Context(), "signup: hash", "error", err)
180
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
181
+		return
182
+	}
183
+
184
+	ctx := r.Context()
185
+	tx, err := h.d.Pool.Begin(ctx)
186
+	if err != nil {
187
+		h.d.Logger.ErrorContext(ctx, "signup: begin tx", "error", err)
188
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
189
+		return
190
+	}
191
+	defer func() { _ = tx.Rollback(ctx) }()
192
+
193
+	user, err := h.q.CreateUser(ctx, tx, usersdb.CreateUserParams{
194
+		Username:     form.Username,
195
+		DisplayName:  form.Username,
196
+		PasswordHash: hash,
197
+	})
198
+	if err != nil {
199
+		if isUniqueViolation(err) {
200
+			render("That username is already taken.")
201
+			return
202
+		}
203
+		h.d.Logger.ErrorContext(ctx, "signup: create user", "error", err)
204
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
205
+		return
206
+	}
207
+
208
+	tokEnc, tokHash, err := token.New()
209
+	if err != nil {
210
+		h.d.Logger.ErrorContext(ctx, "signup: token", "error", err)
211
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
212
+		return
213
+	}
214
+
215
+	em, err := h.q.CreateUserEmail(ctx, tx, usersdb.CreateUserEmailParams{
216
+		UserID:                user.ID,
217
+		Email:                 form.Email,
218
+		IsPrimary:             true,
219
+		Verified:              false,
220
+		VerificationTokenHash: tokHash,
221
+	})
222
+	if err != nil {
223
+		if isUniqueViolation(err) {
224
+			render("That email is already registered. Try signing in?")
225
+			return
226
+		}
227
+		h.d.Logger.ErrorContext(ctx, "signup: create email", "error", err)
228
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
229
+		return
230
+	}
231
+
232
+	if err := h.q.LinkUserPrimaryEmail(ctx, tx, usersdb.LinkUserPrimaryEmailParams{
233
+		ID:             user.ID,
234
+		PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
235
+	}); err != nil {
236
+		h.d.Logger.ErrorContext(ctx, "signup: link primary", "error", err)
237
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
238
+		return
239
+	}
240
+
241
+	expires := pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true}
242
+	if _, err := h.q.CreateEmailVerification(ctx, tx, usersdb.CreateEmailVerificationParams{
243
+		UserEmailID: em.ID,
244
+		TokenHash:   tokHash,
245
+		ExpiresAt:   expires,
246
+	}); err != nil {
247
+		h.d.Logger.ErrorContext(ctx, "signup: create verification", "error", err)
248
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
249
+		return
250
+	}
251
+
252
+	if err := tx.Commit(ctx); err != nil {
253
+		h.d.Logger.ErrorContext(ctx, "signup: commit", "error", err)
254
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
255
+		return
256
+	}
257
+
258
+	// Best-effort send. SMTP transient failure must not break signup.
259
+	msg, err := email.VerifyMessage(h.d.Branding, form.Email, form.Username, tokEnc)
260
+	if err != nil {
261
+		h.d.Logger.ErrorContext(ctx, "signup: build verify msg", "error", err)
262
+	} else if err := h.d.Email.Send(ctx, msg); err != nil {
263
+		h.d.Logger.WarnContext(ctx, "signup: send verify email", "error", err)
264
+	}
265
+
266
+	http.Redirect(w, r, "/login?notice=signup-pending", http.StatusSeeOther)
267
+}
268
+
269
+// ----------------------------- login -----------------------------------
270
+
271
+type loginForm struct {
272
+	Username string
273
+}
274
+
275
+func (h *Handlers) loginForm(w http.ResponseWriter, r *http.Request) {
276
+	notice := ""
277
+	switch r.URL.Query().Get("notice") {
278
+	case "signup-pending":
279
+		notice = "Account created. Check your email for the verification link, then sign in."
280
+	case "verified":
281
+		notice = "Email verified. You can sign in now."
282
+	case "logged-out":
283
+		notice = "Signed out."
284
+	case "password-reset":
285
+		notice = "Password updated. Sign in with your new password."
286
+	}
287
+	h.renderPage(w, r, "auth/login", map[string]any{
288
+		"Title":     "Sign in",
289
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
290
+		"Form":      loginForm{},
291
+		"Notice":    notice,
292
+		"Next":      r.URL.Query().Get("next"),
293
+	})
294
+}
295
+
296
+func (h *Handlers) loginSubmit(w http.ResponseWriter, r *http.Request) {
297
+	if err := r.ParseForm(); err != nil {
298
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
299
+		return
300
+	}
301
+	username := strings.ToLower(strings.TrimSpace(r.PostFormValue("username")))
302
+	pw := r.PostFormValue("password")
303
+	next := r.PostFormValue("next")
304
+
305
+	render := func(msg string) {
306
+		h.renderPage(w, r, "auth/login", map[string]any{
307
+			"Title":     "Sign in",
308
+			"CSRFToken": middleware.CSRFTokenForRequest(r),
309
+			"Form":      loginForm{Username: username},
310
+			"Error":     msg,
311
+			"Next":      next,
312
+		})
313
+	}
314
+
315
+	throttleKey := fmt.Sprintf("ip:%s|%s", clientIP(r), username)
316
+	if err := h.d.Limiter.Hit(r.Context(), h.d.Pool, throttle.Limit{
317
+		Scope: "login", Identifier: throttleKey,
318
+		Max: 6, Window: 15 * time.Minute,
319
+	}); err != nil {
320
+		h.writeRetryAfter(w, err)
321
+		render("Too many sign-in attempts. Please try again later.")
322
+		return
323
+	}
324
+
325
+	user, err := h.q.GetUserByUsername(r.Context(), h.d.Pool, username)
326
+	if err != nil {
327
+		// User doesn't exist — still hash to keep timing constant.
328
+		password.VerifyAgainstDummy(pw)
329
+		render("Incorrect username or password.")
330
+		return
331
+	}
332
+
333
+	ok, err := password.Verify(pw, user.PasswordHash)
334
+	if err != nil {
335
+		h.d.Logger.ErrorContext(r.Context(), "login: verify", "error", err)
336
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
337
+		return
338
+	}
339
+	if !ok {
340
+		render("Incorrect username or password.")
341
+		return
342
+	}
343
+	if user.SuspendedAt.Valid {
344
+		render("This account has been suspended.")
345
+		return
346
+	}
347
+	if h.d.RequireEmailVerification && !user.EmailVerified {
348
+		render("Please verify your email before signing in. Check your inbox or request a new link.")
349
+		return
350
+	}
351
+
352
+	// Forgive prior failed-attempt counter on success.
353
+	_ = h.d.Limiter.Reset(r.Context(), h.d.Pool, "login", throttleKey)
354
+
355
+	if err := h.q.TouchUserLastLogin(r.Context(), h.d.Pool, user.ID); err != nil {
356
+		h.d.Logger.WarnContext(r.Context(), "login: touch last_login_at", "error", err)
357
+	}
358
+
359
+	// Session-fixation defense: bind user_id and re-issue cookie. The
360
+	// AEAD store re-encrypts on every Save, producing a fresh ciphertext.
361
+	s := middleware.SessionFromContext(r.Context())
362
+	s.UserID = user.ID
363
+	s.IssuedAt = time.Now().Unix()
364
+	if err := h.d.SessionStore.Save(w, r, s); err != nil {
365
+		h.d.Logger.ErrorContext(r.Context(), "login: save session", "error", err)
366
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
367
+		return
368
+	}
369
+
370
+	dest := "/"
371
+	if next != "" && strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
372
+		// dest is constrained to single-leading-slash relative paths above,
373
+		// which prevents the protocol-relative ("//evil.com") form gosec warns about.
374
+		dest = next
375
+	}
376
+	//nolint:gosec // G710: dest is whitelisted to single-leading-slash relative paths.
377
+	http.Redirect(w, r, dest, http.StatusSeeOther)
378
+}
379
+
380
+// ---------------------------- logout -----------------------------------
381
+
382
+func (h *Handlers) logoutSubmit(w http.ResponseWriter, r *http.Request) {
383
+	h.d.SessionStore.Clear(w)
384
+	http.Redirect(w, r, "/login?notice=logged-out", http.StatusSeeOther)
385
+}
386
+
387
+// ------------------------- password reset ------------------------------
388
+
389
+func (h *Handlers) resetRequestForm(w http.ResponseWriter, r *http.Request) {
390
+	h.renderPage(w, r, "auth/reset_request", map[string]any{
391
+		"Title":     "Reset password",
392
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
393
+		"Sent":      false,
394
+	})
395
+}
396
+
397
+func (h *Handlers) resetRequestSubmit(w http.ResponseWriter, r *http.Request) {
398
+	if err := r.ParseForm(); err != nil {
399
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
400
+		return
401
+	}
402
+	addr := strings.ToLower(strings.TrimSpace(r.PostFormValue("email")))
403
+
404
+	// Always show the same notice — no enumeration via this flow.
405
+	notice := "If an account is registered to that address, we've sent a password-reset link."
406
+	render := func() {
407
+		h.renderPage(w, r, "auth/reset_request", map[string]any{
408
+			"Title":     "Reset password",
409
+			"CSRFToken": middleware.CSRFTokenForRequest(r),
410
+			"Notice":    notice,
411
+			"Sent":      true,
412
+		})
413
+	}
414
+
415
+	if !looksLikeEmail(addr) {
416
+		render()
417
+		return
418
+	}
419
+
420
+	if err := h.d.Limiter.Hit(r.Context(), h.d.Pool, throttle.Limit{
421
+		Scope: "reset", Identifier: "email:" + addr,
422
+		Max: 3, Window: time.Hour,
423
+	}); err != nil {
424
+		h.writeRetryAfter(w, err)
425
+		render()
426
+		return
427
+	}
428
+
429
+	em, err := h.q.GetUserEmailByAddress(r.Context(), h.d.Pool, addr)
430
+	if err != nil {
431
+		// Don't even hint at existence.
432
+		render()
433
+		return
434
+	}
435
+
436
+	tokEnc, tokHash, err := token.New()
437
+	if err != nil {
438
+		h.d.Logger.ErrorContext(r.Context(), "reset: token", "error", err)
439
+		render()
440
+		return
441
+	}
442
+	expires := pgtype.Timestamptz{Time: time.Now().Add(time.Hour), Valid: true}
443
+	if _, err := h.q.CreatePasswordReset(r.Context(), h.d.Pool, usersdb.CreatePasswordResetParams{
444
+		UserID:    em.UserID,
445
+		TokenHash: tokHash,
446
+		ExpiresAt: expires,
447
+	}); err != nil {
448
+		h.d.Logger.ErrorContext(r.Context(), "reset: insert", "error", err)
449
+		render()
450
+		return
451
+	}
452
+
453
+	msg, err := email.ResetMessage(h.d.Branding, addr, tokEnc)
454
+	if err == nil {
455
+		if err := h.d.Email.Send(r.Context(), msg); err != nil {
456
+			h.d.Logger.WarnContext(r.Context(), "reset: send", "error", err)
457
+		}
458
+	}
459
+	render()
460
+}
461
+
462
+func (h *Handlers) resetConfirmForm(w http.ResponseWriter, r *http.Request) {
463
+	tokStr := chi.URLParam(r, "token")
464
+	if _, err := h.lookupValidReset(r.Context(), tokStr); err != nil {
465
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "reset link invalid or expired")
466
+		return
467
+	}
468
+	h.renderPage(w, r, "auth/reset_confirm", map[string]any{
469
+		"Title":     "Choose new password",
470
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
471
+		"Token":     tokStr,
472
+	})
473
+}
474
+
475
+func (h *Handlers) resetConfirmSubmit(w http.ResponseWriter, r *http.Request) {
476
+	if err := r.ParseForm(); err != nil {
477
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
478
+		return
479
+	}
480
+	tokStr := chi.URLParam(r, "token")
481
+	pw := r.PostFormValue("password")
482
+
483
+	render := func(msg string) {
484
+		h.renderPage(w, r, "auth/reset_confirm", map[string]any{
485
+			"Title":     "Choose new password",
486
+			"CSRFToken": middleware.CSRFTokenForRequest(r),
487
+			"Token":     tokStr,
488
+			"Error":     msg,
489
+		})
490
+	}
491
+
492
+	row, err := h.lookupValidReset(r.Context(), tokStr)
493
+	if err != nil {
494
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "reset link invalid or expired")
495
+		return
496
+	}
497
+	if len(pw) < 10 {
498
+		render("Password must be at least 10 characters.")
499
+		return
500
+	}
501
+	if passwords.IsCommon(pw) {
502
+		render("That password is too common. Please choose another.")
503
+		return
504
+	}
505
+
506
+	hash, err := hashPassword(pw, h.d.Argon2)
507
+	if err != nil {
508
+		h.d.Logger.ErrorContext(r.Context(), "reset: hash", "error", err)
509
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
510
+		return
511
+	}
512
+
513
+	tx, err := h.d.Pool.Begin(r.Context())
514
+	if err != nil {
515
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
516
+		return
517
+	}
518
+	defer func() { _ = tx.Rollback(r.Context()) }()
519
+
520
+	if err := h.q.UpdateUserPassword(r.Context(), tx, usersdb.UpdateUserPasswordParams{
521
+		ID:           row.UserID,
522
+		PasswordHash: hash,
523
+		PasswordAlgo: password.Algo,
524
+	}); err != nil {
525
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
526
+		return
527
+	}
528
+	if err := h.q.ConsumePasswordReset(r.Context(), tx, row.ID); err != nil {
529
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
530
+		return
531
+	}
532
+	if err := tx.Commit(r.Context()); err != nil {
533
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
534
+		return
535
+	}
536
+
537
+	http.Redirect(w, r, "/login?notice=password-reset", http.StatusSeeOther)
538
+}
539
+
540
+// ------------------------- email verification --------------------------
541
+
542
+func (h *Handlers) verifyEmail(w http.ResponseWriter, r *http.Request) {
543
+	tokStr := chi.URLParam(r, "token")
544
+	hash, err := token.HashOf(tokStr)
545
+	if err != nil {
546
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "verification link invalid")
547
+		return
548
+	}
549
+	row, err := h.q.GetEmailVerificationByTokenHash(r.Context(), h.d.Pool, hash)
550
+	if err != nil || row.UsedAt.Valid || time.Now().After(row.ExpiresAt.Time) {
551
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "verification link invalid or expired")
552
+		return
553
+	}
554
+	em, err := h.q.GetUserEmailByID(r.Context(), h.d.Pool, row.UserEmailID)
555
+	if err != nil {
556
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "verification link invalid")
557
+		return
558
+	}
559
+
560
+	tx, err := h.d.Pool.Begin(r.Context())
561
+	if err != nil {
562
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
563
+		return
564
+	}
565
+	defer func() { _ = tx.Rollback(r.Context()) }()
566
+
567
+	if err := h.q.MarkUserEmailVerified(r.Context(), tx, em.ID); err != nil {
568
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
569
+		return
570
+	}
571
+	if em.IsPrimary {
572
+		if err := h.q.MarkUserEmailPrimaryVerified(r.Context(), tx, em.UserID); err != nil {
573
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
574
+			return
575
+		}
576
+	}
577
+	if err := h.q.ConsumeEmailVerification(r.Context(), tx, row.ID); err != nil {
578
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
579
+		return
580
+	}
581
+	if err := tx.Commit(r.Context()); err != nil {
582
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
583
+		return
584
+	}
585
+
586
+	http.Redirect(w, r, "/login?notice=verified", http.StatusSeeOther)
587
+}
588
+
589
+func (h *Handlers) verifyResendForm(w http.ResponseWriter, r *http.Request) {
590
+	h.renderPage(w, r, "auth/verify_resend", map[string]any{
591
+		"Title":     "Resend verification",
592
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
593
+	})
594
+}
595
+
596
+func (h *Handlers) verifyResendSubmit(w http.ResponseWriter, r *http.Request) {
597
+	if err := r.ParseForm(); err != nil {
598
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
599
+		return
600
+	}
601
+	addr := strings.ToLower(strings.TrimSpace(r.PostFormValue("email")))
602
+	notice := "If a pending account is registered to that address, we've sent a fresh verification link."
603
+	render := func() {
604
+		h.renderPage(w, r, "auth/verify_resend", map[string]any{
605
+			"Title":     "Resend verification",
606
+			"CSRFToken": middleware.CSRFTokenForRequest(r),
607
+			"Notice":    notice,
608
+		})
609
+	}
610
+	if !looksLikeEmail(addr) {
611
+		render()
612
+		return
613
+	}
614
+	em, err := h.q.GetUserEmailByAddress(r.Context(), h.d.Pool, addr)
615
+	if err != nil || em.Verified {
616
+		render()
617
+		return
618
+	}
619
+	tokEnc, tokHash, err := token.New()
620
+	if err != nil {
621
+		render()
622
+		return
623
+	}
624
+	expires := pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true}
625
+	tx, err := h.d.Pool.Begin(r.Context())
626
+	if err != nil {
627
+		render()
628
+		return
629
+	}
630
+	defer func() { _ = tx.Rollback(r.Context()) }()
631
+	if err := h.q.SetVerificationToken(r.Context(), tx, usersdb.SetVerificationTokenParams{
632
+		ID:                    em.ID,
633
+		VerificationTokenHash: tokHash,
634
+	}); err != nil {
635
+		render()
636
+		return
637
+	}
638
+	if _, err := h.q.CreateEmailVerification(r.Context(), tx, usersdb.CreateEmailVerificationParams{
639
+		UserEmailID: em.ID, TokenHash: tokHash, ExpiresAt: expires,
640
+	}); err != nil {
641
+		render()
642
+		return
643
+	}
644
+	if err := tx.Commit(r.Context()); err != nil {
645
+		render()
646
+		return
647
+	}
648
+
649
+	user, err := h.q.GetUserByID(r.Context(), h.d.Pool, em.UserID)
650
+	if err == nil {
651
+		msg, err := email.VerifyMessage(h.d.Branding, addr, user.Username, tokEnc)
652
+		if err == nil {
653
+			_ = h.d.Email.Send(r.Context(), msg)
654
+		}
655
+	}
656
+	render()
657
+}
658
+
659
+// ----------------------------- helpers ---------------------------------
660
+
661
+func (h *Handlers) renderPage(w http.ResponseWriter, r *http.Request, page string, data any) {
662
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
663
+	if err := h.d.Render.Render(w, page, data); err != nil {
664
+		h.d.Logger.ErrorContext(r.Context(), "render", "page", page, "error", err)
665
+	}
666
+}
667
+
668
+func (h *Handlers) throttleSignup(r *http.Request) error {
669
+	if err := h.d.Limiter.Hit(r.Context(), h.d.Pool, throttle.Limit{
670
+		Scope: "signup", Identifier: "ip:" + clientIP(r),
671
+		Max: 5, Window: time.Hour,
672
+	}); err != nil {
673
+		return err
674
+	}
675
+	return nil
676
+}
677
+
678
+func (h *Handlers) writeRetryAfter(w http.ResponseWriter, err error) {
679
+	var t *throttle.ErrThrottled
680
+	if errors.As(err, &t) {
681
+		w.Header().Set("Retry-After", strconv.Itoa(int(t.RetryAfter.Seconds())))
682
+		w.WriteHeader(http.StatusTooManyRequests)
683
+	}
684
+}
685
+
686
+func (h *Handlers) lookupValidReset(ctx context.Context, encoded string) (usersdb.PasswordReset, error) {
687
+	hash, err := token.HashOf(encoded)
688
+	if err != nil {
689
+		return usersdb.PasswordReset{}, err
690
+	}
691
+	row, err := h.q.GetPasswordResetByTokenHash(ctx, h.d.Pool, hash)
692
+	if err != nil {
693
+		return usersdb.PasswordReset{}, err
694
+	}
695
+	if row.UsedAt.Valid || time.Now().After(row.ExpiresAt.Time) {
696
+		return usersdb.PasswordReset{}, errors.New("token expired or used")
697
+	}
698
+	return row, nil
699
+}
700
+
701
+// validateUsername returns a user-facing error string (suitable for
702
+// rendering in the form's flash slot) or "" when the name is acceptable.
703
+// Note: returns string, not error, because callers display these to the
704
+// end user — staticcheck's ST1005 rule for error capitalization doesn't
705
+// apply to UI copy.
706
+func validateUsername(name string) string {
707
+	if name == "" {
708
+		return "Username is required."
709
+	}
710
+	if len(name) > 39 {
711
+		return "Username may be at most 39 characters."
712
+	}
713
+	if !usernameRE.MatchString(name) {
714
+		return "Username may contain only lowercase letters, digits, and hyphens, and cannot start or end with a hyphen."
715
+	}
716
+	if authpkg.IsReserved(name) {
717
+		return "That username is reserved. Please choose another."
718
+	}
719
+	return ""
720
+}
721
+
722
+func looksLikeEmail(s string) bool {
723
+	if len(s) < 3 || len(s) > 254 {
724
+		return false
725
+	}
726
+	at := strings.IndexByte(s, '@')
727
+	if at <= 0 || at == len(s)-1 {
728
+		return false
729
+	}
730
+	if strings.IndexByte(s, ' ') >= 0 {
731
+		return false
732
+	}
733
+	return true
734
+}
735
+
736
+// hashPassword wraps password.Hash so callers don't need to import the
737
+// stdlib package alongside the local one.
738
+func hashPassword(pw string, p password.Params) (string, error) {
739
+	return password.Hash(pw, p)
740
+}
741
+
742
+func clientIP(r *http.Request) string {
743
+	if ip := middleware.RealIPFromContext(r.Context(), r); ip != "" {
744
+		return ip
745
+	}
746
+	if h, _, err := splitHostPort(r.RemoteAddr); err == nil {
747
+		return h
748
+	}
749
+	return r.RemoteAddr
750
+}
751
+
752
+func splitHostPort(addr string) (string, string, error) {
753
+	i := strings.LastIndexByte(addr, ':')
754
+	if i < 0 {
755
+		return addr, "", nil
756
+	}
757
+	return addr[:i], addr[i+1:], nil
758
+}
759
+
760
+// isUniqueViolation matches Postgres SQLSTATE 23505. We use an interface
761
+// shim so the auth package doesn't import pgconn directly.
762
+func isUniqueViolation(err error) bool {
763
+	type sqlStater interface {
764
+		SQLState() string
765
+	}
766
+	if errors.Is(err, pgx.ErrNoRows) {
767
+		return false
768
+	}
769
+	for cur := err; cur != nil; cur = errors.Unwrap(cur) {
770
+		if s, ok := cur.(sqlStater); ok && s.SQLState() == "23505" {
771
+			return true
772
+		}
773
+	}
774
+	return false
775
+}
internal/web/handlers/auth/auth_test.goadded
@@ -0,0 +1,553 @@
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/justinas/nosurf"
24
+
25
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
26
+	"github.com/tenseleyFlow/shithub/internal/auth/password"
27
+	"github.com/tenseleyFlow/shithub/internal/auth/session"
28
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
29
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
30
+	authh "github.com/tenseleyFlow/shithub/internal/web/handlers/auth"
31
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
32
+	"github.com/tenseleyFlow/shithub/internal/web/render"
33
+)
34
+
35
+// captureSender records every Send call. Used by tests to assert what
36
+// would have been emailed and to extract verification/reset tokens.
37
+type captureSender struct {
38
+	mu  sync.Mutex
39
+	out []email.Message
40
+}
41
+
42
+func (c *captureSender) Send(_ context.Context, m email.Message) error {
43
+	c.mu.Lock()
44
+	defer c.mu.Unlock()
45
+	c.out = append(c.out, m)
46
+	return nil
47
+}
48
+
49
+func (c *captureSender) all() []email.Message {
50
+	c.mu.Lock()
51
+	defer c.mu.Unlock()
52
+	out := make([]email.Message, len(c.out))
53
+	copy(out, c.out)
54
+	return out
55
+}
56
+
57
+// fastArgon keeps tests under a few seconds. The full-cost defaults are
58
+// exercised by the unit test in internal/auth/password.
59
+var fastArgon = password.Params{Memory: 16 * 1024, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
60
+
61
+func newTestServer(t *testing.T, requireVerify bool) (*httptest.Server, *captureSender) {
62
+	t.Helper()
63
+	pool := dbtest.NewTestDB(t)
64
+
65
+	tmplFS := authTemplatesFS()
66
+	rr, err := render.New(tmplFS, render.Options{})
67
+	if err != nil {
68
+		t.Fatalf("render.New: %v", err)
69
+	}
70
+
71
+	storeKey, err := session.GenerateKey()
72
+	if err != nil {
73
+		t.Fatalf("GenerateKey: %v", err)
74
+	}
75
+	store, err := session.NewCookieStore(session.CookieStoreConfig{
76
+		Key: storeKey, MaxAge: 24 * time.Hour, Secure: false,
77
+	})
78
+	if err != nil {
79
+		t.Fatalf("NewCookieStore: %v", err)
80
+	}
81
+
82
+	cap := &captureSender{}
83
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
84
+
85
+	h, err := authh.New(authh.Deps{
86
+		Logger:       logger,
87
+		Render:       rr,
88
+		Pool:         pool,
89
+		SessionStore: store,
90
+		Email:        cap,
91
+		Branding: email.Branding{
92
+			SiteName: "shithub", BaseURL: "http://test.invalid",
93
+			From: "noreply@shithub.test",
94
+		},
95
+		Argon2:                   fastArgon,
96
+		Limiter:                  throttle.NewLimiter(),
97
+		RequireEmailVerification: requireVerify,
98
+	})
99
+	if err != nil {
100
+		t.Fatalf("authh.New: %v", err)
101
+	}
102
+
103
+	r := chi.NewRouter()
104
+	r.Use(middleware.RequestID)
105
+	r.Use(middleware.RealIP(middleware.RealIPConfig{}))
106
+	r.Use(middleware.SessionLoader(store, logger))
107
+	csrf := middleware.CSRF(middleware.CSRFConfig{
108
+		FailureHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109
+			http.Error(w, "csrf: "+nosurfReason(r), http.StatusForbidden)
110
+		}),
111
+	})
112
+	r.Group(func(r chi.Router) {
113
+		r.Use(csrf)
114
+		h.Mount(r)
115
+	})
116
+
117
+	srv := httptest.NewServer(r)
118
+	t.Cleanup(srv.Close)
119
+	return srv, cap
120
+}
121
+
122
+// authTemplatesFS returns a minimal templates FS sufficient for the auth
123
+// handlers to render successfully. Each form wraps the CSRF token in
124
+// `<<<CSRF:...:CSRF>>>` markers so the test client can extract it
125
+// unambiguously regardless of the token's base64 alphabet.
126
+func authTemplatesFS() fs.FS {
127
+	layout := `{{ define "layout" }}<!DOCTYPE html><html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`
128
+	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 }}`
129
+	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 }}`
130
+	resetReq := `{{ define "page" }}<form>{{ with .Notice }}<p class=notice>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}"></form>{{ end }}`
131
+	resetConf := `{{ define "page" }}<form>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">{{.Token}}</form>{{ end }}`
132
+	verifyResend := `{{ define "page" }}<form>{{ with .Notice }}<p class=notice>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}"></form>{{ end }}`
133
+	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
134
+	return fstest.MapFS{
135
+		"_layout.html":            {Data: []byte(layout)},
136
+		"hello.html":              {Data: []byte(`{{ define "page" }}home{{ end }}`)},
137
+		"auth/signup.html":        {Data: []byte(signup)},
138
+		"auth/login.html":         {Data: []byte(login)},
139
+		"auth/reset_request.html": {Data: []byte(resetReq)},
140
+		"auth/reset_confirm.html": {Data: []byte(resetConf)},
141
+		"auth/verify_resend.html": {Data: []byte(verifyResend)},
142
+		"errors/404.html":         {Data: []byte(errorPage)},
143
+		"errors/403.html":         {Data: []byte(errorPage)},
144
+		"errors/429.html":         {Data: []byte(errorPage)},
145
+		"errors/500.html":         {Data: []byte(errorPage)},
146
+	}
147
+}
148
+
149
+// client wraps http.Client with a cookie jar so session/CSRF cookies persist.
150
+type client struct {
151
+	c   *http.Client
152
+	srv *httptest.Server
153
+}
154
+
155
+func newClient(t *testing.T, srv *httptest.Server) *client {
156
+	t.Helper()
157
+	jar, err := cookiejar.New(nil)
158
+	if err != nil {
159
+		t.Fatalf("cookiejar: %v", err)
160
+	}
161
+	return &client{
162
+		c: &http.Client{
163
+			Jar: jar,
164
+			CheckRedirect: func(req *http.Request, via []*http.Request) error {
165
+				return http.ErrUseLastResponse
166
+			},
167
+		},
168
+		srv: srv,
169
+	}
170
+}
171
+
172
+func (c *client) get(t *testing.T, path string) *http.Response {
173
+	t.Helper()
174
+	resp, err := c.c.Get(c.srv.URL + path)
175
+	if err != nil {
176
+		t.Fatalf("GET %s: %v", path, err)
177
+	}
178
+	return resp
179
+}
180
+
181
+func (c *client) post(t *testing.T, path string, form url.Values) *http.Response {
182
+	t.Helper()
183
+	// nosurf enforces same-origin on POST via Origin/Referer (browsers set
184
+	// these for form submissions; http.Client.PostForm does not).
185
+	req, err := http.NewRequest(http.MethodPost, c.srv.URL+path, strings.NewReader(form.Encode()))
186
+	if err != nil {
187
+		t.Fatalf("new request: %v", err)
188
+	}
189
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
190
+	req.Header.Set("Referer", c.srv.URL+path)
191
+	resp, err := c.c.Do(req)
192
+	if err != nil {
193
+		t.Fatalf("POST %s: %v", path, err)
194
+	}
195
+	return resp
196
+}
197
+
198
+// extractCSRF GETs path and returns the CSRF token the form would carry.
199
+// The CSRF middleware sets the token cookie on first GET; the test
200
+// templates wrap the printed token in `<<<CSRF:...:CSRF>>>` markers so
201
+// extraction is unambiguous (nosurf uses base64 with `+/=` characters
202
+// that a generic alphanumeric regex would mishandle).
203
+func (c *client) extractCSRF(t *testing.T, path string) string {
204
+	t.Helper()
205
+	resp := c.get(t, path)
206
+	defer func() { _ = resp.Body.Close() }()
207
+	if resp.StatusCode != 200 {
208
+		t.Fatalf("GET %s: status %d", path, resp.StatusCode)
209
+	}
210
+	body, _ := io.ReadAll(resp.Body)
211
+	m := csrfMarkerRE.FindStringSubmatch(string(body))
212
+	if m == nil {
213
+		t.Fatalf("no CSRF marker in body of %s: %s", path, body)
214
+	}
215
+	// html/template HTML-escapes `+` to `&#43;` (and similar) in attribute
216
+	// values; browsers decode these transparently when reading form values,
217
+	// so the test client must mirror that decoding.
218
+	return html.UnescapeString(m[1])
219
+}
220
+
221
+var csrfMarkerRE = regexp.MustCompile(`name=csrf_token value="([^"]*)"`)
222
+
223
+func nosurfReason(r *http.Request) string {
224
+	if err := nosurf.Reason(r); err != nil {
225
+		return err.Error()
226
+	}
227
+	return "no reason"
228
+}
229
+
230
+// extractTokenFromMessage pulls the URL-encoded token out of a verify or
231
+// reset email body. The link shape is /<path>/<token>, where token is the
232
+// b64url-encoded 32-byte payload from internal/auth/token.
233
+func extractTokenFromMessage(t *testing.T, m email.Message, prefix string) string {
234
+	t.Helper()
235
+	re := regexp.MustCompile(prefix + `/([A-Za-z0-9_\-]{30,})`)
236
+	for _, body := range []string{m.Text, m.HTML} {
237
+		if mm := re.FindStringSubmatch(body); mm != nil {
238
+			return mm[1]
239
+		}
240
+	}
241
+	t.Fatalf("no token in message under prefix %s\nbodies:\n%s\n%s", prefix, m.Text, m.HTML)
242
+	return ""
243
+}
244
+
245
+// ============================== tests ==================================
246
+
247
+func TestSignup_Verify_Login_Logout(t *testing.T) {
248
+	t.Parallel()
249
+	srv, sender := newTestServer(t, true)
250
+	cli := newClient(t, srv)
251
+
252
+	csrf := cli.extractCSRF(t, "/signup")
253
+	resp := cli.post(t, "/signup", url.Values{
254
+		"csrf_token": {csrf},
255
+		"username":   {"alice"},
256
+		"email":      {"alice@example.com"},
257
+		"password":   {"correct horse battery staple"},
258
+	})
259
+	if resp.StatusCode != http.StatusSeeOther {
260
+		body, _ := io.ReadAll(resp.Body)
261
+		t.Fatalf("signup: status %d body=%s", resp.StatusCode, body)
262
+	}
263
+	_ = resp.Body.Close()
264
+
265
+	// login while unverified should be rejected.
266
+	csrf = cli.extractCSRF(t, "/login")
267
+	resp = cli.post(t, "/login", url.Values{
268
+		"csrf_token": {csrf},
269
+		"username":   {"alice"},
270
+		"password":   {"correct horse battery staple"},
271
+	})
272
+	if resp.StatusCode != http.StatusOK {
273
+		body, _ := io.ReadAll(resp.Body)
274
+		t.Fatalf("unverified login: status %d body=%s", resp.StatusCode, body)
275
+	}
276
+	body, _ := io.ReadAll(resp.Body)
277
+	if !strings.Contains(string(body), "verify your email") {
278
+		t.Fatalf("expected verify-required message, got: %s", body)
279
+	}
280
+	_ = resp.Body.Close()
281
+
282
+	// Use the captured email's token to verify.
283
+	msgs := sender.all()
284
+	if len(msgs) == 0 {
285
+		t.Fatal("expected verification email")
286
+	}
287
+	tok := extractTokenFromMessage(t, msgs[0], "/verify-email")
288
+
289
+	resp = cli.get(t, "/verify-email/"+tok)
290
+	if resp.StatusCode != http.StatusSeeOther {
291
+		body, _ := io.ReadAll(resp.Body)
292
+		t.Fatalf("verify: status %d body=%s", resp.StatusCode, body)
293
+	}
294
+	_ = resp.Body.Close()
295
+
296
+	// Now log in successfully.
297
+	csrf = cli.extractCSRF(t, "/login")
298
+	resp = cli.post(t, "/login", url.Values{
299
+		"csrf_token": {csrf},
300
+		"username":   {"alice"},
301
+		"password":   {"correct horse battery staple"},
302
+	})
303
+	if resp.StatusCode != http.StatusSeeOther {
304
+		body, _ := io.ReadAll(resp.Body)
305
+		t.Fatalf("verified login: status %d body=%s", resp.StatusCode, body)
306
+	}
307
+	if loc := resp.Header.Get("Location"); loc != "/" {
308
+		t.Fatalf("login redirect: %q, want /", loc)
309
+	}
310
+	_ = resp.Body.Close()
311
+
312
+	// Logout — POST /logout with CSRF.
313
+	csrf = cli.extractCSRF(t, "/login")
314
+	resp = cli.post(t, "/logout", url.Values{"csrf_token": {csrf}})
315
+	if resp.StatusCode != http.StatusSeeOther {
316
+		t.Fatalf("logout: status %d", resp.StatusCode)
317
+	}
318
+	_ = resp.Body.Close()
319
+}
320
+
321
+func TestPasswordReset_EndToEnd(t *testing.T) {
322
+	t.Parallel()
323
+	srv, sender := newTestServer(t, false)
324
+	cli := newClient(t, srv)
325
+
326
+	// Seed a verified user.
327
+	mustSignup(t, cli, "bob", "bob@example.com", "original-password-1")
328
+	tok := extractTokenFromMessage(t, sender.all()[0], "/verify-email")
329
+	resp := cli.get(t, "/verify-email/"+tok)
330
+	_ = resp.Body.Close()
331
+
332
+	// Request a reset.
333
+	csrf := cli.extractCSRF(t, "/password/reset")
334
+	resp = cli.post(t, "/password/reset", url.Values{
335
+		"csrf_token": {csrf},
336
+		"email":      {"bob@example.com"},
337
+	})
338
+	if resp.StatusCode != http.StatusOK {
339
+		t.Fatalf("reset request: status %d", resp.StatusCode)
340
+	}
341
+	_ = resp.Body.Close()
342
+
343
+	all := sender.all()
344
+	resetTok := extractTokenFromMessage(t, all[len(all)-1], "/password/reset")
345
+
346
+	// Confirm.
347
+	csrf = cli.extractCSRF(t, "/password/reset/"+resetTok)
348
+	resp = cli.post(t, "/password/reset/"+resetTok, url.Values{
349
+		"csrf_token": {csrf},
350
+		"password":   {"brand-new-password-2"},
351
+	})
352
+	if resp.StatusCode != http.StatusSeeOther {
353
+		body, _ := io.ReadAll(resp.Body)
354
+		t.Fatalf("reset confirm: status %d body=%s", resp.StatusCode, body)
355
+	}
356
+	_ = resp.Body.Close()
357
+
358
+	// Sign in with the new password.
359
+	csrf = cli.extractCSRF(t, "/login")
360
+	resp = cli.post(t, "/login", url.Values{
361
+		"csrf_token": {csrf},
362
+		"username":   {"bob"},
363
+		"password":   {"brand-new-password-2"},
364
+	})
365
+	if resp.StatusCode != http.StatusSeeOther {
366
+		body, _ := io.ReadAll(resp.Body)
367
+		t.Fatalf("post-reset login: status %d body=%s", resp.StatusCode, body)
368
+	}
369
+	_ = resp.Body.Close()
370
+}
371
+
372
+func TestPasswordReset_UnknownEmail_GenericResponse(t *testing.T) {
373
+	t.Parallel()
374
+	srv, sender := newTestServer(t, false)
375
+	cli := newClient(t, srv)
376
+
377
+	csrf := cli.extractCSRF(t, "/password/reset")
378
+	resp := cli.post(t, "/password/reset", url.Values{
379
+		"csrf_token": {csrf},
380
+		"email":      {"nobody@nowhere.example"},
381
+	})
382
+	if resp.StatusCode != http.StatusOK {
383
+		t.Fatalf("reset for unknown: status %d", resp.StatusCode)
384
+	}
385
+	body, _ := io.ReadAll(resp.Body)
386
+	_ = resp.Body.Close()
387
+	if !strings.Contains(string(body), "If an account is registered") {
388
+		t.Fatalf("expected generic notice, got: %s", body)
389
+	}
390
+	if len(sender.all()) != 0 {
391
+		t.Fatalf("expected no email sent for unknown address, got %d", len(sender.all()))
392
+	}
393
+}
394
+
395
+func TestLogin_BruteForceThrottled(t *testing.T) {
396
+	t.Parallel()
397
+	srv, sender := newTestServer(t, false)
398
+	cli := newClient(t, srv)
399
+
400
+	mustSignup(t, cli, "carol", "carol@example.com", "original-password-1")
401
+	tok := extractTokenFromMessage(t, sender.all()[0], "/verify-email")
402
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
403
+
404
+	for i := 0; i < 6; i++ {
405
+		csrf := cli.extractCSRF(t, "/login")
406
+		resp := cli.post(t, "/login", url.Values{
407
+			"csrf_token": {csrf},
408
+			"username":   {"carol"},
409
+			"password":   {"wrong-password"},
410
+		})
411
+		if resp.StatusCode != http.StatusOK {
412
+			body, _ := io.ReadAll(resp.Body)
413
+			t.Fatalf("attempt %d: status %d body=%s", i+1, resp.StatusCode, body)
414
+		}
415
+		_ = resp.Body.Close()
416
+	}
417
+
418
+	// 7th attempt should be throttled.
419
+	csrf := cli.extractCSRF(t, "/login")
420
+	resp := cli.post(t, "/login", url.Values{
421
+		"csrf_token": {csrf},
422
+		"username":   {"carol"},
423
+		"password":   {"wrong-password"},
424
+	})
425
+	if resp.StatusCode != http.StatusTooManyRequests {
426
+		body, _ := io.ReadAll(resp.Body)
427
+		t.Fatalf("7th attempt: status %d, want 429; body=%s", resp.StatusCode, body)
428
+	}
429
+	if ra := resp.Header.Get("Retry-After"); ra == "" {
430
+		t.Fatal("missing Retry-After on throttled response")
431
+	}
432
+	_ = resp.Body.Close()
433
+}
434
+
435
+func TestLogin_ConstantTime(t *testing.T) {
436
+	t.Parallel()
437
+	srv, sender := newTestServer(t, false)
438
+	cli := newClient(t, srv)
439
+
440
+	mustSignup(t, cli, "dave", "dave@example.com", "original-password-1")
441
+	tok := extractTokenFromMessage(t, sender.all()[0], "/verify-email")
442
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
443
+
444
+	const trials = 10
445
+	measure := func(username string) time.Duration {
446
+		var total time.Duration
447
+		for i := 0; i < trials; i++ {
448
+			cli2 := newClient(t, srv)
449
+			csrf := cli2.extractCSRF(t, "/login")
450
+			start := time.Now()
451
+			resp := cli2.post(t, "/login", url.Values{
452
+				"csrf_token": {csrf},
453
+				"username":   {username},
454
+				"password":   {"any-wrong-password"},
455
+			})
456
+			total += time.Since(start)
457
+			_ = resp.Body.Close()
458
+		}
459
+		return total / trials
460
+	}
461
+	existing := measure("dave")
462
+	missing := measure("does-not-exist")
463
+	delta := existing - missing
464
+	if delta < 0 {
465
+		delta = -delta
466
+	}
467
+	// On the test argon params (~5–15ms) any user-existence shortcut would
468
+	// shave off most of the time; allow generous slack for CI noise but
469
+	// reject a 5x divergence.
470
+	if existing > missing*5 || missing > existing*5 {
471
+		t.Fatalf("login timing diverges too much: existing=%v missing=%v delta=%v", existing, missing, delta)
472
+	}
473
+}
474
+
475
+func TestSignup_ReservedNameRejected(t *testing.T) {
476
+	t.Parallel()
477
+	srv, _ := newTestServer(t, false)
478
+	cli := newClient(t, srv)
479
+
480
+	csrf := cli.extractCSRF(t, "/signup")
481
+	resp := cli.post(t, "/signup", url.Values{
482
+		"csrf_token": {csrf},
483
+		"username":   {"login"},
484
+		"email":      {"x@example.com"},
485
+		"password":   {"correct horse battery staple"},
486
+	})
487
+	if resp.StatusCode != http.StatusOK {
488
+		t.Fatalf("status %d, want 200 with form re-render", resp.StatusCode)
489
+	}
490
+	body, _ := io.ReadAll(resp.Body)
491
+	_ = resp.Body.Close()
492
+	if !strings.Contains(string(body), "reserved") {
493
+		t.Fatalf("expected reserved-name error, got: %s", body)
494
+	}
495
+}
496
+
497
+func TestSignup_CommonPasswordRejected(t *testing.T) {
498
+	t.Parallel()
499
+	srv, _ := newTestServer(t, false)
500
+	cli := newClient(t, srv)
501
+
502
+	csrf := cli.extractCSRF(t, "/signup")
503
+	resp := cli.post(t, "/signup", url.Values{
504
+		"csrf_token": {csrf},
505
+		"username":   {"erin"},
506
+		"email":      {"erin@example.com"},
507
+		"password":   {"qwertyuiop"},
508
+	})
509
+	if resp.StatusCode != http.StatusOK {
510
+		t.Fatalf("status %d", resp.StatusCode)
511
+	}
512
+	body, _ := io.ReadAll(resp.Body)
513
+	_ = resp.Body.Close()
514
+	if !strings.Contains(string(body), "common") {
515
+		t.Fatalf("expected common-password error, got: %s", body)
516
+	}
517
+}
518
+
519
+func TestSignup_HoneypotSilent(t *testing.T) {
520
+	t.Parallel()
521
+	srv, _ := newTestServer(t, false)
522
+	cli := newClient(t, srv)
523
+	csrf := cli.extractCSRF(t, "/signup")
524
+	resp := cli.post(t, "/signup", url.Values{
525
+		"csrf_token": {csrf},
526
+		"username":   {"frank"},
527
+		"email":      {"frank@example.com"},
528
+		"password":   {"correct horse battery staple"},
529
+		"company":    {"oops, a bot filled this"},
530
+	})
531
+	if resp.StatusCode != http.StatusSeeOther {
532
+		body, _ := io.ReadAll(resp.Body)
533
+		t.Fatalf("honeypot: expected redirect, got %d body=%s", resp.StatusCode, body)
534
+	}
535
+	_ = resp.Body.Close()
536
+}
537
+
538
+// mustSignup is a convenience for tests that need a seeded user.
539
+func mustSignup(t *testing.T, cli *client, username, em, pw string) {
540
+	t.Helper()
541
+	csrf := cli.extractCSRF(t, "/signup")
542
+	resp := cli.post(t, "/signup", url.Values{
543
+		"csrf_token": {csrf},
544
+		"username":   {username},
545
+		"email":      {em},
546
+		"password":   {pw},
547
+	})
548
+	defer func() { _ = resp.Body.Close() }()
549
+	if resp.StatusCode != http.StatusSeeOther {
550
+		body, _ := io.ReadAll(resp.Body)
551
+		t.Fatalf("seed signup %s: status %d body=%s", username, resp.StatusCode, body)
552
+	}
553
+}
internal/web/handlers/handlers.gomodified
@@ -35,6 +35,10 @@ type Deps struct {
3535
 	// MetricsHandler, when non-nil, is mounted at /metrics. Caller is
3636
 	// responsible for any access control (e.g. HTTP Basic auth wrapping).
3737
 	MetricsHandler http.Handler
38
+	// AuthMounter, when non-nil, is invoked inside the CSRF-protected
39
+	// route group with the chi.Router so the auth handlers can register
40
+	// signup/login/logout/reset/verify routes.
41
+	AuthMounter func(chi.Router)
3842
 }
3943
 
4044
 // panicHandler implements middleware.PanicHandler. The recover middleware
@@ -95,6 +99,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
9599
 		// panic-recovery path so an operator can confirm the styled 500
96100
 		// page renders. S35 will gate this behind a dev flag.
97101
 		r.Get("/internal/panic", panicTrigger)
102
+		if deps.AuthMounter != nil {
103
+			deps.AuthMounter(r)
104
+		}
98105
 	})
99106
 
100107
 	notFound := func(w http.ResponseWriter, r *http.Request) {