Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions internal/hub/case_insensitive_email_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions internal/hub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
34 changes: 34 additions & 0 deletions internal/users/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package users
import (
"log"
"net/http"
"strings"

"github.com/henrygd/beszel/internal/migrations"

Expand All @@ -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
Expand Down
Loading