From 7e4cacd151a8b1beb8ceb352016135b414c13597 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 21:44:51 -0400 Subject: [PATCH] feat(auth): make usernames and emails case-insensitive Login identifiers were compared case-sensitively, so "John" and "john" were distinct accounts and a user could not log in unless they matched the exact casing used at registration. Convert users.username and users.email to the citext type (migration 165). citext compares case-insensitively while preserving the originally stored casing for display, so the existing unique constraints become case-insensitive and `WHERE username = $1` / `email = $1` lookups match regardless of case with no change to the query code itself. Also add auth.NormalizeUsername/NormalizeEmail (trim-only; case preserved), applied at the repository chokepoints (Create, Update, GetByUsername, GetByEmail) and before validation in the create paths, so surrounding whitespace no longer defeats matching or creates lookalike accounts. Verified non-destructively against the dev DB: mixed-case lookups resolve to the same row, case-variant inserts are rejected by the unique constraint, and the down migration cleanly reverts to text. Co-Authored-By: Claude Opus 4.7 --- internal/api/handlers/admin.go | 3 ++ internal/api/handlers/auth.go | 6 +++ internal/auth/normalize.go | 17 +++++++ internal/auth/normalize_test.go | 46 +++++++++++++++++++ internal/auth/repository.go | 16 +++---- ...5_case_insensitive_username_email.down.sql | 6 +++ ...165_case_insensitive_username_email.up.sql | 9 ++++ 7 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 internal/auth/normalize.go create mode 100644 internal/auth/normalize_test.go create mode 100644 migrations/165_case_insensitive_username_email.down.sql create mode 100644 migrations/165_case_insensitive_username_email.up.sql diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 473e0dd2..e2449b2b 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -373,6 +373,9 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request) return } + req.Username = auth.NormalizeUsername(req.Username) + req.Email = auth.NormalizeEmail(req.Email) + if req.Username == "" || req.Email == "" || req.Password == "" || req.Role == "" { writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and role are required") return diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go index 42804740..78b985ad 100644 --- a/internal/api/handlers/auth.go +++ b/internal/api/handlers/auth.go @@ -214,6 +214,9 @@ func (h *AuthHandler) HandleSetup(w http.ResponseWriter, r *http.Request) { return } + req.Username = auth.NormalizeUsername(req.Username) + req.Email = auth.NormalizeEmail(req.Email) + if req.Username == "" || req.Email == "" || req.Password == "" { writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required") return @@ -447,6 +450,9 @@ func (h *AuthHandler) HandleSignup(w http.ResponseWriter, r *http.Request) { return } + req.Username = auth.NormalizeUsername(req.Username) + req.Email = auth.NormalizeEmail(req.Email) + if req.Username == "" || req.Email == "" || req.Password == "" || req.InviteCode == "" { writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and invite code are required") return diff --git a/internal/auth/normalize.go b/internal/auth/normalize.go new file mode 100644 index 00000000..4fed2482 --- /dev/null +++ b/internal/auth/normalize.go @@ -0,0 +1,17 @@ +package auth + +import "strings" + +// NormalizeUsername canonicalizes a username for storage and lookup by trimming +// surrounding whitespace. Case is preserved for display; case-insensitive +// matching is enforced by the citext column type in the database. +func NormalizeUsername(username string) string { + return strings.TrimSpace(username) +} + +// NormalizeEmail canonicalizes an email for storage and lookup by trimming +// surrounding whitespace. Case is preserved (not lowercased); case-insensitive +// matching is enforced by the citext column type in the database. +func NormalizeEmail(email string) string { + return strings.TrimSpace(email) +} diff --git a/internal/auth/normalize_test.go b/internal/auth/normalize_test.go new file mode 100644 index 00000000..b1eaa834 --- /dev/null +++ b/internal/auth/normalize_test.go @@ -0,0 +1,46 @@ +package auth + +import "testing" + +func TestNormalizeUsername(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"trims surrounding spaces", " john ", "john"}, + {"trims tabs and newlines", "\t john\n", "john"}, + {"preserves internal spacing", "john doe", "john doe"}, + {"preserves case", "JohnDoe", "JohnDoe"}, + {"whitespace only becomes empty", " ", ""}, + {"already clean is unchanged", "john", "john"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := NormalizeUsername(tc.in); got != tc.want { + t.Errorf("NormalizeUsername(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizeEmail(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"trims surrounding spaces", " user@example.com ", "user@example.com"}, + {"trims tabs and newlines", "\tuser@example.com\n", "user@example.com"}, + {"preserves case (not lowercased)", "User@Example.COM", "User@Example.COM"}, + {"whitespace only becomes empty", " ", ""}, + {"already clean is unchanged", "user@example.com", "user@example.com"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := NormalizeEmail(tc.in); got != tc.want { + t.Errorf("NormalizeEmail(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/internal/auth/repository.go b/internal/auth/repository.go index f4a7f8e1..87f6cfc5 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -142,8 +142,8 @@ func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInpu cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"} args := []any{ - input.Email, - input.Username, + NormalizeEmail(input.Email), + NormalizeUsername(input.Username), string(hash), localPasswordLoginEnabled, input.Role, @@ -205,16 +205,16 @@ func (r *UserRepository) GetByID(ctx context.Context, id int) (*models.User, err return scanUser(r.pool.QueryRow(ctx, query, id)) } -// GetByUsername retrieves a user by their username. +// GetByUsername retrieves a user by their username (case-insensitive). func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*models.User, error) { query := `SELECT ` + allColumns + ` FROM users WHERE username = $1` - return scanUser(r.pool.QueryRow(ctx, query, username)) + return scanUser(r.pool.QueryRow(ctx, query, NormalizeUsername(username))) } -// GetByEmail retrieves a user by their email address. +// GetByEmail retrieves a user by their email address (case-insensitive). func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) { query := `SELECT ` + allColumns + ` FROM users WHERE email = $1` - return scanUser(r.pool.QueryRow(ctx, query, email)) + return scanUser(r.pool.QueryRow(ctx, query, NormalizeEmail(email))) } // Update modifies a user's fields. Only non-nil fields in the input are updated. @@ -226,12 +226,12 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update if input.Email != nil { setClauses = append(setClauses, fmt.Sprintf("email = $%d", argIndex)) - args = append(args, *input.Email) + args = append(args, NormalizeEmail(*input.Email)) argIndex++ } if input.Username != nil { setClauses = append(setClauses, fmt.Sprintf("username = $%d", argIndex)) - args = append(args, *input.Username) + args = append(args, NormalizeUsername(*input.Username)) argIndex++ } if input.Password != nil { diff --git a/migrations/165_case_insensitive_username_email.down.sql b/migrations/165_case_insensitive_username_email.down.sql new file mode 100644 index 00000000..b1911233 --- /dev/null +++ b/migrations/165_case_insensitive_username_email.down.sql @@ -0,0 +1,6 @@ +-- Revert username/email to case-sensitive text. The citext extension is left +-- installed: dropping it is unnecessary and would fail if any other object +-- ever depends on it. +ALTER TABLE public.users + ALTER COLUMN username TYPE text USING username::text, + ALTER COLUMN email TYPE text USING email::text; diff --git a/migrations/165_case_insensitive_username_email.up.sql b/migrations/165_case_insensitive_username_email.up.sql new file mode 100644 index 00000000..c4320758 --- /dev/null +++ b/migrations/165_case_insensitive_username_email.up.sql @@ -0,0 +1,9 @@ +-- Make user login identifiers case-insensitive. The citext column type compares +-- case-insensitively, so the existing users_username_key / users_email_key unique +-- indexes are rebuilt as case-insensitive and WHERE username = $1 / email = $1 +-- lookups match regardless of case. Original casing is preserved for display. +CREATE EXTENSION IF NOT EXISTS citext; + +ALTER TABLE public.users + ALTER COLUMN username TYPE citext USING username::citext, + ALTER COLUMN email TYPE citext USING email::citext;