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