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