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;