tenseleyflow/shithub / 9bc9a6c

Browse files

Wire 2FA handlers: login challenge, enroll, disable, regenerate; route password-login through /login/2fa when enrolled

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9bc9a6c23dfbac74b5c7da0bc5697ed63349193a
Parents
c21cd5a
Tree
6172907

2 changed files

StatusFile+-
M internal/web/handlers/auth/auth.go 47 0
A internal/web/handlers/auth/twofactor.go 517 0
internal/web/handlers/auth/auth.gomodified
@@ -26,6 +26,7 @@ import (
2626
 	"fmt"
2727
 	"log/slog"
2828
 	"net/http"
29
+	"net/url"
2930
 	"regexp"
3031
 	"strconv"
3132
 	"strings"
@@ -37,8 +38,10 @@ import (
3738
 	"github.com/jackc/pgx/v5/pgxpool"
3839
 
3940
 	authpkg "github.com/tenseleyFlow/shithub/internal/auth"
41
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
4042
 	"github.com/tenseleyFlow/shithub/internal/auth/email"
4143
 	"github.com/tenseleyFlow/shithub/internal/auth/password"
44
+	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
4245
 	"github.com/tenseleyFlow/shithub/internal/auth/session"
4346
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
4447
 	"github.com/tenseleyFlow/shithub/internal/auth/token"
@@ -63,6 +66,11 @@ type Deps struct {
6366
 	Argon2                   password.Params
6467
 	Limiter                  *throttle.Limiter
6568
 	RequireEmailVerification bool
69
+	// SecretBox encrypts at-rest TOTP secrets. May be nil; when nil, the
70
+	// 2FA enrollment endpoints are not registered.
71
+	SecretBox *secretbox.Box
72
+	// Audit records security-relevant events (2fa state changes, etc.).
73
+	Audit *audit.Recorder
6674
 }
6775
 
6876
 // Handlers is the registered handler set. Construct with New.
@@ -85,6 +93,9 @@ func New(d Deps) (*Handlers, error) {
8593
 	if d.Limiter == nil {
8694
 		d.Limiter = throttle.NewLimiter()
8795
 	}
96
+	if d.Audit == nil {
97
+		d.Audit = audit.NewRecorder()
98
+	}
8899
 	password.MustGenerateDummy(d.Argon2)
89100
 	return &Handlers{d: d, q: usersdb.New()}, nil
90101
 }
@@ -98,6 +109,8 @@ func (h *Handlers) Mount(r chi.Router) {
98109
 		r.Post("/signup", h.signupSubmit)
99110
 		r.Get("/login", h.loginForm)
100111
 		r.Post("/login", h.loginSubmit)
112
+		r.Get("/login/2fa", h.twoFactorChallengeForm)
113
+		r.Post("/login/2fa", h.twoFactorChallengeSubmit)
101114
 		r.Post("/logout", h.logoutSubmit)
102115
 		r.Get("/password/reset", h.resetRequestForm)
103116
 		r.Post("/password/reset", h.resetRequestSubmit)
@@ -106,6 +119,18 @@ func (h *Handlers) Mount(r chi.Router) {
106119
 		r.Get("/verify-email/{token}", h.verifyEmail)
107120
 		r.Get("/verify-email/resend", h.verifyResendForm)
108121
 		r.Post("/verify-email/resend", h.verifyResendSubmit)
122
+
123
+		// Settings — require an authenticated user.
124
+		if h.d.SecretBox != nil {
125
+			r.Group(func(r chi.Router) {
126
+				r.Use(middleware.RequireUser)
127
+				r.Get("/settings/security/2fa/enable", h.twoFactorEnableForm)
128
+				r.Post("/settings/security/2fa/enable", h.twoFactorEnableSubmit)
129
+				r.Get("/settings/security/2fa/disable", h.twoFactorDisableForm)
130
+				r.Post("/settings/security/2fa/disable", h.twoFactorDisableSubmit)
131
+				r.Post("/settings/security/2fa/regenerate", h.twoFactorRegenerateSubmit)
132
+			})
133
+		}
109134
 	})
110135
 }
111136
 
@@ -352,6 +377,27 @@ func (h *Handlers) loginSubmit(w http.ResponseWriter, r *http.Request) {
352377
 	// Forgive prior failed-attempt counter on success.
353378
 	_ = h.d.Limiter.Reset(r.Context(), h.d.Pool, "login", throttleKey)
354379
 
380
+	// If 2FA is enrolled and confirmed, redirect to the challenge step.
381
+	// Pre-2FA marker carries user_id intent without granting full session.
382
+	if t, terr := h.q.GetUserTOTP(r.Context(), h.d.Pool, user.ID); terr == nil && t.ConfirmedAt.Valid {
383
+		s := middleware.SessionFromContext(r.Context())
384
+		s.UserID = 0
385
+		s.Pre2FAUserID = user.ID
386
+		s.IssuedAt = time.Now().Unix()
387
+		if err := h.d.SessionStore.Save(w, r, s); err != nil {
388
+			h.d.Logger.ErrorContext(r.Context(), "login: save pre-2fa session", "error", err)
389
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
390
+			return
391
+		}
392
+		dest := "/login/2fa"
393
+		if next != "" && strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
394
+			dest = "/login/2fa?next=" + url.QueryEscape(next)
395
+		}
396
+		//nolint:gosec // G710: dest is whitelisted to /login/2fa with sanitized next.
397
+		http.Redirect(w, r, dest, http.StatusSeeOther)
398
+		return
399
+	}
400
+
355401
 	if err := h.q.TouchUserLastLogin(r.Context(), h.d.Pool, user.ID); err != nil {
356402
 		h.d.Logger.WarnContext(r.Context(), "login: touch last_login_at", "error", err)
357403
 	}
@@ -360,6 +406,7 @@ func (h *Handlers) loginSubmit(w http.ResponseWriter, r *http.Request) {
360406
 	// AEAD store re-encrypts on every Save, producing a fresh ciphertext.
361407
 	s := middleware.SessionFromContext(r.Context())
362408
 	s.UserID = user.ID
409
+	s.Pre2FAUserID = 0
363410
 	s.IssuedAt = time.Now().Unix()
364411
 	if err := h.d.SessionStore.Save(w, r, s); err != nil {
365412
 		h.d.Logger.ErrorContext(r.Context(), "login: save session", "error", err)
internal/web/handlers/auth/twofactor.goadded
@@ -0,0 +1,517 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+	"net/http"
10
+	"strings"
11
+	"time"
12
+
13
+	"github.com/jackc/pgx/v5"
14
+	"github.com/jackc/pgx/v5/pgtype"
15
+
16
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
17
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
18
+	"github.com/tenseleyFlow/shithub/internal/auth/password"
19
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
20
+	"github.com/tenseleyFlow/shithub/internal/auth/totp"
21
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
22
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
23
+)
24
+
25
+// ============================ login challenge ===========================
26
+
27
+func (h *Handlers) twoFactorChallengeForm(w http.ResponseWriter, r *http.Request) {
28
+	s := middleware.SessionFromContext(r.Context())
29
+	if s.Pre2FAUserID == 0 {
30
+		http.Redirect(w, r, "/login", http.StatusSeeOther)
31
+		return
32
+	}
33
+	h.renderPage(w, r, "auth/2fa_challenge", map[string]any{
34
+		"Title":     "Two-factor authentication",
35
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
36
+		"Next":      r.URL.Query().Get("next"),
37
+	})
38
+}
39
+
40
+func (h *Handlers) twoFactorChallengeSubmit(w http.ResponseWriter, r *http.Request) {
41
+	if err := r.ParseForm(); err != nil {
42
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
43
+		return
44
+	}
45
+	s := middleware.SessionFromContext(r.Context())
46
+	if s.Pre2FAUserID == 0 {
47
+		http.Redirect(w, r, "/login", http.StatusSeeOther)
48
+		return
49
+	}
50
+	userID := s.Pre2FAUserID
51
+	code := strings.TrimSpace(r.PostFormValue("code"))
52
+	next := r.PostFormValue("next")
53
+
54
+	render := func(msg string) {
55
+		h.renderPage(w, r, "auth/2fa_challenge", map[string]any{
56
+			"Title":     "Two-factor authentication",
57
+			"CSRFToken": middleware.CSRFTokenForRequest(r),
58
+			"Error":     msg,
59
+			"Next":      next,
60
+		})
61
+	}
62
+
63
+	throttleKey := fmt.Sprintf("ip:%s|uid:%d", clientIP(r), userID)
64
+	if err := h.d.Limiter.Hit(r.Context(), h.d.Pool, throttle.Limit{
65
+		Scope: "2fa", Identifier: throttleKey,
66
+		Max: 5, Window: 5 * time.Minute,
67
+	}); err != nil {
68
+		h.writeRetryAfter(w, err)
69
+		render("Too many failed attempts. Please sign in again.")
70
+		// Drop pre-2fa marker so caller restarts the flow.
71
+		s.Pre2FAUserID = 0
72
+		_ = h.d.SessionStore.Save(w, r, s)
73
+		return
74
+	}
75
+
76
+	if code == "" {
77
+		render("Enter your 6-digit code or a recovery code.")
78
+		return
79
+	}
80
+
81
+	accepted := false
82
+	usedRecovery := false
83
+	if totp.LooksLikeRecoveryCode(code) {
84
+		ok, err := h.consumeRecoveryCode(r.Context(), userID, code)
85
+		if err != nil {
86
+			h.d.Logger.ErrorContext(r.Context(), "2fa: consume recovery", "error", err)
87
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
88
+			return
89
+		}
90
+		accepted = ok
91
+		usedRecovery = ok
92
+	} else {
93
+		ok, err := h.verifyTOTPCode(r.Context(), userID, code)
94
+		if err != nil {
95
+			h.d.Logger.ErrorContext(r.Context(), "2fa: verify totp", "error", err)
96
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
97
+			return
98
+		}
99
+		accepted = ok
100
+	}
101
+
102
+	if !accepted {
103
+		render("Incorrect code. Try again.")
104
+		return
105
+	}
106
+
107
+	// Forgive prior failed-attempt counter on success.
108
+	_ = h.d.Limiter.Reset(r.Context(), h.d.Pool, "2fa", throttleKey)
109
+
110
+	if err := h.q.TouchUserLastLogin(r.Context(), h.d.Pool, userID); err != nil {
111
+		h.d.Logger.WarnContext(r.Context(), "2fa: touch last_login_at", "error", err)
112
+	}
113
+
114
+	if usedRecovery {
115
+		_ = h.d.Audit.Record(r.Context(), h.d.Pool, userID,
116
+			audit.ActionRecoveryCodeUsed, audit.TargetUser, userID, nil)
117
+	}
118
+
119
+	// Upgrade session: drop pre-2FA marker, set UserID, reissue.
120
+	s.Pre2FAUserID = 0
121
+	s.UserID = userID
122
+	s.IssuedAt = time.Now().Unix()
123
+	if err := h.d.SessionStore.Save(w, r, s); err != nil {
124
+		h.d.Logger.ErrorContext(r.Context(), "2fa: save session", "error", err)
125
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
126
+		return
127
+	}
128
+
129
+	dest := "/"
130
+	if next != "" && strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
131
+		dest = next
132
+	}
133
+	//nolint:gosec // G710: dest is whitelisted to single-leading-slash relative paths.
134
+	http.Redirect(w, r, dest, http.StatusSeeOther)
135
+}
136
+
137
+// ============================ enrollment ================================
138
+
139
+func (h *Handlers) twoFactorEnableForm(w http.ResponseWriter, r *http.Request) {
140
+	user := middleware.CurrentUserFromContext(r.Context())
141
+
142
+	// If already enrolled and confirmed, send to disable page instead.
143
+	if existing, err := h.q.GetUserTOTP(r.Context(), h.d.Pool, user.ID); err == nil && existing.ConfirmedAt.Valid {
144
+		http.Redirect(w, r, "/settings/security/2fa/disable", http.StatusSeeOther)
145
+		return
146
+	}
147
+
148
+	// Mint or replace a pending secret. UpsertUserTOTP only updates when
149
+	// confirmed_at IS NULL — confirmed rows are protected.
150
+	secret, err := totp.GenerateSecret()
151
+	if err != nil {
152
+		h.d.Logger.ErrorContext(r.Context(), "2fa: secret", "error", err)
153
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
154
+		return
155
+	}
156
+	enc, nonce, err := h.d.SecretBox.Seal(secret)
157
+	if err != nil {
158
+		h.d.Logger.ErrorContext(r.Context(), "2fa: seal", "error", err)
159
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
160
+		return
161
+	}
162
+	if _, err := h.q.UpsertUserTOTP(r.Context(), h.d.Pool, usersdb.UpsertUserTOTPParams{
163
+		UserID:          user.ID,
164
+		SecretEncrypted: enc,
165
+		SecretNonce:     nonce,
166
+	}); err != nil {
167
+		h.d.Logger.ErrorContext(r.Context(), "2fa: upsert secret", "error", err)
168
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
169
+		return
170
+	}
171
+
172
+	uri := totp.OtpauthURI(h.d.Branding.SiteName, user.Username, secret)
173
+	svg, err := totp.QRSVG(uri)
174
+	if err != nil {
175
+		h.d.Logger.ErrorContext(r.Context(), "2fa: qr", "error", err)
176
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
177
+		return
178
+	}
179
+
180
+	h.renderPage(w, r, "settings/2fa_enable", map[string]any{
181
+		"Title":     "Enable two-factor authentication",
182
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
183
+		"QRSvg":     svg,
184
+		"Secret":    totp.EncodeBase32(secret), // displayed for manual entry; also high-entropy + redacted in logs
185
+	})
186
+}
187
+
188
+func (h *Handlers) twoFactorEnableSubmit(w http.ResponseWriter, r *http.Request) {
189
+	if err := r.ParseForm(); err != nil {
190
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
191
+		return
192
+	}
193
+	user := middleware.CurrentUserFromContext(r.Context())
194
+	code := strings.TrimSpace(r.PostFormValue("code"))
195
+
196
+	render := func(msg string, recoveryCodes []string) {
197
+		data := map[string]any{
198
+			"Title":     "Enable two-factor authentication",
199
+			"CSRFToken": middleware.CSRFTokenForRequest(r),
200
+		}
201
+		if msg != "" {
202
+			data["Error"] = msg
203
+		}
204
+		if len(recoveryCodes) > 0 {
205
+			data["RecoveryCodes"] = recoveryCodes
206
+		}
207
+		h.renderPage(w, r, "settings/2fa_recovery", data)
208
+	}
209
+
210
+	row, err := h.q.GetUserTOTP(r.Context(), h.d.Pool, user.ID)
211
+	if err != nil {
212
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "no pending 2FA enrollment")
213
+		return
214
+	}
215
+	if row.ConfirmedAt.Valid {
216
+		http.Redirect(w, r, "/settings/security/2fa/disable", http.StatusSeeOther)
217
+		return
218
+	}
219
+
220
+	secret, err := h.d.SecretBox.Open(row.SecretEncrypted, row.SecretNonce)
221
+	if err != nil {
222
+		h.d.Logger.ErrorContext(r.Context(), "2fa: open secret", "error", err)
223
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
224
+		return
225
+	}
226
+	step, err := totp.Verify(secret, code, time.Now())
227
+	if err != nil {
228
+		render("That code is incorrect. Try again.", nil)
229
+		return
230
+	}
231
+
232
+	codes, hashes, err := totp.GenerateRecoveryCodes()
233
+	if err != nil {
234
+		h.d.Logger.ErrorContext(r.Context(), "2fa: generate recovery", "error", err)
235
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
236
+		return
237
+	}
238
+
239
+	tx, err := h.d.Pool.Begin(r.Context())
240
+	if err != nil {
241
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
242
+		return
243
+	}
244
+	defer func() { _ = tx.Rollback(r.Context()) }()
245
+
246
+	// ConfirmUserTOTP only updates when confirmed_at IS NULL — handles the
247
+	// parallel-enrollment race; a second submit finds rows-affected==0.
248
+	rows, err := h.q.ConfirmUserTOTP(r.Context(), tx, usersdb.ConfirmUserTOTPParams{
249
+		UserID:          user.ID,
250
+		LastUsedCounter: step,
251
+	})
252
+	if err != nil {
253
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
254
+		return
255
+	}
256
+	if rows == 0 {
257
+		// Already confirmed by a parallel request.
258
+		http.Redirect(w, r, "/settings/security/2fa/disable", http.StatusSeeOther)
259
+		return
260
+	}
261
+
262
+	if err := h.q.DeleteUserRecoveryCodes(r.Context(), tx, user.ID); err != nil {
263
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
264
+		return
265
+	}
266
+	for _, hsh := range hashes {
267
+		if err := h.q.InsertRecoveryCode(r.Context(), tx, usersdb.InsertRecoveryCodeParams{
268
+			UserID: user.ID, CodeHash: hsh,
269
+		}); err != nil {
270
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
271
+			return
272
+		}
273
+	}
274
+	if err := h.d.Audit.Record(r.Context(), tx, user.ID,
275
+		audit.Action2FAEnabled, audit.TargetUser, user.ID, nil); err != nil {
276
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
277
+		return
278
+	}
279
+	if err := h.d.Audit.Record(r.Context(), tx, user.ID,
280
+		audit.ActionRecoveryCodesIssued, audit.TargetUser, user.ID, map[string]any{"count": len(codes)}); err != nil {
281
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
282
+		return
283
+	}
284
+	if err := tx.Commit(r.Context()); err != nil {
285
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
286
+		return
287
+	}
288
+
289
+	h.notifyUser(r.Context(), user.ID, "2fa_enabled")
290
+
291
+	render("", codes)
292
+}
293
+
294
+// =============================== disable ================================
295
+
296
+func (h *Handlers) twoFactorDisableForm(w http.ResponseWriter, r *http.Request) {
297
+	h.renderPage(w, r, "settings/2fa_disable", map[string]any{
298
+		"Title":     "Disable two-factor authentication",
299
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
300
+	})
301
+}
302
+
303
+func (h *Handlers) twoFactorDisableSubmit(w http.ResponseWriter, r *http.Request) {
304
+	if err := r.ParseForm(); err != nil {
305
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
306
+		return
307
+	}
308
+	user := middleware.CurrentUserFromContext(r.Context())
309
+	pw := r.PostFormValue("password")
310
+	code := strings.TrimSpace(r.PostFormValue("code"))
311
+
312
+	render := func(msg string) {
313
+		h.renderPage(w, r, "settings/2fa_disable", map[string]any{
314
+			"Title":     "Disable two-factor authentication",
315
+			"CSRFToken": middleware.CSRFTokenForRequest(r),
316
+			"Error":     msg,
317
+		})
318
+	}
319
+
320
+	if ok, err := h.confirmPasswordAndTOTP(r.Context(), user.ID, pw, code); err != nil {
321
+		h.d.Logger.ErrorContext(r.Context(), "2fa: disable confirm", "error", err)
322
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
323
+		return
324
+	} else if !ok {
325
+		render("Password or code incorrect. Please try again.")
326
+		return
327
+	}
328
+
329
+	tx, err := h.d.Pool.Begin(r.Context())
330
+	if err != nil {
331
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
332
+		return
333
+	}
334
+	defer func() { _ = tx.Rollback(r.Context()) }()
335
+
336
+	if err := h.q.DeleteUserTOTP(r.Context(), tx, user.ID); err != nil {
337
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
338
+		return
339
+	}
340
+	if err := h.q.DeleteUserRecoveryCodes(r.Context(), tx, user.ID); err != nil {
341
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
342
+		return
343
+	}
344
+	if err := h.d.Audit.Record(r.Context(), tx, user.ID,
345
+		audit.Action2FADisabled, audit.TargetUser, user.ID, nil); err != nil {
346
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
347
+		return
348
+	}
349
+	if err := tx.Commit(r.Context()); err != nil {
350
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
351
+		return
352
+	}
353
+
354
+	h.notifyUser(r.Context(), user.ID, "2fa_disabled")
355
+	http.Redirect(w, r, "/settings/security/2fa/enable?notice=disabled", http.StatusSeeOther)
356
+}
357
+
358
+// ============================== regenerate ==============================
359
+
360
+func (h *Handlers) twoFactorRegenerateSubmit(w http.ResponseWriter, r *http.Request) {
361
+	if err := r.ParseForm(); err != nil {
362
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
363
+		return
364
+	}
365
+	user := middleware.CurrentUserFromContext(r.Context())
366
+	pw := r.PostFormValue("password")
367
+	code := strings.TrimSpace(r.PostFormValue("code"))
368
+
369
+	if ok, err := h.confirmPasswordAndTOTP(r.Context(), user.ID, pw, code); err != nil {
370
+		h.d.Logger.ErrorContext(r.Context(), "2fa: regen confirm", "error", err)
371
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
372
+		return
373
+	} else if !ok {
374
+		h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "Password or code incorrect")
375
+		return
376
+	}
377
+
378
+	codes, hashes, err := totp.GenerateRecoveryCodes()
379
+	if err != nil {
380
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
381
+		return
382
+	}
383
+
384
+	tx, err := h.d.Pool.Begin(r.Context())
385
+	if err != nil {
386
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
387
+		return
388
+	}
389
+	defer func() { _ = tx.Rollback(r.Context()) }()
390
+
391
+	if err := h.q.DeleteUserRecoveryCodes(r.Context(), tx, user.ID); err != nil {
392
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
393
+		return
394
+	}
395
+	for _, hsh := range hashes {
396
+		if err := h.q.InsertRecoveryCode(r.Context(), tx, usersdb.InsertRecoveryCodeParams{
397
+			UserID: user.ID, CodeHash: hsh,
398
+		}); err != nil {
399
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
400
+			return
401
+		}
402
+	}
403
+	if err := h.d.Audit.Record(r.Context(), tx, user.ID,
404
+		audit.ActionRecoveryRegenerated, audit.TargetUser, user.ID, map[string]any{"count": len(codes)}); err != nil {
405
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
406
+		return
407
+	}
408
+	if err := tx.Commit(r.Context()); err != nil {
409
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
410
+		return
411
+	}
412
+
413
+	h.notifyUser(r.Context(), user.ID, "recovery_regenerated")
414
+
415
+	h.renderPage(w, r, "settings/2fa_recovery", map[string]any{
416
+		"Title":         "New recovery codes",
417
+		"CSRFToken":     middleware.CSRFTokenForRequest(r),
418
+		"RecoveryCodes": codes,
419
+	})
420
+}
421
+
422
+// =============================== helpers =================================
423
+
424
+// verifyTOTPCode verifies code against the user's confirmed TOTP secret
425
+// AND advances last_used_counter atomically (counter anti-replay).
426
+func (h *Handlers) verifyTOTPCode(ctx context.Context, userID int64, code string) (bool, error) {
427
+	row, err := h.q.GetUserTOTP(ctx, h.d.Pool, userID)
428
+	if err != nil {
429
+		return false, nil // no enrollment → reject without leaking
430
+	}
431
+	if !row.ConfirmedAt.Valid {
432
+		return false, nil
433
+	}
434
+	secret, err := h.d.SecretBox.Open(row.SecretEncrypted, row.SecretNonce)
435
+	if err != nil {
436
+		return false, fmt.Errorf("open secret: %w", err)
437
+	}
438
+	step, err := totp.Verify(secret, code, time.Now())
439
+	if err != nil {
440
+		return false, nil
441
+	}
442
+	rows, err := h.q.BumpTOTPCounter(ctx, h.d.Pool, usersdb.BumpTOTPCounterParams{
443
+		UserID:          userID,
444
+		LastUsedCounter: step,
445
+	})
446
+	if err != nil {
447
+		return false, fmt.Errorf("bump counter: %w", err)
448
+	}
449
+	return rows == 1, nil // rows==0 means counter replay → reject
450
+}
451
+
452
+// consumeRecoveryCode hashes the typed code and tries to mark it used.
453
+// Returns true iff exactly one matching unused row was found.
454
+func (h *Handlers) consumeRecoveryCode(ctx context.Context, userID int64, code string) (bool, error) {
455
+	hash := totp.HashRecoveryCode(code)
456
+	rows, err := h.q.ConsumeRecoveryCode(ctx, h.d.Pool, usersdb.ConsumeRecoveryCodeParams{
457
+		UserID: userID, CodeHash: hash,
458
+	})
459
+	if err != nil {
460
+		return false, err
461
+	}
462
+	return rows == 1, nil
463
+}
464
+
465
+// confirmPasswordAndTOTP validates current password AND current TOTP
466
+// before sensitive 2FA state changes. Returns (true, nil) on success,
467
+// (false, nil) on a clean rejection, or (false, err) on a real error.
468
+func (h *Handlers) confirmPasswordAndTOTP(ctx context.Context, userID int64, pw, code string) (bool, error) {
469
+	user, err := h.q.GetUserByID(ctx, h.d.Pool, userID)
470
+	if err != nil {
471
+		return false, err
472
+	}
473
+	ok, err := password.Verify(pw, user.PasswordHash)
474
+	if err != nil {
475
+		return false, err
476
+	}
477
+	if !ok {
478
+		return false, nil
479
+	}
480
+	if codeOK, err := h.verifyTOTPCode(ctx, userID, code); err != nil {
481
+		return false, err
482
+	} else if !codeOK {
483
+		return false, nil
484
+	}
485
+	return true, nil
486
+}
487
+
488
+// notifyUser sends a notification email about a 2FA state change. Best
489
+// effort — failure is logged but does not break the flow.
490
+func (h *Handlers) notifyUser(ctx context.Context, userID int64, kind string) {
491
+	user, err := h.q.GetUserByID(ctx, h.d.Pool, userID)
492
+	if err != nil {
493
+		return
494
+	}
495
+	if !user.PrimaryEmailID.Valid {
496
+		return
497
+	}
498
+	em, err := h.q.GetUserEmailByID(ctx, h.d.Pool, user.PrimaryEmailID.Int64)
499
+	if err != nil {
500
+		return
501
+	}
502
+	msg, err := email.NoticeMessage(h.d.Branding, string(em.Email), user.Username, kind)
503
+	if err != nil {
504
+		h.d.Logger.WarnContext(ctx, "notice: build", "kind", kind, "error", err)
505
+		return
506
+	}
507
+	if err := h.d.Email.Send(ctx, msg); err != nil {
508
+		h.d.Logger.WarnContext(ctx, "notice: send", "kind", kind, "error", err)
509
+	}
510
+}
511
+
512
+// silence unused import warnings if guards are removed.
513
+var (
514
+	_ = pgx.ErrNoRows
515
+	_ = pgtype.Int8{}
516
+	_ = errors.New
517
+)