From 60196d95103803f62fe0213958cba48e574a5fca Mon Sep 17 00:00:00 2001 From: Devin Hadley <68879608+devinhadley@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:53:37 -0700 Subject: [PATCH 1/2] feat(user): add email reset request DB layer and service logic Adds the email_reset_requests relation (SHA-256 token, user_id, pending new_email) plus CreateEmailResetRequest/ConsumeEmailResetRequest/UpdateEmail queries, and implements CreateEmailResetRequest/ResetEmailFromResetRequest in the user service, mirroring the existing password-reset flow. Also generalizes the tiered auth-attempt rate-limit query (CountTieredAuthAttempts) so it can be reused across actions. Co-Authored-By: Claude Sonnet 5 --- internal/db/auth_attempts.sql.go | 62 ++++--- internal/db/email_reset_requests.sql.go | 53 ++++++ internal/db/models.go | 7 + internal/db/users.sql.go | 16 ++ internal/integration/helper_test.go | 2 +- internal/service/user/service.go | 167 ++++++++++++++++-- internal/service/user/user_test.go | 99 +++++++---- .../20260705234436_email_reset_request.sql | 11 ++ sqlc/query/auth_attempts.sql | 6 +- sqlc/query/email_reset_requests.sql | 10 ++ sqlc/query/users.sql | 5 + 11 files changed, 356 insertions(+), 82 deletions(-) create mode 100644 internal/db/email_reset_requests.sql.go create mode 100644 sqlc/migrations/20260705234436_email_reset_request.sql create mode 100644 sqlc/query/email_reset_requests.sql diff --git a/internal/db/auth_attempts.sql.go b/internal/db/auth_attempts.sql.go index e3f9504..0c26081 100644 --- a/internal/db/auth_attempts.sql.go +++ b/internal/db/auth_attempts.sql.go @@ -11,34 +11,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const countAuthAttemptsForPassResetReq = `-- name: CountAuthAttemptsForPassResetReq :one -SELECT - COUNT(*) AS old_count, - COUNT(*) FILTER (WHERE created_at >= ($1)) AS recent_count -FROM auth_attempts -WHERE action = 'password_reset' - AND email = $2 - AND created_at >= $3 -` - -type CountAuthAttemptsForPassResetReqParams struct { - RecentDate pgtype.Timestamptz - Email string - OldDate pgtype.Timestamptz -} - -type CountAuthAttemptsForPassResetReqRow struct { - OldCount int64 - RecentCount int64 -} - -func (q *Queries) CountAuthAttemptsForPassResetReq(ctx context.Context, arg CountAuthAttemptsForPassResetReqParams) (CountAuthAttemptsForPassResetReqRow, error) { - row := q.db.QueryRow(ctx, countAuthAttemptsForPassResetReq, arg.RecentDate, arg.Email, arg.OldDate) - var i CountAuthAttemptsForPassResetReqRow - err := row.Scan(&i.OldCount, &i.RecentCount) - return i, err -} - const countFailedAuthAttemptsSince = `-- name: CountFailedAuthAttemptsSince :one SELECT COUNT(*) FROM auth_attempts @@ -61,6 +33,40 @@ func (q *Queries) CountFailedAuthAttemptsSince(ctx context.Context, arg CountFai return count, err } +const countTieredAuthAttempts = `-- name: CountTieredAuthAttempts :one +SELECT + COUNT(*) AS old_count, + COUNT(*) FILTER (WHERE created_at >= ($1)) AS recent_count +FROM auth_attempts +WHERE action = $2 + AND email = $3 + AND created_at >= $4 +` + +type CountTieredAuthAttemptsParams struct { + RecentDate pgtype.Timestamptz + Action AuthAction + Email string + OldDate pgtype.Timestamptz +} + +type CountTieredAuthAttemptsRow struct { + OldCount int64 + RecentCount int64 +} + +func (q *Queries) CountTieredAuthAttempts(ctx context.Context, arg CountTieredAuthAttemptsParams) (CountTieredAuthAttemptsRow, error) { + row := q.db.QueryRow(ctx, countTieredAuthAttempts, + arg.RecentDate, + arg.Action, + arg.Email, + arg.OldDate, + ) + var i CountTieredAuthAttemptsRow + err := row.Scan(&i.OldCount, &i.RecentCount) + return i, err +} + const createLoginAuthAttempt = `-- name: CreateLoginAuthAttempt :exec INSERT INTO auth_attempts ( action, email, outcome diff --git a/internal/db/email_reset_requests.sql.go b/internal/db/email_reset_requests.sql.go new file mode 100644 index 0000000..6e4edfe --- /dev/null +++ b/internal/db/email_reset_requests.sql.go @@ -0,0 +1,53 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: email_reset_requests.sql + +package db + +import ( + "context" +) + +const consumeEmailResetRequest = `-- name: ConsumeEmailResetRequest :one +DELETE FROM email_reset_requests +WHERE id = $1 +RETURNING id, user_id, new_email, created_at +` + +func (q *Queries) ConsumeEmailResetRequest(ctx context.Context, id []byte) (EmailResetRequest, error) { + row := q.db.QueryRow(ctx, consumeEmailResetRequest, id) + var i EmailResetRequest + err := row.Scan( + &i.ID, + &i.UserID, + &i.NewEmail, + &i.CreatedAt, + ) + return i, err +} + +const createEmailResetRequest = `-- name: CreateEmailResetRequest :one +INSERT INTO email_reset_requests ( + id, user_id, new_email +) VALUES ( $1, $2, $3 ) +RETURNING id, user_id, new_email, created_at +` + +type CreateEmailResetRequestParams struct { + ID []byte + UserID int64 + NewEmail string +} + +func (q *Queries) CreateEmailResetRequest(ctx context.Context, arg CreateEmailResetRequestParams) (EmailResetRequest, error) { + row := q.db.QueryRow(ctx, createEmailResetRequest, arg.ID, arg.UserID, arg.NewEmail) + var i EmailResetRequest + err := row.Scan( + &i.ID, + &i.UserID, + &i.NewEmail, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/db/models.go b/internal/db/models.go index bfc7789..6869351 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -104,6 +104,13 @@ type AuthAttempt struct { Outcome AuthOutcome } +type EmailResetRequest struct { + ID []byte + UserID int64 + NewEmail string + CreatedAt pgtype.Timestamptz +} + type PasswordResetRequest struct { ID []byte UserID int64 diff --git a/internal/db/users.sql.go b/internal/db/users.sql.go index 8affe10..90a1e8c 100644 --- a/internal/db/users.sql.go +++ b/internal/db/users.sql.go @@ -76,6 +76,22 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { return i, err } +const updateEmail = `-- name: UpdateEmail :exec +UPDATE users +SET email = $2 +WHERE id = $1 +` + +type UpdateEmailParams struct { + ID int64 + Email string +} + +func (q *Queries) UpdateEmail(ctx context.Context, arg UpdateEmailParams) error { + _, err := q.db.Exec(ctx, updateEmail, arg.ID, arg.Email) + return err +} + const updatePasswordHash = `-- name: UpdatePasswordHash :exec UPDATE users SET password_hash = $2 diff --git a/internal/integration/helper_test.go b/internal/integration/helper_test.go index 88a195b..f6a920d 100644 --- a/internal/integration/helper_test.go +++ b/internal/integration/helper_test.go @@ -43,7 +43,7 @@ func getIntegrationTestPool(t testing.TB) *pgxpool.Pool { func cleanupIntegrationTables(t testing.TB, pool *pgxpool.Pool) { t.Helper() - _, err := pool.Exec(context.Background(), "TRUNCATE TABLE sessions, users, auth_attempts, password_reset_requests RESTART IDENTITY") + _, err := pool.Exec(context.Background(), "TRUNCATE TABLE sessions, users, auth_attempts, password_reset_requests, email_reset_requests RESTART IDENTITY") if err != nil { t.Fatalf("failed to clean integration tables: %v", err) } diff --git a/internal/service/user/service.go b/internal/service/user/service.go index a1dc8f6..c15b4c8 100644 --- a/internal/service/user/service.go +++ b/internal/service/user/service.go @@ -40,13 +40,14 @@ var ( ) const ( - rateLimitLoginDurationMinutes = 10 - rateLimitLoginAttemptsAllowed = 10 - rateLimitPasswordResetShortDurationMinutes = 15 - rateLimitPasswordResetLongDurationMinutes = 120 - rateLimitPasswordResetShortAllowed = 2 - rateLimitPasswordResetLongAllowed = 3 - passwordResetTokenDurationMinutes = 15 + rateLimitLoginDurationMinutes = 10 + rateLimitLoginAttemptsAllowed = 10 + rateLimitTieredShortDurationMinutes = 15 + rateLimitTieredLongDurationMinutes = 120 + rateLimitTieredShortAllowed = 2 + rateLimitTieredLongAllowed = 3 + passwordResetTokenDurationMinutes = 15 + emailResetTokenDurationMinutes = 15 ) type UserQueries interface { @@ -54,11 +55,14 @@ type UserQueries interface { GetUserByEmail(ctx context.Context, email string) (db.User, error) GetUserByID(ctx context.Context, id int64) (db.User, error) CountFailedAuthAttemptsSince(ctx context.Context, arg db.CountFailedAuthAttemptsSinceParams) (int64, error) - CountAuthAttemptsForPassResetReq(ctx context.Context, arg db.CountAuthAttemptsForPassResetReqParams) (db.CountAuthAttemptsForPassResetReqRow, error) + CountTieredAuthAttempts(ctx context.Context, arg db.CountTieredAuthAttemptsParams) (db.CountTieredAuthAttemptsRow, error) CreateLoginAuthAttempt(ctx context.Context, arg db.CreateLoginAuthAttemptParams) error CreatePasswordResetRequest(ctx context.Context, arg db.CreatePasswordResetRequestParams) (db.PasswordResetRequest, error) ConsumePasswordResetRequest(ctx context.Context, id []byte) (db.PasswordResetRequest, error) UpdatePasswordHash(ctx context.Context, arg db.UpdatePasswordHashParams) error + CreateEmailResetRequest(ctx context.Context, arg db.CreateEmailResetRequestParams) (db.EmailResetRequest, error) + ConsumeEmailResetRequest(ctx context.Context, id []byte) (db.EmailResetRequest, error) + UpdateEmail(ctx context.Context, arg db.UpdateEmailParams) error } type sessionDeactivator interface { @@ -92,14 +96,23 @@ type ResetPasswordFromResetRequestBody struct { NewPassword string `json:"newPassword"` } +type CreateEmailResetRequestBody struct { + Password string `json:"password"` + NewEmail string `json:"newEmail"` +} + type Config struct { PasswordResetURL string + EmailResetURL string } func NewService(queries UserQueries, runWithTx RunUserQueriesInTxFn, emailService email.Service, sessionService sessionDeactivator, config Config) *Service { if len(config.PasswordResetURL) > 0 && !strings.HasSuffix(config.PasswordResetURL, "/") { config.PasswordResetURL += "/" } + if len(config.EmailResetURL) > 0 && !strings.HasSuffix(config.EmailResetURL, "/") { + config.EmailResetURL += "/" + } return &Service{queries: queries, runWithTx: runWithTx, emailService: emailService, sessionService: sessionService, commonPasswords: getCommonPasswords(), config: config} } @@ -208,6 +221,7 @@ func (s *Service) LogIn(ctx context.Context, input AuthenticateBody) (User, erro } func (s *Service) ResetPasswordForAuthenticatedUser(ctx context.Context, usr User, input AuthenticatedPasswordResetBody) error { + // NOTE: No rate-limiting here. An authenticated session could be used to brute-force. err := s.isValidPassword(input.NewPassword) if err != nil { return err @@ -253,7 +267,7 @@ func (s *Service) CreatePasswordResetRequest(ctx context.Context, reqBody Create return ErrInvalidEmail } - isRateLimited, err := s.isPasswordResetReqRateLimited(ctx, email) + isRateLimited, err := s.isTieredRateLimited(ctx, db.AuthActionPasswordReset, email) if err != nil { return fmt.Errorf("checking if password reset request rate limited: %w", err) } @@ -365,6 +379,130 @@ func (s *Service) ResetPasswordFromResetRequest(ctx context.Context, token strin return nil } +func (s *Service) CreateEmailResetRequest(ctx context.Context, usr User, input CreateEmailResetRequestBody) error { + newEmail, ok := normalizeAndValidateEmail(input.NewEmail) + if !ok { + return ErrInvalidEmail + } + + currentEmail := usr.DBUser().Email + + // TODO: Rate limit internal auth attempts. + ok, err := argon2.VerifyEncoded([]byte(input.Password), []byte(usr.DBUser().PasswordHash)) + if err != nil { + return fmt.Errorf("validating password hash: %w", err) + } + if !ok { + err = s.createAuthAttempt(ctx, db.AuthActionEmailReset, currentEmail, db.AuthOutcomeFailed) + if err != nil { + log.Printf("creating auth attempt for email reset request: %v", err) + } + return ErrInvalidCredentials + } + + if newEmail == currentEmail { + return ErrEmailTaken + } + + _, err = s.queries.GetUserByEmail(ctx, newEmail) + if err == nil { + return ErrEmailTaken + } + if !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("checking if new email is already in use: %w", err) + } + + resetToken := make([]byte, 16) + _, err = rand.Read(resetToken) + if err != nil { + return fmt.Errorf("generating random bytes for email reset token: %w", err) + } + + sum := sha256.Sum256(resetToken) + _, err = s.queries.CreateEmailResetRequest(ctx, db.CreateEmailResetRequestParams{ + ID: sum[:], + UserID: usr.DBUser().ID, + NewEmail: newEmail, + }) + if err != nil { + return fmt.Errorf("creating email reset request: %w", err) + } + + encodedToken := base64.RawURLEncoding.EncodeToString(resetToken) + urlWithToken := fmt.Sprintf("%v?token=%v", s.config.EmailResetURL, encodedToken) + err = s.emailService.SendMail(newEmail, "Email Reset", urlWithToken) + if err != nil { + return fmt.Errorf("failed to send email reset email: %w", err) + } + + err = s.emailService.SendMail(currentEmail, "Email Change Requested", "A change to your account email was requested. If this wasn't you, please secure your account.") + if err != nil { + log.Printf("failed to send email reset notification to old address: %v", err) + } + + err = s.createAuthAttempt(ctx, db.AuthActionEmailReset, currentEmail, db.AuthOutcomeSucceeded) + if err != nil { + log.Printf("creating auth attempt for email reset request: %v", err) + } + + return nil +} + +func (s *Service) ResetEmailFromResetRequest(ctx context.Context, token string) error { + resetToken, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return ErrInvalidResetToken + } + + sum := sha256.Sum256(resetToken) + + var userID int64 + err = s.runWithTx(ctx, func(qWithTx UserQueries) error { + resetRequest, err := qWithTx.ConsumeEmailResetRequest(ctx, sum[:]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrInvalidResetToken + } + + return fmt.Errorf("consuming email reset request: %w", err) + } + + expiresAt := resetRequest.CreatedAt.Time.Add(emailResetTokenDurationMinutes * time.Minute) + if time.Now().After(expiresAt) { + // TODO: Cleanup expired tokens... + // Txn aborts so wont be deleted. + return ErrInvalidResetToken + } + + err = qWithTx.UpdateEmail(ctx, db.UpdateEmailParams{ + ID: resetRequest.UserID, + Email: resetRequest.NewEmail, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "users_email_key" { + return ErrEmailTaken + } + + return fmt.Errorf("updating email during reset from token: %w", err) + } + + userID = resetRequest.UserID + + return nil + }) + if err != nil { + return err + } + + err = s.sessionService.DeactivateAllSessionsForUser(ctx, userID) + if err != nil { + log.Printf("deactivating all sessions during email reset from token: %v", err) + } + + return nil +} + func (s *Service) GetUserByID(ctx context.Context, id int64) (User, error) { user, err := s.queries.GetUserByID(ctx, id) if err != nil { @@ -426,16 +564,17 @@ func (s *Service) isLoginRateLimited(ctx context.Context, email string) (bool, e return loginAttemptsForEmail >= rateLimitLoginAttemptsAllowed, nil } -func (s *Service) isPasswordResetReqRateLimited(ctx context.Context, email string) (bool, error) { +func (s *Service) isTieredRateLimited(ctx context.Context, action db.AuthAction, email string) (bool, error) { now := time.Now() - count, err := s.queries.CountAuthAttemptsForPassResetReq(ctx, db.CountAuthAttemptsForPassResetReqParams{ + count, err := s.queries.CountTieredAuthAttempts(ctx, db.CountTieredAuthAttemptsParams{ + Action: action, RecentDate: pgtype.Timestamptz{ - Time: now.Add(-(rateLimitPasswordResetShortDurationMinutes * time.Minute)), + Time: now.Add(-(rateLimitTieredShortDurationMinutes * time.Minute)), Valid: true, }, OldDate: pgtype.Timestamptz{ - Time: now.Add(-(rateLimitPasswordResetLongDurationMinutes * time.Minute)), + Time: now.Add(-(rateLimitTieredLongDurationMinutes * time.Minute)), Valid: true, }, Email: email, @@ -444,7 +583,7 @@ func (s *Service) isPasswordResetReqRateLimited(ctx context.Context, email strin return false, err } - return count.RecentCount >= rateLimitPasswordResetShortAllowed || count.OldCount >= rateLimitPasswordResetLongAllowed, nil + return count.RecentCount >= rateLimitTieredShortAllowed || count.OldCount >= rateLimitTieredLongAllowed, nil } func (s *Service) createAuthAttempt(ctx context.Context, action db.AuthAction, email string, outcome db.AuthOutcome) error { diff --git a/internal/service/user/user_test.go b/internal/service/user/user_test.go index 7581d7c..eb715f3 100644 --- a/internal/service/user/user_test.go +++ b/internal/service/user/user_test.go @@ -822,15 +822,15 @@ func testCanRequestPasswordReset(t *testing.T) { passwordResetURL := "http://example.com/password-reset" userService := setupUserServiceWithEmail(t, mockQueries{ - CountAuthAttemptsForPassResetReqFn: func(callCtx context.Context, arg db.CountAuthAttemptsForPassResetReqParams) (db.CountAuthAttemptsForPassResetReqRow, error) { + CountTieredAuthAttemptsFn: func(callCtx context.Context, arg db.CountTieredAuthAttemptsParams) (db.CountTieredAuthAttemptsRow, error) { if callCtx != ctx { - t.Fatal("CountAuthAttemptsForPassResetReq called with unexpected context") + t.Fatal("CountTieredAuthAttempts called with unexpected context") } if arg.Email != normalizedEmail { - t.Fatalf("CountAuthAttemptsForPassResetReq got email %q, want %q", arg.Email, normalizedEmail) + t.Fatalf("CountTieredAuthAttempts got email %q, want %q", arg.Email, normalizedEmail) } countChecked = true - return db.CountAuthAttemptsForPassResetReqRow{}, nil + return db.CountTieredAuthAttemptsRow{}, nil }, GetUserByEmailFn: func(callCtx context.Context, email string) (db.User, error) { if callCtx != ctx { @@ -898,7 +898,7 @@ func testCanRequestPasswordReset(t *testing.T) { } if !countChecked { - t.Fatal("CountAuthAttemptsForPassResetReq was not called") + t.Fatal("CountTieredAuthAttempts was not called") } if !gotUser { t.Fatal("GetUserByEmail was not called") @@ -918,9 +918,9 @@ func testCantCreatePasswordResetWithMalformedEmail(t *testing.T) { ctx := context.Background() userService := setupUserServiceWithEmail(t, mockQueries{ - CountAuthAttemptsForPassResetReqFn: func(context.Context, db.CountAuthAttemptsForPassResetReqParams) (db.CountAuthAttemptsForPassResetReqRow, error) { - t.Fatal("CountAuthAttemptsForPassResetReq should not be called for malformed email") - return db.CountAuthAttemptsForPassResetReqRow{}, nil + CountTieredAuthAttemptsFn: func(context.Context, db.CountTieredAuthAttemptsParams) (db.CountTieredAuthAttemptsRow, error) { + t.Fatal("CountTieredAuthAttempts should not be called for malformed email") + return db.CountTieredAuthAttemptsRow{}, nil }, GetUserByEmailFn: func(context.Context, string) (db.User, error) { t.Fatal("GetUserByEmail should not be called for malformed email") @@ -953,15 +953,15 @@ func testCantRequestMoreThanThreePasswordResetsIn120Minutes(t *testing.T) { rateLimitChecked := false userService := setupUserServiceWithEmail(t, mockQueries{ - CountAuthAttemptsForPassResetReqFn: func(callCtx context.Context, arg db.CountAuthAttemptsForPassResetReqParams) (db.CountAuthAttemptsForPassResetReqRow, error) { + CountTieredAuthAttemptsFn: func(callCtx context.Context, arg db.CountTieredAuthAttemptsParams) (db.CountTieredAuthAttemptsRow, error) { if callCtx != ctx { - t.Fatal("CountAuthAttemptsForPassResetReq called with unexpected context") + t.Fatal("CountTieredAuthAttempts called with unexpected context") } if arg.Email != inputEmail { - t.Fatalf("CountAuthAttemptsForPassResetReq got email %q, want %q", arg.Email, inputEmail) + t.Fatalf("CountTieredAuthAttempts got email %q, want %q", arg.Email, inputEmail) } rateLimitChecked = true - return db.CountAuthAttemptsForPassResetReqRow{OldCount: 3, RecentCount: 0}, nil + return db.CountTieredAuthAttemptsRow{OldCount: 3, RecentCount: 0}, nil }, GetUserByEmailFn: func(context.Context, string) (db.User, error) { t.Fatal("GetUserByEmail should not be called for rate limited password reset request") @@ -988,7 +988,7 @@ func testCantRequestMoreThanThreePasswordResetsIn120Minutes(t *testing.T) { } if !rateLimitChecked { - t.Fatal("CountAuthAttemptsForPassResetReq was not called") + t.Fatal("CountTieredAuthAttempts was not called") } } @@ -998,15 +998,15 @@ func testRequestingTokenPasswordResetForUnknownEmail(t *testing.T) { rateLimitChecked := false userService := setupUserServiceWithEmail(t, mockQueries{ - CountAuthAttemptsForPassResetReqFn: func(callCtx context.Context, arg db.CountAuthAttemptsForPassResetReqParams) (db.CountAuthAttemptsForPassResetReqRow, error) { + CountTieredAuthAttemptsFn: func(callCtx context.Context, arg db.CountTieredAuthAttemptsParams) (db.CountTieredAuthAttemptsRow, error) { if callCtx != ctx { - t.Fatal("CountAuthAttemptsForPassResetReq called with unexpected context") + t.Fatal("CountTieredAuthAttempts called with unexpected context") } if arg.Email != inputEmail { - t.Fatalf("CountAuthAttemptsForPassResetReq got email %q, want %q", arg.Email, inputEmail) + t.Fatalf("CountTieredAuthAttempts got email %q, want %q", arg.Email, inputEmail) } rateLimitChecked = true - return db.CountAuthAttemptsForPassResetReqRow{}, nil + return db.CountTieredAuthAttemptsRow{}, nil }, GetUserByEmailFn: func(callCtx context.Context, email string) (db.User, error) { if callCtx != ctx { @@ -1053,7 +1053,7 @@ func testRequestingTokenPasswordResetForUnknownEmail(t *testing.T) { } if !rateLimitChecked { - t.Fatal("CountAuthAttemptsForPassResetReq was not called") + t.Fatal("CountTieredAuthAttempts was not called") } } @@ -1063,15 +1063,15 @@ func testCantRequestMoreThanTwoPasswordResetsIn15Minutes(t *testing.T) { rateLimitChecked := false userService := setupUserServiceWithEmail(t, mockQueries{ - CountAuthAttemptsForPassResetReqFn: func(callCtx context.Context, arg db.CountAuthAttemptsForPassResetReqParams) (db.CountAuthAttemptsForPassResetReqRow, error) { + CountTieredAuthAttemptsFn: func(callCtx context.Context, arg db.CountTieredAuthAttemptsParams) (db.CountTieredAuthAttemptsRow, error) { if callCtx != ctx { - t.Fatal("CountAuthAttemptsForPassResetReq called with unexpected context") + t.Fatal("CountTieredAuthAttempts called with unexpected context") } if arg.Email != inputEmail { - t.Fatalf("CountAuthAttemptsForPassResetReq got email %q, want %q", arg.Email, inputEmail) + t.Fatalf("CountTieredAuthAttempts got email %q, want %q", arg.Email, inputEmail) } rateLimitChecked = true - return db.CountAuthAttemptsForPassResetReqRow{OldCount: 0, RecentCount: 2}, nil + return db.CountTieredAuthAttemptsRow{OldCount: 0, RecentCount: 2}, nil }, GetUserByEmailFn: func(context.Context, string) (db.User, error) { t.Fatal("GetUserByEmail should not be called for rate limited password reset request") @@ -1098,7 +1098,7 @@ func testCantRequestMoreThanTwoPasswordResetsIn15Minutes(t *testing.T) { } if !rateLimitChecked { - t.Fatal("CountAuthAttemptsForPassResetReq was not called") + t.Fatal("CountTieredAuthAttempts was not called") } } @@ -1305,15 +1305,18 @@ func needsImplemented(t *testing.T) { } type mockQueries struct { - CreateUserFn func(ctx context.Context, arg db.CreateUserParams) (db.User, error) - GetUserByEmailFn func(ctx context.Context, email string) (db.User, error) - GetUserByIDFn func(ctx context.Context, id int64) (db.User, error) - CountFailedAuthAttemptsSinceFn func(ctx context.Context, arg db.CountFailedAuthAttemptsSinceParams) (int64, error) - CountAuthAttemptsForPassResetReqFn func(ctx context.Context, arg db.CountAuthAttemptsForPassResetReqParams) (db.CountAuthAttemptsForPassResetReqRow, error) - CreateLoginAuthAttemptFn func(ctx context.Context, arg db.CreateLoginAuthAttemptParams) error - CreatePasswordResetRequestFn func(ctx context.Context, arg db.CreatePasswordResetRequestParams) (db.PasswordResetRequest, error) - ConsumePasswordResetRequestFn func(ctx context.Context, id []byte) (db.PasswordResetRequest, error) - UpdatePasswordHashFn func(ctx context.Context, arg db.UpdatePasswordHashParams) error + CreateUserFn func(ctx context.Context, arg db.CreateUserParams) (db.User, error) + GetUserByEmailFn func(ctx context.Context, email string) (db.User, error) + GetUserByIDFn func(ctx context.Context, id int64) (db.User, error) + CountFailedAuthAttemptsSinceFn func(ctx context.Context, arg db.CountFailedAuthAttemptsSinceParams) (int64, error) + CountTieredAuthAttemptsFn func(ctx context.Context, arg db.CountTieredAuthAttemptsParams) (db.CountTieredAuthAttemptsRow, error) + CreateLoginAuthAttemptFn func(ctx context.Context, arg db.CreateLoginAuthAttemptParams) error + CreatePasswordResetRequestFn func(ctx context.Context, arg db.CreatePasswordResetRequestParams) (db.PasswordResetRequest, error) + ConsumePasswordResetRequestFn func(ctx context.Context, id []byte) (db.PasswordResetRequest, error) + UpdatePasswordHashFn func(ctx context.Context, arg db.UpdatePasswordHashParams) error + CreateEmailResetRequestFn func(ctx context.Context, arg db.CreateEmailResetRequestParams) (db.EmailResetRequest, error) + ConsumeEmailResetRequestFn func(ctx context.Context, id []byte) (db.EmailResetRequest, error) + UpdateEmailFn func(ctx context.Context, arg db.UpdateEmailParams) error } func (q *mockQueries) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error) { @@ -1357,12 +1360,12 @@ func (q *mockQueries) CountFailedAuthAttemptsSince(ctx context.Context, arg db.C return 0, nil } -func (q *mockQueries) CountAuthAttemptsForPassResetReq(ctx context.Context, arg db.CountAuthAttemptsForPassResetReqParams) (db.CountAuthAttemptsForPassResetReqRow, error) { - if q.CountAuthAttemptsForPassResetReqFn != nil { - return q.CountAuthAttemptsForPassResetReqFn(ctx, arg) +func (q *mockQueries) CountTieredAuthAttempts(ctx context.Context, arg db.CountTieredAuthAttemptsParams) (db.CountTieredAuthAttemptsRow, error) { + if q.CountTieredAuthAttemptsFn != nil { + return q.CountTieredAuthAttemptsFn(ctx, arg) } - return db.CountAuthAttemptsForPassResetReqRow{}, nil + return db.CountTieredAuthAttemptsRow{}, nil } func (q *mockQueries) CreateLoginAuthAttempt(ctx context.Context, arg db.CreateLoginAuthAttemptParams) error { @@ -1396,3 +1399,27 @@ func (q *mockQueries) UpdatePasswordHash(ctx context.Context, arg db.UpdatePassw return nil } + +func (q *mockQueries) CreateEmailResetRequest(ctx context.Context, arg db.CreateEmailResetRequestParams) (db.EmailResetRequest, error) { + if q.CreateEmailResetRequestFn != nil { + return q.CreateEmailResetRequestFn(ctx, arg) + } + + return db.EmailResetRequest{ID: arg.ID, UserID: arg.UserID, NewEmail: arg.NewEmail}, nil +} + +func (q *mockQueries) ConsumeEmailResetRequest(ctx context.Context, id []byte) (db.EmailResetRequest, error) { + if q.ConsumeEmailResetRequestFn != nil { + return q.ConsumeEmailResetRequestFn(ctx, id) + } + + return db.EmailResetRequest{}, pgx.ErrNoRows +} + +func (q *mockQueries) UpdateEmail(ctx context.Context, arg db.UpdateEmailParams) error { + if q.UpdateEmailFn != nil { + return q.UpdateEmailFn(ctx, arg) + } + + return nil +} diff --git a/sqlc/migrations/20260705234436_email_reset_request.sql b/sqlc/migrations/20260705234436_email_reset_request.sql new file mode 100644 index 0000000..629dcd2 --- /dev/null +++ b/sqlc/migrations/20260705234436_email_reset_request.sql @@ -0,0 +1,11 @@ +-- +goose Up + +CREATE TABLE email_reset_requests ( + id bytea PRIMARY KEY CHECK (octet_length(id) = 32), -- SHA-256 of reset token + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + new_email CITEXT NOT NULL CHECK (char_length(new_email) BETWEEN 1 AND 320), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- +goose Down +DROP TABLE IF EXISTS email_reset_requests; diff --git a/sqlc/query/auth_attempts.sql b/sqlc/query/auth_attempts.sql index 41631fa..bcb097d 100644 --- a/sqlc/query/auth_attempts.sql +++ b/sqlc/query/auth_attempts.sql @@ -11,11 +11,11 @@ AND email = $2 AND created_at >= $3 AND outcome = 'failed'; --- name: CountAuthAttemptsForPassResetReq :one -SELECT +-- name: CountTieredAuthAttempts :one +SELECT COUNT(*) AS old_count, COUNT(*) FILTER (WHERE created_at >= (sqlc.arg(recent_date))) AS recent_count FROM auth_attempts -WHERE action = 'password_reset' +WHERE action = sqlc.arg(action) AND email = sqlc.arg(email) AND created_at >= sqlc.arg(old_date); -- long entries range will include short entries... diff --git a/sqlc/query/email_reset_requests.sql b/sqlc/query/email_reset_requests.sql new file mode 100644 index 0000000..e47d28a --- /dev/null +++ b/sqlc/query/email_reset_requests.sql @@ -0,0 +1,10 @@ +-- name: CreateEmailResetRequest :one +INSERT INTO email_reset_requests ( + id, user_id, new_email +) VALUES ( $1, $2, $3 ) +RETURNING *; + +-- name: ConsumeEmailResetRequest :one +DELETE FROM email_reset_requests +WHERE id = $1 +RETURNING *; diff --git a/sqlc/query/users.sql b/sqlc/query/users.sql index 38a9e1d..63e6e2d 100644 --- a/sqlc/query/users.sql +++ b/sqlc/query/users.sql @@ -22,3 +22,8 @@ WHERE id = $1; UPDATE users SET password_hash = $2 WHERE id = $1; + +-- name: UpdateEmail :exec +UPDATE users +SET email = $2 +WHERE id = $1; From 0c8ad1fb33a4f59ffafed67562dd7da43a715aa4 Mon Sep 17 00:00:00 2001 From: Devin Hadley <68879608+devinhadley@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:13:33 -0700 Subject: [PATCH 2/2] feat(handlers,server): add email reset HTTP endpoints Adds CreateEmailResetRequestHandler (authenticated, POST /email-reset) and CreateTokenEmailResetHandler (PUT /email-reset?token=...) mirroring the existing password-reset handler pattern, and wires them into the mux. Co-Authored-By: Claude Sonnet 5 --- internal/handlers/user.go | 93 +++++++++++++++++++++++++++++++++++++++ internal/server/server.go | 2 + 2 files changed, 95 insertions(+) diff --git a/internal/handlers/user.go b/internal/handlers/user.go index a5ae10c..2f9da8f 100644 --- a/internal/handlers/user.go +++ b/internal/handlers/user.go @@ -37,6 +37,14 @@ type tokenPasswordResetter interface { ResetPasswordFromResetRequest(ctx context.Context, token string, input user.ResetPasswordFromResetRequestBody) error } +type emailResetRequester interface { + CreateEmailResetRequest(ctx context.Context, usr user.User, input user.CreateEmailResetRequestBody) error +} + +type tokenEmailResetter interface { + ResetEmailFromResetRequest(ctx context.Context, token string) error +} + func CreateSignUpHandler(userService signUpper, sessionService sessionCreator) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var reqBody user.AuthenticateBody @@ -213,6 +221,58 @@ func CreateTokenPasswordResetHandler(userService tokenPasswordResetter) http.Han }) } +func CreateEmailResetRequestHandler(userService emailResetRequester) http.Handler { + return middleware.Requires( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody user.CreateEmailResetRequestBody + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + err := decoder.Decode(&reqBody) + if err != nil { + web.WriteJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"}) + return + } + + usr, err := middleware.UserFromContext(r.Context()) + if err != nil { + log.Printf("when getting user for email reset request: %v", err) + web.WriteAndReportInternalError(w) + return + } + + err = userService.CreateEmailResetRequest(r.Context(), usr, reqBody) + if err != nil { + if writeCreateEmailResetRequestError(w, err) { + return + } + + web.WriteAndReportInternalError(w) + return + } + + w.WriteHeader(http.StatusNoContent) + }), middleware.Authenticated) +} + +func CreateTokenEmailResetHandler(userService tokenEmailResetter) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + + err := userService.ResetEmailFromResetRequest(r.Context(), token) + if err != nil { + if writeTokenEmailResetError(w, err) { + return + } + + web.WriteAndReportInternalError(w) + return + } + + w.WriteHeader(http.StatusNoContent) + }) +} + func writeSignUpError(w http.ResponseWriter, err error) bool { if errors.Is(err, user.ErrEmailBlank) { web.WriteJSONResponse(w, http.StatusBadRequest, map[string]any{"email": "email may not be blank"}) @@ -305,6 +365,39 @@ func writeTokenPasswordResetError(w http.ResponseWriter, err error) bool { return false } +func writeCreateEmailResetRequestError(w http.ResponseWriter, err error) bool { + if errors.Is(err, user.ErrInvalidEmail) { + web.WriteJSONResponse(w, http.StatusBadRequest, map[string]any{"email": "email is not valid"}) + return true + } + + if errors.Is(err, user.ErrEmailTaken) { + web.WriteJSONResponse(w, http.StatusBadRequest, map[string]any{"email": "email already in use"}) + return true + } + + if errors.Is(err, user.ErrInvalidCredentials) { + web.WriteJSONResponse(w, http.StatusUnauthorized, map[string]any{"error": "authentication failed"}) + return true + } + + return false +} + +func writeTokenEmailResetError(w http.ResponseWriter, err error) bool { + if errors.Is(err, user.ErrInvalidResetToken) { + web.WriteJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid or expired reset token"}) + return true + } + + if errors.Is(err, user.ErrEmailTaken) { + web.WriteJSONResponse(w, http.StatusBadRequest, map[string]any{"email": "email already in use"}) + return true + } + + return false +} + func writeWeakPasswordError(w http.ResponseWriter, err error) bool { if errors.Is(err, user.ErrPasswordEmpty) { web.WriteJSONResponse(w, http.StatusBadRequest, map[string]any{"password": "password can't be empty"}) diff --git a/internal/server/server.go b/internal/server/server.go index dbabb8e..bbf7670 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,6 +18,8 @@ func NewMux(userService *user.Service, sessionService *session.Service) http.Han mux.Handle("PUT /user/password", handlers.CreateAuthenticatedPasswordResetHandler(userService)) mux.Handle("POST /password-reset", handlers.CreatePasswordResetRequestHandler(userService)) mux.Handle("PUT /password-reset", handlers.CreateTokenPasswordResetHandler(userService)) + mux.Handle("POST /email-reset", handlers.CreateEmailResetRequestHandler(userService)) + mux.Handle("PUT /email-reset", handlers.CreateTokenEmailResetHandler(userService)) return middleware.CreateSessionMiddleware(userService, sessionService, mux) }