diff --git a/internal/hub/case_insensitive_email_test.go b/internal/hub/case_insensitive_email_test.go new file mode 100644 index 000000000..288892f03 --- /dev/null +++ b/internal/hub/case_insensitive_email_test.go @@ -0,0 +1,98 @@ +//go:build testing + +package hub_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + + beszelTests "github.com/henrygd/beszel/internal/tests" + pbTests "github.com/pocketbase/pocketbase/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// authWithPasswordScenario builds an API scenario for the auth-with-password +// endpoint. Keeps the test tables below compact. +func authWithPasswordScenario(name, collection, identity, password string, status int, content []string, factory func(t testing.TB) *pbTests.TestApp) beszelTests.ApiScenario { + return beszelTests.ApiScenario{ + Name: name, + Method: http.MethodPost, + URL: fmt.Sprintf("/api/collections/%s/auth-with-password", collection), + Body: strings.NewReader(fmt.Sprintf(`{"identity":%q,"password":%q}`, identity, password)), + ExpectedStatus: status, + ExpectedContent: content, + TestAppFactory: factory, + } +} + +// TestEmailIsNormalizedOnCreate verifies that new user/superuser records have +// their email lowercased on save. This prevents future case-variant duplicates +// and keeps the stored data canonical. +func TestEmailIsNormalizedOnCreate(t *testing.T) { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + // StartHub binds the event hooks; the returned error is about the TestApp + // not being a full *pocketbase.PocketBase and is expected here + _ = hub.StartHub() + + user, err := beszelTests.CreateUser(hub, "Mixed@Case.com", "password123") + require.NoError(t, err) + assert.Equal(t, "mixed@case.com", user.Email(), "user email should be stored lowercase") + + superuser, err := beszelTests.CreateSuperuser(hub, "Admin@Case.com", "password123") + require.NoError(t, err) + assert.Equal(t, "admin@case.com", superuser.Email(), "superuser email should be stored lowercase") + + // a second user with a different case should collide with the existing + // normalized record via PocketBase's unique email index + _, err = beszelTests.CreateUser(hub, "MIXED@case.com", "password123") + require.Error(t, err, "creating a case-variant duplicate user should fail") +} + +// TestCaseInsensitiveEmailLogin verifies that a user can authenticate +// regardless of the case used at login time, including for pre-existing +// records whose email was stored with mixed case before the normalize hook +// existed (the scenario reported in issue #1887). +func TestCaseInsensitiveEmailLogin(t *testing.T) { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + // create records BEFORE StartHub binds the normalize-on-create hook so + // the emails are persisted with their original mixed case, simulating + // accounts created on versions without this fix + user, err := beszelTests.CreateUser(hub, "Legacy@Example.com", "password123") + require.NoError(t, err) + user.SetVerified(true) + require.NoError(t, hub.Save(user)) + require.Equal(t, "Legacy@Example.com", user.Email(), "pre-existing email should retain original case") + + superuser, err := beszelTests.CreateSuperuser(hub, "Admin@Example.com", "password123") + require.NoError(t, err) + require.Equal(t, "Admin@Example.com", superuser.Email(), "pre-existing superuser email should retain original case") + + _ = hub.StartHub() + + factory := func(t testing.TB) *pbTests.TestApp { return hub.TestApp } + + okContent := []string{`"token":`, user.Id} + superuserOk := []string{`"token":`, superuser.Id} + failContent := []string{"Failed to authenticate"} + + scenarios := []beszelTests.ApiScenario{ + authWithPasswordScenario("user login with lowercase", "users", "legacy@example.com", "password123", 200, okContent, factory), + authWithPasswordScenario("user login with uppercase", "users", "LEGACY@EXAMPLE.COM", "password123", 200, okContent, factory), + authWithPasswordScenario("user login with original case", "users", "Legacy@Example.com", "password123", 200, okContent, factory), + authWithPasswordScenario("user login with wrong password", "users", "legacy@example.com", "wrong", 400, failContent, factory), + authWithPasswordScenario("user login with unknown email", "users", "nobody@example.com", "password123", 400, failContent, factory), + authWithPasswordScenario("superuser login with lowercase", "_superusers", "admin@example.com", "password123", 200, superuserOk, factory), + authWithPasswordScenario("superuser login with uppercase", "_superusers", "ADMIN@EXAMPLE.COM", "password123", 200, superuserOk, factory), + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 13cd7e6a1..6d3fa8775 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -109,6 +109,14 @@ func (h *Hub) StartHub() error { h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole) h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings) + // normalize email to lowercase on user / superuser create so duplicate + // accounts that differ only in email case cannot be registered + h.App.OnRecordCreate("users", core.CollectionNameSuperusers).BindFunc(h.um.NormalizeEmail) + + // fall back to case-insensitive email lookup on password auth so users + // registered with a different case (e.g. "Foo@bar.com") can still log in + h.App.OnRecordAuthWithPasswordRequest("users", core.CollectionNameSuperusers).BindFunc(h.um.ResolveAuthIdentity) + pb, ok := h.App.(*pocketbase.PocketBase) if !ok { return errors.New("not a pocketbase app") diff --git a/internal/users/users.go b/internal/users/users.go index 9807d0fe6..2332380b7 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -4,6 +4,7 @@ package users import ( "log" "net/http" + "strings" "github.com/henrygd/beszel/internal/migrations" @@ -29,6 +30,39 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error { return e.Next() } +// NormalizeEmail lowercases the email on the record so stored values are +// always in a canonical form. Prevents creating accounts that differ only +// in email case (e.g. "Foo@bar.com" vs "foo@bar.com"). +func (um *UserManager) NormalizeEmail(e *core.RecordEvent) error { + if email := e.Record.Email(); email != "" { + lower := strings.ToLower(email) + if lower != email { + e.Record.SetEmail(lower) + } + } + return e.Next() +} + +// ResolveAuthIdentity handles case-insensitive email lookup for password auth. +// PocketBase's default lookup is exact-match (unless the email unique index +// uses COLLATE NOCASE), so accounts registered with a different case +// (e.g. "Foo@bar.com") could not be used to log in as "foo@bar.com". +// If the default lookup did not find a record, fall back to a case-insensitive +// search on the email field. +func (um *UserManager) ResolveAuthIdentity(e *core.RecordAuthWithPasswordRequestEvent) error { + if e.Record == nil && e.Identity != "" && (e.IdentityField == "" || e.IdentityField == core.FieldNameEmail) { + record := &core.Record{} + err := e.App.RecordQuery(e.Collection). + AndWhere(dbx.NewExp("[[email]] = {:email} COLLATE NOCASE", dbx.Params{"email": e.Identity})). + Limit(1). + One(record) + if err == nil { + e.Record = record + } + } + return e.Next() +} + // Initialize user settings with defaults if not set func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error { record := e.Record