Merge pull request #22 from Silo-Server/feat/case-insensitive-usernames
feat(auth): make usernames and emails case-insensitive
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user