Merge pull request #22 from Silo-Server/feat/case-insensitive-usernames

feat(auth): make usernames and emails case-insensitive
This commit is contained in:
Quick
2026-05-28 21:46:07 -04:00
committed by GitHub
7 changed files with 95 additions and 8 deletions
+3
View File
@@ -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
+6
View File
@@ -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
+17
View File
@@ -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)
}
+46
View File
@@ -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)
}
})
}
}
+8 -8
View File
@@ -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 {
@@ -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;
@@ -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;