Go · 33108 bytes Raw Blame History
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 "net/url"
30 "regexp"
31 "strconv"
32 "strings"
33 "time"
34
35 "github.com/go-chi/chi/v5"
36 "github.com/jackc/pgx/v5"
37 "github.com/jackc/pgx/v5/pgtype"
38 "github.com/jackc/pgx/v5/pgxpool"
39
40 authpkg "github.com/tenseleyFlow/shithub/internal/auth"
41 "github.com/tenseleyFlow/shithub/internal/auth/audit"
42 "github.com/tenseleyFlow/shithub/internal/auth/devicecode"
43 "github.com/tenseleyFlow/shithub/internal/auth/email"
44 "github.com/tenseleyFlow/shithub/internal/auth/password"
45 "github.com/tenseleyFlow/shithub/internal/auth/secretbox"
46 "github.com/tenseleyFlow/shithub/internal/auth/session"
47 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
48 "github.com/tenseleyFlow/shithub/internal/auth/token"
49 "github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
50 "github.com/tenseleyFlow/shithub/internal/infra/storage"
51 "github.com/tenseleyFlow/shithub/internal/passwords"
52 "github.com/tenseleyFlow/shithub/internal/ratelimit"
53 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
54 "github.com/tenseleyFlow/shithub/internal/web/middleware"
55 "github.com/tenseleyFlow/shithub/internal/web/render"
56 )
57
58 // usernameRE mirrors the path whitelist used by RepoFS.
59 var usernameRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$`)
60
61 // Deps is everything the auth handlers need. Constructed by the web
62 // package and injected at registration time.
63 type Deps struct {
64 Logger *slog.Logger
65 Render *render.Renderer
66 Pool *pgxpool.Pool
67 SessionStore session.Store
68 Email email.Sender
69 Branding email.Branding
70 Argon2 password.Params
71 Limiter *throttle.Limiter
72 // RateLimiter wraps the S35 generalised rate-limit table. Used
73 // for the per-/24 signup throttle (anti-abuse heuristic). nil
74 // disables the secondary throttle; the per-IP S05 limiter still
75 // applies.
76 RateLimiter *ratelimit.Limiter
77 RequireEmailVerification bool
78 // SecretBox encrypts at-rest TOTP secrets. May be nil; when nil, the
79 // 2FA enrollment endpoints are not registered.
80 SecretBox *secretbox.Box
81 // Audit records security-relevant events (2fa state changes, etc.).
82 Audit *audit.Recorder
83 // ObjectStore is the avatar / attachment backend. May be nil; when
84 // nil the avatar upload endpoint is not registered (the user keeps
85 // their identicon and never sees the upload form).
86 ObjectStore storage.ObjectStore
87 // OrgBillingEnabled mirrors whether the org handler registered the
88 // owner-only billing/settings routes. The auth settings page uses it
89 // only to decide whether existing orgs can link to plan comparison.
90 OrgBillingEnabled bool
91 // DeviceCode configures the RFC 8628 device-code grant exposed at
92 // /login/device, /login/device/code, and /login/oauth/access_token.
93 // Zero value uses devicecode.Defaults() so a deployment that never
94 // wires this still gets a working grant for the canonical
95 // shithub-cli client_id.
96 DeviceCode devicecode.Config
97 // BillingEnabled gates whether the user-tier billing settings
98 // surface (PRO06) registers its routes. False ⇒ /settings/billing
99 // renders a "paid plans not configured on this instance" page;
100 // checkout / portal routes return 404. Operators flip this once
101 // they've configured a Stripe secret + Pro price.
102 BillingEnabled bool
103 BillingGracePeriod time.Duration
104 Stripe stripebilling.Remote
105 StripeSuccessURL string
106 StripeCancelURL string
107 StripePortalReturnURL string
108 // BaseURL is the canonical absolute URL of this shithub instance.
109 // Used to construct Stripe return URLs when no per-route override
110 // is configured.
111 BaseURL string
112 }
113
114 // Handlers is the registered handler set. Construct with New.
115 type Handlers struct {
116 d Deps
117 q *usersdb.Queries
118 }
119
120 // New constructs the handler set, validating Deps.
121 func New(d Deps) (*Handlers, error) {
122 if d.Render == nil {
123 return nil, errors.New("auth: nil Render")
124 }
125 if d.SessionStore == nil {
126 return nil, errors.New("auth: nil SessionStore")
127 }
128 if d.Email == nil {
129 return nil, errors.New("auth: nil Email sender")
130 }
131 if d.Limiter == nil {
132 d.Limiter = throttle.NewLimiter()
133 }
134 if d.Audit == nil {
135 d.Audit = audit.NewRecorder()
136 }
137 if len(d.DeviceCode.ClientIDs) == 0 {
138 d.DeviceCode = devicecode.Defaults()
139 }
140 password.MustGenerateDummy(d.Argon2)
141 return &Handlers{d: d, q: usersdb.New()}, nil
142 }
143
144 // Mount registers every auth route on r. The 4 KiB body cap is applied
145 // to POST endpoints inside this method.
146 func (h *Handlers) Mount(r chi.Router) {
147 r.Group(func(r chi.Router) {
148 r.Use(middleware.MaxBodySize(4 * 1024))
149 r.Get("/signup", h.signupForm)
150 r.Post("/signup", h.signupSubmit)
151 r.Get("/login", h.loginForm)
152 r.Post("/login", h.loginSubmit)
153 r.Get("/login/2fa", h.twoFactorChallengeForm)
154 r.Post("/login/2fa", h.twoFactorChallengeSubmit)
155 // S50 §1 — device-code (RFC 8628) user verification page.
156 // The matching CSRF-exempt JSON endpoints land under
157 // /login/device/code and /login/oauth/access_token via
158 // MountDeviceCodeAPI, wired separately by handlers.go.
159 r.Get("/login/device", h.deviceCodeForm)
160 r.Post("/login/device", h.deviceCodeSubmit)
161 r.Post("/logout", h.logoutSubmit)
162 r.Get("/password/reset", h.resetRequestForm)
163 r.Post("/password/reset", h.resetRequestSubmit)
164 r.Get("/password/reset/{token}", h.resetConfirmForm)
165 r.Post("/password/reset/{token}", h.resetConfirmSubmit)
166 r.Get("/verify-email/{token}", h.verifyEmail)
167 r.Get("/verify-email/resend", h.verifyResendForm)
168 r.Post("/verify-email/resend", h.verifyResendSubmit)
169
170 // Settings — require an authenticated user.
171 r.Group(func(r chi.Router) {
172 r.Use(middleware.RequireUser)
173 r.Get("/settings/profile", h.settingsProfileForm)
174 r.Post("/settings/profile", h.settingsProfileSubmit)
175 if h.d.ObjectStore != nil {
176 r.Post("/settings/profile/avatar", h.settingsAvatarUpload)
177 r.Post("/settings/profile/avatar/remove", h.settingsAvatarRemove)
178 }
179 r.Get("/settings/account", h.settingsAccountForm)
180 r.Post("/settings/account/username", h.settingsAccountUsername)
181 r.Get("/settings/password", h.settingsPasswordForm)
182 r.Post("/settings/password", h.settingsPasswordSubmit)
183 r.Get("/settings/appearance", h.settingsAppearanceForm)
184 r.Post("/settings/appearance", h.settingsAppearanceSubmit)
185 r.Get("/settings/organizations", h.settingsOrganizations)
186 r.Get("/settings/emails", h.settingsEmailsList)
187 r.Post("/settings/emails", h.settingsEmailsAdd)
188 r.Post("/settings/emails/{id}/resend", h.settingsEmailsResend)
189 r.Post("/settings/emails/{id}/primary", h.settingsEmailsSetPrimary)
190 r.Post("/settings/emails/{id}/remove", h.settingsEmailsRemove)
191 r.Get("/settings/notifications", h.settingsNotificationsForm)
192 r.Post("/settings/notifications", h.settingsNotificationsSubmit)
193 r.Get("/settings/sessions", h.settingsSessionsList)
194 r.Post("/settings/sessions/logout-everywhere", h.settingsSessionsLogoutAll)
195 r.Get("/settings/danger", h.settingsDangerForm)
196 r.Post("/settings/danger", h.settingsDangerDelete)
197 r.Get("/settings/keys", h.sshKeysList)
198 r.Post("/settings/keys", h.sshKeysAdd)
199 r.Post("/settings/keys/{id}/delete", h.sshKeysDelete)
200 r.Get("/settings/keys/gpg/new", h.gpgKeysAddForm)
201 r.Post("/settings/keys/gpg", h.gpgKeysAdd)
202 r.Post("/settings/keys/gpg/{id}/delete", h.gpgKeysDelete)
203 // PRO06 — user-tier billing settings. The settings page
204 // itself is always reachable so a Free user can see what
205 // Pro offers; checkout/portal routes only register when
206 // Stripe is configured for this instance.
207 r.Get("/settings/billing", h.settingsBilling)
208 if h.d.BillingEnabled && h.d.Stripe != nil {
209 r.Post("/settings/billing/checkout", h.settingsBillingCheckout)
210 r.Post("/settings/billing/portal", h.settingsBillingPortal)
211 r.Get("/settings/billing/success", h.settingsBillingSuccess)
212 r.Get("/settings/billing/cancel", h.settingsBillingCancel)
213 }
214 r.Get("/settings/tokens", h.tokensList)
215 r.Post("/settings/tokens", h.tokensCreate)
216 r.Post("/settings/tokens/{id}/revoke", h.tokensRevoke)
217 if h.d.SecretBox != nil {
218 r.Get("/settings/security/2fa/enable", h.twoFactorEnableForm)
219 r.Post("/settings/security/2fa/enable", h.twoFactorEnableSubmit)
220 r.Get("/settings/security/2fa/disable", h.twoFactorDisableForm)
221 r.Post("/settings/security/2fa/disable", h.twoFactorDisableSubmit)
222 r.Post("/settings/security/2fa/regenerate", h.twoFactorRegenerateSubmit)
223 }
224 })
225 })
226 }
227
228 // ---------------------------- signup -----------------------------------
229
230 type signupForm struct {
231 Username string
232 Email string
233 }
234
235 func (h *Handlers) signupForm(w http.ResponseWriter, r *http.Request) {
236 h.renderPage(w, r, "auth/signup", map[string]any{
237 "Title": "Sign up",
238 "CSRFToken": middleware.CSRFTokenForRequest(r),
239 "Form": signupForm{},
240 })
241 }
242
243 func (h *Handlers) signupSubmit(w http.ResponseWriter, r *http.Request) {
244 if err := r.ParseForm(); err != nil {
245 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
246 return
247 }
248 form := signupForm{
249 Username: strings.ToLower(strings.TrimSpace(r.PostFormValue("username"))),
250 Email: strings.ToLower(strings.TrimSpace(r.PostFormValue("email"))),
251 }
252 password := r.PostFormValue("password")
253 honeypot := r.PostFormValue("company")
254
255 render := func(msg string) {
256 h.renderPage(w, r, "auth/signup", map[string]any{
257 "Title": "Sign up",
258 "CSRFToken": middleware.CSRFTokenForRequest(r),
259 "Form": form,
260 "Error": msg,
261 })
262 }
263
264 // Honeypot: silently treat as success so bots can't probe.
265 if honeypot != "" {
266 http.Redirect(w, r, "/login?notice=signup-pending", http.StatusSeeOther)
267 return
268 }
269
270 if err := h.throttleSignup(r); err != nil {
271 h.writeRetryAfter(w, err)
272 render("Too many signup attempts. Please try again later.")
273 return
274 }
275
276 if msg := validateUsername(form.Username); msg != "" {
277 render(msg)
278 return
279 }
280 if !looksLikeEmail(form.Email) {
281 render("Please enter a valid email address.")
282 return
283 }
284 if len(password) < 10 {
285 render("Password must be at least 10 characters.")
286 return
287 }
288 if passwords.IsCommon(password) {
289 render("That password is too common. Please choose another.")
290 return
291 }
292
293 hash, err := hashPassword(password, h.d.Argon2)
294 if err != nil {
295 h.d.Logger.ErrorContext(r.Context(), "signup: hash", "error", err)
296 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
297 return
298 }
299
300 ctx := r.Context()
301 tx, err := h.d.Pool.Begin(ctx)
302 if err != nil {
303 h.d.Logger.ErrorContext(ctx, "signup: begin tx", "error", err)
304 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
305 return
306 }
307 defer func() { _ = tx.Rollback(ctx) }()
308
309 user, err := h.q.CreateUser(ctx, tx, usersdb.CreateUserParams{
310 Username: form.Username,
311 DisplayName: form.Username,
312 PasswordHash: hash,
313 })
314 if err != nil {
315 if isUniqueViolation(err) {
316 render("That username is already taken.")
317 return
318 }
319 h.d.Logger.ErrorContext(ctx, "signup: create user", "error", err)
320 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
321 return
322 }
323
324 tokEnc, tokHash, err := token.New()
325 if err != nil {
326 h.d.Logger.ErrorContext(ctx, "signup: token", "error", err)
327 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
328 return
329 }
330
331 em, err := h.q.CreateUserEmail(ctx, tx, usersdb.CreateUserEmailParams{
332 UserID: user.ID,
333 Email: form.Email,
334 IsPrimary: true,
335 Verified: false,
336 VerificationTokenHash: tokHash,
337 })
338 if err != nil {
339 if isUniqueViolation(err) {
340 render("That email is already registered. Try signing in?")
341 return
342 }
343 h.d.Logger.ErrorContext(ctx, "signup: create email", "error", err)
344 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
345 return
346 }
347
348 if err := h.q.LinkUserPrimaryEmail(ctx, tx, usersdb.LinkUserPrimaryEmailParams{
349 ID: user.ID,
350 PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
351 }); err != nil {
352 h.d.Logger.ErrorContext(ctx, "signup: link primary", "error", err)
353 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
354 return
355 }
356
357 expires := pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true}
358 if _, err := h.q.CreateEmailVerification(ctx, tx, usersdb.CreateEmailVerificationParams{
359 UserEmailID: em.ID,
360 TokenHash: tokHash,
361 ExpiresAt: expires,
362 }); err != nil {
363 h.d.Logger.ErrorContext(ctx, "signup: create verification", "error", err)
364 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
365 return
366 }
367
368 if err := tx.Commit(ctx); err != nil {
369 h.d.Logger.ErrorContext(ctx, "signup: commit", "error", err)
370 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
371 return
372 }
373
374 // Best-effort send. SMTP transient failure must not break signup.
375 msg, err := email.VerifyMessage(h.d.Branding, form.Email, form.Username, tokEnc)
376 if err != nil {
377 h.d.Logger.ErrorContext(ctx, "signup: build verify msg", "error", err)
378 } else if err := h.d.Email.Send(ctx, msg); err != nil {
379 h.d.Logger.WarnContext(ctx, "signup: send verify email", "error", err)
380 }
381
382 http.Redirect(w, r, "/login?notice=signup-pending", http.StatusSeeOther)
383 }
384
385 // ----------------------------- login -----------------------------------
386
387 type loginForm struct {
388 Username string
389 }
390
391 const defaultPostLoginPath = "/explore"
392
393 func (h *Handlers) loginUser(ctx context.Context, identifier string) (usersdb.User, error) {
394 if strings.Contains(identifier, "@") {
395 em, err := h.q.GetUserEmailByAddress(ctx, h.d.Pool, identifier)
396 if err != nil {
397 return usersdb.User{}, err
398 }
399 return h.q.GetUserIncludingDeleted(ctx, h.d.Pool, em.UserID)
400 }
401 return h.q.GetUserByUsernameIncludingDeleted(ctx, h.d.Pool, identifier)
402 }
403
404 func (h *Handlers) loginForm(w http.ResponseWriter, r *http.Request) {
405 notice := ""
406 switch r.URL.Query().Get("notice") {
407 case "signup-pending":
408 notice = "Account created. Check your email for the verification link, then sign in."
409 case "verified":
410 notice = "Email verified. You can sign in now."
411 case "logged-out":
412 notice = "Signed out."
413 case "password-reset":
414 notice = "Password updated. Sign in with your new password."
415 }
416 h.renderPage(w, r, "auth/login", map[string]any{
417 "Title": "Sign in",
418 "CSRFToken": middleware.CSRFTokenForRequest(r),
419 "Form": loginForm{},
420 "Notice": notice,
421 "Next": r.URL.Query().Get("next"),
422 })
423 }
424
425 func (h *Handlers) loginSubmit(w http.ResponseWriter, r *http.Request) {
426 if err := r.ParseForm(); err != nil {
427 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
428 return
429 }
430 identifier := strings.ToLower(strings.TrimSpace(r.PostFormValue("username")))
431 pw := r.PostFormValue("password")
432 next := r.PostFormValue("next")
433
434 render := func(msg string) {
435 h.renderPage(w, r, "auth/login", map[string]any{
436 "Title": "Sign in",
437 "CSRFToken": middleware.CSRFTokenForRequest(r),
438 "Form": loginForm{Username: identifier},
439 "Error": msg,
440 "Next": next,
441 })
442 }
443
444 throttleKey := fmt.Sprintf("ip:%s|%s", clientIP(r), identifier)
445 if err := h.d.Limiter.Hit(r.Context(), h.d.Pool, throttle.Limit{
446 Scope: "login", Identifier: throttleKey,
447 Max: 6, Window: 15 * time.Minute,
448 }); err != nil {
449 h.writeRetryAfter(w, err)
450 render("Too many sign-in attempts. Please try again later.")
451 return
452 }
453
454 // IncludingDeleted lets us spot soft-deleted users so we can restore
455 // them on login during the grace window. Past the window they look
456 // indistinguishable from "doesn't exist" — same response, same timing.
457 user, err := h.loginUser(r.Context(), identifier)
458 if err != nil {
459 // User doesn't exist — still hash to keep timing constant.
460 password.VerifyAgainstDummy(pw)
461 render("Incorrect username or password.")
462 return
463 }
464 if user.DeletedAt.Valid && time.Since(user.DeletedAt.Time) >= deletionGraceWindow {
465 password.VerifyAgainstDummy(pw)
466 render("Incorrect username or password.")
467 return
468 }
469
470 ok, err := password.Verify(pw, user.PasswordHash)
471 if err != nil {
472 h.d.Logger.ErrorContext(r.Context(), "login: verify", "error", err)
473 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
474 return
475 }
476 if !ok {
477 render("Incorrect username or password.")
478 return
479 }
480
481 // Restore-on-login: a within-grace soft-deleted user gets undeleted
482 // the moment they prove ownership of the password. Best-effort: a
483 // failed restore doesn't block the login (the row stays
484 // soft-deleted; UI will surface the issue).
485 if user.DeletedAt.Valid {
486 if err := h.q.RestoreUserAccount(r.Context(), h.d.Pool, user.ID); err != nil {
487 h.d.Logger.WarnContext(r.Context(), "login: restore", "error", err)
488 } else {
489 user.DeletedAt.Valid = false
490 }
491 }
492 if user.SuspendedAt.Valid {
493 render("This account has been suspended.")
494 return
495 }
496 if h.d.RequireEmailVerification && !user.EmailVerified {
497 render("Please verify your email before signing in. Check your inbox or request a new link.")
498 return
499 }
500
501 // Forgive prior failed-attempt counter on success.
502 _ = h.d.Limiter.Reset(r.Context(), h.d.Pool, "login", throttleKey)
503
504 // If 2FA is enrolled and confirmed, redirect to the challenge step.
505 // Pre-2FA marker carries user_id intent without granting full session.
506 if t, terr := h.q.GetUserTOTP(r.Context(), h.d.Pool, user.ID); terr == nil && t.ConfirmedAt.Valid {
507 s := middleware.SessionFromContext(r.Context())
508 s.UserID = 0
509 s.Pre2FAUserID = user.ID
510 s.IssuedAt = time.Now().Unix()
511 if err := h.d.SessionStore.Save(w, r, s); err != nil {
512 h.d.Logger.ErrorContext(r.Context(), "login: save pre-2fa session", "error", err)
513 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
514 return
515 }
516 dest := "/login/2fa"
517 if next != "" && strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
518 dest = "/login/2fa?next=" + url.QueryEscape(next)
519 }
520 //nolint:gosec // G710: dest is whitelisted to /login/2fa with sanitized next.
521 http.Redirect(w, r, dest, http.StatusSeeOther)
522 return
523 }
524
525 if err := h.q.TouchUserLastLogin(r.Context(), h.d.Pool, user.ID); err != nil {
526 h.d.Logger.WarnContext(r.Context(), "login: touch last_login_at", "error", err)
527 }
528
529 // Session-fixation defense: bind user_id and re-issue cookie. The
530 // AEAD store re-encrypts on every Save, producing a fresh ciphertext.
531 // Epoch snapshotting is what powers "log out everywhere": this cookie
532 // is invalidated the moment users.session_epoch advances past it.
533 s := middleware.SessionFromContext(r.Context())
534 s.UserID = user.ID
535 s.Pre2FAUserID = 0
536 s.Epoch = user.SessionEpoch
537 s.IssuedAt = time.Now().Unix()
538 if err := h.d.SessionStore.Save(w, r, s); err != nil {
539 h.d.Logger.ErrorContext(r.Context(), "login: save session", "error", err)
540 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
541 return
542 }
543
544 dest := defaultPostLoginPath
545 if next != "" && strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
546 // dest is constrained to single-leading-slash relative paths above,
547 // which prevents the protocol-relative ("//evil.com") form gosec warns about.
548 dest = next
549 }
550 //nolint:gosec // G710: dest is whitelisted to single-leading-slash relative paths.
551 http.Redirect(w, r, dest, http.StatusSeeOther)
552 }
553
554 // ---------------------------- logout -----------------------------------
555
556 func (h *Handlers) logoutSubmit(w http.ResponseWriter, r *http.Request) {
557 h.d.SessionStore.Clear(w)
558 http.Redirect(w, r, "/login?notice=logged-out", http.StatusSeeOther)
559 }
560
561 // ------------------------- password reset ------------------------------
562
563 func (h *Handlers) resetRequestForm(w http.ResponseWriter, r *http.Request) {
564 h.renderPage(w, r, "auth/reset_request", map[string]any{
565 "Title": "Reset password",
566 "CSRFToken": middleware.CSRFTokenForRequest(r),
567 "Sent": false,
568 })
569 }
570
571 func (h *Handlers) resetRequestSubmit(w http.ResponseWriter, r *http.Request) {
572 if err := r.ParseForm(); err != nil {
573 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
574 return
575 }
576 addr := strings.ToLower(strings.TrimSpace(r.PostFormValue("email")))
577
578 // Always show the same notice — no enumeration via this flow.
579 notice := "If an account is registered to that address, we've sent a password-reset link."
580 render := func() {
581 h.renderPage(w, r, "auth/reset_request", map[string]any{
582 "Title": "Reset password",
583 "CSRFToken": middleware.CSRFTokenForRequest(r),
584 "Notice": notice,
585 "Sent": true,
586 })
587 }
588
589 if !looksLikeEmail(addr) {
590 render()
591 return
592 }
593
594 if err := h.d.Limiter.Hit(r.Context(), h.d.Pool, throttle.Limit{
595 Scope: "reset", Identifier: "email:" + addr,
596 Max: 3, Window: time.Hour,
597 }); err != nil {
598 h.writeRetryAfter(w, err)
599 render()
600 return
601 }
602
603 em, err := h.q.GetUserEmailByAddress(r.Context(), h.d.Pool, addr)
604 if err != nil {
605 // Don't even hint at existence.
606 render()
607 return
608 }
609
610 tokEnc, tokHash, err := token.New()
611 if err != nil {
612 h.d.Logger.ErrorContext(r.Context(), "reset: token", "error", err)
613 render()
614 return
615 }
616 expires := pgtype.Timestamptz{Time: time.Now().Add(time.Hour), Valid: true}
617 if _, err := h.q.CreatePasswordReset(r.Context(), h.d.Pool, usersdb.CreatePasswordResetParams{
618 UserID: em.UserID,
619 TokenHash: tokHash,
620 ExpiresAt: expires,
621 }); err != nil {
622 h.d.Logger.ErrorContext(r.Context(), "reset: insert", "error", err)
623 render()
624 return
625 }
626
627 msg, err := email.ResetMessage(h.d.Branding, addr, tokEnc)
628 if err == nil {
629 if err := h.d.Email.Send(r.Context(), msg); err != nil {
630 h.d.Logger.WarnContext(r.Context(), "reset: send", "error", err)
631 }
632 }
633 render()
634 }
635
636 func (h *Handlers) resetConfirmForm(w http.ResponseWriter, r *http.Request) {
637 tokStr := chi.URLParam(r, "token")
638 if _, err := h.lookupValidReset(r.Context(), tokStr); err != nil {
639 h.d.Render.HTTPError(w, r, http.StatusNotFound, "reset link invalid or expired")
640 return
641 }
642 h.renderPage(w, r, "auth/reset_confirm", map[string]any{
643 "Title": "Choose new password",
644 "CSRFToken": middleware.CSRFTokenForRequest(r),
645 "Token": tokStr,
646 })
647 }
648
649 func (h *Handlers) resetConfirmSubmit(w http.ResponseWriter, r *http.Request) {
650 if err := r.ParseForm(); err != nil {
651 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
652 return
653 }
654 tokStr := chi.URLParam(r, "token")
655 pw := r.PostFormValue("password")
656
657 render := func(msg string) {
658 h.renderPage(w, r, "auth/reset_confirm", map[string]any{
659 "Title": "Choose new password",
660 "CSRFToken": middleware.CSRFTokenForRequest(r),
661 "Token": tokStr,
662 "Error": msg,
663 })
664 }
665
666 row, err := h.lookupValidReset(r.Context(), tokStr)
667 if err != nil {
668 h.d.Render.HTTPError(w, r, http.StatusNotFound, "reset link invalid or expired")
669 return
670 }
671 if len(pw) < 10 {
672 render("Password must be at least 10 characters.")
673 return
674 }
675 if passwords.IsCommon(pw) {
676 render("That password is too common. Please choose another.")
677 return
678 }
679
680 hash, err := hashPassword(pw, h.d.Argon2)
681 if err != nil {
682 h.d.Logger.ErrorContext(r.Context(), "reset: hash", "error", err)
683 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
684 return
685 }
686
687 tx, err := h.d.Pool.Begin(r.Context())
688 if err != nil {
689 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
690 return
691 }
692 defer func() { _ = tx.Rollback(r.Context()) }()
693
694 if err := h.q.UpdateUserPassword(r.Context(), tx, usersdb.UpdateUserPasswordParams{
695 ID: row.UserID,
696 PasswordHash: hash,
697 PasswordAlgo: password.Algo,
698 }); err != nil {
699 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
700 return
701 }
702 if err := h.q.ConsumePasswordReset(r.Context(), tx, row.ID); err != nil {
703 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
704 return
705 }
706 if err := tx.Commit(r.Context()); err != nil {
707 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
708 return
709 }
710
711 http.Redirect(w, r, "/login?notice=password-reset", http.StatusSeeOther)
712 }
713
714 // ------------------------- email verification --------------------------
715
716 func (h *Handlers) verifyEmail(w http.ResponseWriter, r *http.Request) {
717 tokStr := chi.URLParam(r, "token")
718 hash, err := token.HashOf(tokStr)
719 if err != nil {
720 h.d.Render.HTTPError(w, r, http.StatusNotFound, "verification link invalid")
721 return
722 }
723 row, err := h.q.GetEmailVerificationByTokenHash(r.Context(), h.d.Pool, hash)
724 if err != nil || row.UsedAt.Valid || time.Now().After(row.ExpiresAt.Time) {
725 h.d.Render.HTTPError(w, r, http.StatusNotFound, "verification link invalid or expired")
726 return
727 }
728 em, err := h.q.GetUserEmailByID(r.Context(), h.d.Pool, row.UserEmailID)
729 if err != nil {
730 h.d.Render.HTTPError(w, r, http.StatusNotFound, "verification link invalid")
731 return
732 }
733
734 tx, err := h.d.Pool.Begin(r.Context())
735 if err != nil {
736 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
737 return
738 }
739 defer func() { _ = tx.Rollback(r.Context()) }()
740
741 if err := h.q.MarkUserEmailVerified(r.Context(), tx, em.ID); err != nil {
742 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
743 return
744 }
745 if em.IsPrimary {
746 if err := h.q.MarkUserEmailPrimaryVerified(r.Context(), tx, em.UserID); err != nil {
747 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
748 return
749 }
750 }
751 if err := h.q.ConsumeEmailVerification(r.Context(), tx, row.ID); err != nil {
752 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
753 return
754 }
755 if err := tx.Commit(r.Context()); err != nil {
756 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
757 return
758 }
759
760 http.Redirect(w, r, "/login?notice=verified", http.StatusSeeOther)
761 }
762
763 func (h *Handlers) verifyResendForm(w http.ResponseWriter, r *http.Request) {
764 h.renderPage(w, r, "auth/verify_resend", map[string]any{
765 "Title": "Resend verification",
766 "CSRFToken": middleware.CSRFTokenForRequest(r),
767 })
768 }
769
770 func (h *Handlers) verifyResendSubmit(w http.ResponseWriter, r *http.Request) {
771 if err := r.ParseForm(); err != nil {
772 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
773 return
774 }
775 addr := strings.ToLower(strings.TrimSpace(r.PostFormValue("email")))
776 notice := "If a pending account is registered to that address, we've sent a fresh verification link."
777 render := func() {
778 h.renderPage(w, r, "auth/verify_resend", map[string]any{
779 "Title": "Resend verification",
780 "CSRFToken": middleware.CSRFTokenForRequest(r),
781 "Notice": notice,
782 })
783 }
784 if !looksLikeEmail(addr) {
785 render()
786 return
787 }
788 em, err := h.q.GetUserEmailByAddress(r.Context(), h.d.Pool, addr)
789 if err != nil || em.Verified {
790 render()
791 return
792 }
793 tokEnc, tokHash, err := token.New()
794 if err != nil {
795 render()
796 return
797 }
798 expires := pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true}
799 tx, err := h.d.Pool.Begin(r.Context())
800 if err != nil {
801 render()
802 return
803 }
804 defer func() { _ = tx.Rollback(r.Context()) }()
805 if err := h.q.SetVerificationToken(r.Context(), tx, usersdb.SetVerificationTokenParams{
806 ID: em.ID,
807 VerificationTokenHash: tokHash,
808 }); err != nil {
809 render()
810 return
811 }
812 if _, err := h.q.CreateEmailVerification(r.Context(), tx, usersdb.CreateEmailVerificationParams{
813 UserEmailID: em.ID, TokenHash: tokHash, ExpiresAt: expires,
814 }); err != nil {
815 render()
816 return
817 }
818 if err := tx.Commit(r.Context()); err != nil {
819 render()
820 return
821 }
822
823 user, err := h.q.GetUserByID(r.Context(), h.d.Pool, em.UserID)
824 if err == nil {
825 msg, err := email.VerifyMessage(h.d.Branding, addr, user.Username, tokEnc)
826 if err == nil {
827 _ = h.d.Email.Send(r.Context(), msg)
828 }
829 }
830 render()
831 }
832
833 // ----------------------------- helpers ---------------------------------
834
835 func (h *Handlers) renderPage(w http.ResponseWriter, r *http.Request, page string, data any) {
836 w.Header().Set("Content-Type", "text/html; charset=utf-8")
837 if err := h.d.Render.RenderPage(w, r, page, data); err != nil {
838 h.d.Logger.ErrorContext(r.Context(), "render", "page", page, "error", err)
839 }
840 }
841
842 func (h *Handlers) throttleSignup(r *http.Request) error {
843 // Layer 1 — per-IP cap (S05). Tight per-host throttle so a single
844 // machine can't spin up dozens of accounts in an hour.
845 if err := h.d.Limiter.Hit(r.Context(), h.d.Pool, throttle.Limit{
846 Scope: "signup", Identifier: "ip:" + clientIP(r),
847 Max: 5, Window: time.Hour,
848 }); err != nil {
849 return err
850 }
851 // Layer 2 — per-/24 cap (S35 anti-abuse). Catches spray-from-many-
852 // IPs-on-the-same-network patterns. The threshold is intentionally
853 // looser (20/hour) than the per-IP cap so legitimate shared-NAT
854 // users (universities, corporate networks) aren't false-positives.
855 // nil RateLimiter skips this layer — the single-IP throttle still
856 // applies.
857 if h.d.RateLimiter != nil {
858 if ip, ok := ratelimit.ClientIP(r, false); ok {
859 d, err := h.d.RateLimiter.AllowSignupIP(r.Context(), ip, 20, time.Hour)
860 if err == nil && !d.Allowed {
861 return &throttle.ErrThrottled{RetryAfter: d.RetryAfter, Hits: d.Limit + 1}
862 }
863 }
864 }
865 return nil
866 }
867
868 func (h *Handlers) writeRetryAfter(w http.ResponseWriter, err error) {
869 var t *throttle.ErrThrottled
870 if errors.As(err, &t) {
871 // Content-Type must land BEFORE WriteHeader: callers follow
872 // this with a Render() that writes a fully-formed HTML body.
873 // Without this, the browser sees status 429 with no Content-Type
874 // and falls back to text/plain — rendering the page source as
875 // literal text instead of HTML.
876 w.Header().Set("Retry-After", strconv.Itoa(int(t.RetryAfter.Seconds())))
877 w.Header().Set("Content-Type", "text/html; charset=utf-8")
878 w.WriteHeader(http.StatusTooManyRequests)
879 }
880 }
881
882 func (h *Handlers) lookupValidReset(ctx context.Context, encoded string) (usersdb.PasswordReset, error) {
883 hash, err := token.HashOf(encoded)
884 if err != nil {
885 return usersdb.PasswordReset{}, err
886 }
887 row, err := h.q.GetPasswordResetByTokenHash(ctx, h.d.Pool, hash)
888 if err != nil {
889 return usersdb.PasswordReset{}, err
890 }
891 if row.UsedAt.Valid || time.Now().After(row.ExpiresAt.Time) {
892 return usersdb.PasswordReset{}, errors.New("token expired or used")
893 }
894 return row, nil
895 }
896
897 // validateUsername returns a user-facing error string (suitable for
898 // rendering in the form's flash slot) or "" when the name is acceptable.
899 // Note: returns string, not error, because callers display these to the
900 // end user — staticcheck's ST1005 rule for error capitalization doesn't
901 // apply to UI copy.
902 func validateUsername(name string) string {
903 if name == "" {
904 return "Username is required."
905 }
906 if len(name) > 39 {
907 return "Username may be at most 39 characters."
908 }
909 if !usernameRE.MatchString(name) {
910 return "Username may contain only lowercase letters, digits, and hyphens, and cannot start or end with a hyphen."
911 }
912 if authpkg.IsReserved(name) {
913 return "That username is reserved. Please choose another."
914 }
915 return ""
916 }
917
918 func looksLikeEmail(s string) bool {
919 if len(s) < 3 || len(s) > 254 {
920 return false
921 }
922 at := strings.IndexByte(s, '@')
923 if at <= 0 || at == len(s)-1 {
924 return false
925 }
926 if strings.IndexByte(s, ' ') >= 0 {
927 return false
928 }
929 return true
930 }
931
932 // hashPassword wraps password.Hash so callers don't need to import the
933 // stdlib package alongside the local one.
934 func hashPassword(pw string, p password.Params) (string, error) {
935 return password.Hash(pw, p)
936 }
937
938 func clientIP(r *http.Request) string {
939 if ip := middleware.RealIPFromContext(r.Context(), r); ip != "" {
940 return ip
941 }
942 if h, _, err := splitHostPort(r.RemoteAddr); err == nil {
943 return h
944 }
945 return r.RemoteAddr
946 }
947
948 func splitHostPort(addr string) (string, string, error) {
949 i := strings.LastIndexByte(addr, ':')
950 if i < 0 {
951 return addr, "", nil
952 }
953 return addr[:i], addr[i+1:], nil
954 }
955
956 // isUniqueViolation matches Postgres SQLSTATE 23505. We use an interface
957 // shim so the auth package doesn't import pgconn directly.
958 func isUniqueViolation(err error) bool {
959 type sqlStater interface {
960 SQLState() string
961 }
962 if errors.Is(err, pgx.ErrNoRows) {
963 return false
964 }
965 for cur := err; cur != nil; cur = errors.Unwrap(cur) {
966 if s, ok := cur.(sqlStater); ok && s.SQLState() == "23505" {
967 return true
968 }
969 }
970 return false
971 }
972