[#6337] added support for case-insensitive password auth
This commit is contained in:
		
							parent
							
								
									c101798516
								
							
						
					
					
						commit
						33340a6977
					
				| 
						 | 
				
			
			@ -12,7 +12,7 @@
 | 
			
		|||
 | 
			
		||||
- Upgraded to `golang-jwt/jwt/v5`.
 | 
			
		||||
 | 
			
		||||
- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)).
 | 
			
		||||
- Added support for case-insensitive password auth based on the related UNIQUE index field collation ([#6337](https://github.com/pocketbase/pocketbase/discussions/6337)).
 | 
			
		||||
 | 
			
		||||
- Soft-deprecated `Record.GetUploadedFiles` in favor of `Record.GetUnsavedFiles` to minimize the ambiguities what the method do ([#6269](https://github.com/pocketbase/pocketbase/discussions/6269)).
 | 
			
		||||
    (@todo update docs to reflect the `:unsaved` getter change)
 | 
			
		||||
| 
						 | 
				
			
			@ -22,6 +22,8 @@
 | 
			
		|||
 | 
			
		||||
- Replaced archived `github.com/AlecAivazis/survey` dependency with a simpler  `osutils.YesNoPrompt(message, fallback)` helper.
 | 
			
		||||
 | 
			
		||||
- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)).
 | 
			
		||||
 | 
			
		||||
- Added `inflector.Camelize(str)` and `inflector.Singularize(str)` helper methods.
 | 
			
		||||
 | 
			
		||||
- Other minor improvements (_replaced all `bool` exists db scans with `int` for broader drivers compatibility, use the non-transactional app instance during realtime records delete access checks to ensure that cascade deleted records with API rules relying on the parent will be resolved, updated UI dependencies, etc._)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,6 +8,7 @@ import (
 | 
			
		|||
	"log/slog"
 | 
			
		||||
	"maps"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	validation "github.com/go-ozzo/ozzo-validation/v4"
 | 
			
		||||
| 
						 | 
				
			
			@ -194,10 +195,20 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
 | 
			
		|||
 | 
			
		||||
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
 | 
			
		||||
	// ensure that username is unique
 | 
			
		||||
	checkUnique := dbutils.HasSingleColumnUniqueIndex(collection.OAuth2.MappedFields.Username, collection.Indexes)
 | 
			
		||||
	if checkUnique {
 | 
			
		||||
		if _, err := txApp.FindFirstRecordByData(collection, collection.OAuth2.MappedFields.Username, username); err == nil {
 | 
			
		||||
			return false // already exist
 | 
			
		||||
	index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, collection.OAuth2.MappedFields.Username)
 | 
			
		||||
	if hasUniqueue {
 | 
			
		||||
		var expr dbx.Expression
 | 
			
		||||
		if strings.EqualFold(index.Columns[0].Collate, "nocase") {
 | 
			
		||||
			// case-insensitive search
 | 
			
		||||
			expr = dbx.NewExp("username = {:username} COLLATE NOCASE", dbx.Params{"username": username})
 | 
			
		||||
		} else {
 | 
			
		||||
			expr = dbx.HashExp{"username": username}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var exists int
 | 
			
		||||
		_ = txApp.RecordQuery(collection).Select("(1)").AndWhere(expr).Limit(1).Row(&exists)
 | 
			
		||||
		if exists > 0 {
 | 
			
		||||
			return false
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,6 +14,7 @@ import (
 | 
			
		|||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tests"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/auth"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/dbutils"
 | 
			
		||||
	"golang.org/x/oauth2"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1210,7 +1211,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
 | 
			
		|||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "creating user (with mapped OAuth2 fields and avatarURL->non-file field)",
 | 
			
		||||
			Name:   "creating user (with mapped OAuth2 fields, case-sensitive username and avatarURL->non-file field)",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/collections/users/auth-with-oauth2",
 | 
			
		||||
			Body: strings.NewReader(`{
 | 
			
		||||
| 
						 | 
				
			
			@ -1230,7 +1231,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
 | 
			
		|||
						AuthUser: &auth.AuthUser{
 | 
			
		||||
							Id:        "oauth2_id",
 | 
			
		||||
							Email:     "oauth2@example.com",
 | 
			
		||||
							Username:  "oauth2_username",
 | 
			
		||||
							Username:  "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive
 | 
			
		||||
							Name:      "oauth2_name",
 | 
			
		||||
							AvatarURL: server.URL + "/oauth2_avatar.png",
 | 
			
		||||
						},
 | 
			
		||||
| 
						 | 
				
			
			@ -1258,7 +1259,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
 | 
			
		|||
			ExpectedContent: []string{
 | 
			
		||||
				`"email":"oauth2@example.com"`,
 | 
			
		||||
				`"emailVisibility":false`,
 | 
			
		||||
				`"username":"oauth2_username"`,
 | 
			
		||||
				`"username":"tESt2_username"`,
 | 
			
		||||
				`"name":"http://127.`,
 | 
			
		||||
				`"verified":true`,
 | 
			
		||||
				`"avatar":""`,
 | 
			
		||||
| 
						 | 
				
			
			@ -1294,7 +1295,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
 | 
			
		|||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "creating user (with mapped OAuth2 fields and duplicated username)",
 | 
			
		||||
			Name:   "creating user (with mapped OAuth2 fields and duplicated case-insensitive username)",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/collections/users/auth-with-oauth2",
 | 
			
		||||
			Body: strings.NewReader(`{
 | 
			
		||||
| 
						 | 
				
			
			@ -1314,13 +1315,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
 | 
			
		|||
						AuthUser: &auth.AuthUser{
 | 
			
		||||
							Id:       "oauth2_id",
 | 
			
		||||
							Email:    "oauth2@example.com",
 | 
			
		||||
							Username: "test2_username",
 | 
			
		||||
							Username: "tESt2_username",
 | 
			
		||||
							Name:     "oauth2_name",
 | 
			
		||||
						},
 | 
			
		||||
						Token: &oauth2.Token{AccessToken: "abc"},
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// make the username index case-insensitive to ensure that case-insensitive match is used
 | 
			
		||||
				index, ok := dbutils.FindSingleColumnUniqueIndex(usersCol.Indexes, "username")
 | 
			
		||||
				if ok {
 | 
			
		||||
					index.Columns[0].Collate = "nocase"
 | 
			
		||||
					usersCol.RemoveIndex(index.IndexName)
 | 
			
		||||
					usersCol.Indexes = append(usersCol.Indexes, index.Build())
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// add the test provider in the collection
 | 
			
		||||
				usersCol.MFA.Enabled = false
 | 
			
		||||
				usersCol.OAuth2.Enabled = true
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,10 +3,14 @@ package apis
 | 
			
		|||
import (
 | 
			
		||||
	"database/sql"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"slices"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	validation "github.com/go-ozzo/ozzo-validation/v4"
 | 
			
		||||
	"github.com/go-ozzo/ozzo-validation/v4/is"
 | 
			
		||||
	"github.com/pocketbase/dbx"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/dbutils"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/list"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -32,12 +36,12 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
 | 
			
		|||
	var foundErr error
 | 
			
		||||
 | 
			
		||||
	if form.IdentityField != "" {
 | 
			
		||||
		foundRecord, foundErr = e.App.FindFirstRecordByData(collection.Id, form.IdentityField, form.Identity)
 | 
			
		||||
		foundRecord, foundErr = findRecordByIdentityField(e.App, collection, form.IdentityField, form.Identity)
 | 
			
		||||
	} else {
 | 
			
		||||
		// prioritize email lookup
 | 
			
		||||
		isEmail := is.EmailFormat.Validate(form.Identity) == nil
 | 
			
		||||
		if isEmail && list.ExistInSlice(core.FieldNameEmail, collection.PasswordAuth.IdentityFields) {
 | 
			
		||||
			foundRecord, foundErr = e.App.FindAuthRecordByEmail(collection.Id, form.Identity)
 | 
			
		||||
			foundRecord, foundErr = findRecordByIdentityField(e.App, collection, core.FieldNameEmail, form.Identity)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// search by the other identity fields
 | 
			
		||||
| 
						 | 
				
			
			@ -47,7 +51,7 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
 | 
			
		|||
					continue // no need to search by the email field if it is not an email
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				foundRecord, foundErr = e.App.FindFirstRecordByData(collection.Id, name, form.Identity)
 | 
			
		||||
				foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
 | 
			
		||||
				if foundErr == nil {
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
| 
						 | 
				
			
			@ -95,3 +99,31 @@ func (form *authWithPasswordForm) validate(collection *core.Collection) error {
 | 
			
		|||
		validation.Field(&form.IdentityField, validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...)),
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func findRecordByIdentityField(app core.App, collection *core.Collection, field string, value any) (*core.Record, error) {
 | 
			
		||||
	if !slices.Contains(collection.PasswordAuth.IdentityFields, field) {
 | 
			
		||||
		return nil, errors.New("invalid identity field " + field)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, errors.New("missing " + field + " unique index constraint")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var expr dbx.Expression
 | 
			
		||||
	if strings.EqualFold(index.Columns[0].Collate, "nocase") {
 | 
			
		||||
		// case-insensitive search
 | 
			
		||||
		expr = dbx.NewExp("[["+field+"]] = {:identity} COLLATE NOCASE", dbx.Params{"identity": value})
 | 
			
		||||
	} else {
 | 
			
		||||
		expr = dbx.HashExp{field: value}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	record := &core.Record{}
 | 
			
		||||
 | 
			
		||||
	err := app.RecordQuery(collection).AndWhere(expr).Limit(1).One(record)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return record, nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,11 +8,38 @@ import (
 | 
			
		|||
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tests"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/dbutils"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestRecordAuthWithPassword(t *testing.T) {
 | 
			
		||||
	t.Parallel()
 | 
			
		||||
 | 
			
		||||
	updateIdentityIndex := func(collectionIdOrName string, fieldCollateMap map[string]string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
 | 
			
		||||
		return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
 | 
			
		||||
			collection, err := app.FindCollectionByNameOrId("clients")
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			for column, collate := range fieldCollateMap {
 | 
			
		||||
				index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, column)
 | 
			
		||||
				if !ok {
 | 
			
		||||
					t.Fatalf("Missing unique identityField index for column %q", column)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				index.Columns[0].Collate = collate
 | 
			
		||||
 | 
			
		||||
				collection.RemoveIndex(index.IndexName)
 | 
			
		||||
				collection.Indexes = append(collection.Indexes, index.Build())
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			err = app.Save(collection)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatalf("Failed to update identityField index: %v", err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	scenarios := []tests.ApiScenario{
 | 
			
		||||
		{
 | 
			
		||||
			Name:            "disabled password auth",
 | 
			
		||||
| 
						 | 
				
			
			@ -164,6 +191,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
 | 
			
		|||
				"OnMailerRecordAuthAlertSend": 1,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "unknown explicit identityField",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/collections/clients/auth-with-password",
 | 
			
		||||
			Body: strings.NewReader(`{
 | 
			
		||||
				"identityField": "created",
 | 
			
		||||
				"identity":"test@example.com",
 | 
			
		||||
				"password":"1234567890"
 | 
			
		||||
			}`),
 | 
			
		||||
			ExpectedStatus: 400,
 | 
			
		||||
			ExpectedContent: []string{
 | 
			
		||||
				`"data":{`,
 | 
			
		||||
				`"identityField":{"code":"validation_in_invalid"`,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedEvents: map[string]int{"*": 0},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "valid identity field and valid password with mismatched explicit identityField",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
| 
						 | 
				
			
			@ -440,6 +483,141 @@ func TestRecordAuthWithPassword(t *testing.T) {
 | 
			
		|||
			},
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		// case sensitivity checks
 | 
			
		||||
		// -----------------------------------------------------------
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "with explicit identityField (case-sensitive)",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/collections/clients/auth-with-password",
 | 
			
		||||
			Body: strings.NewReader(`{
 | 
			
		||||
				"identityField": "username",
 | 
			
		||||
				"identity":"Clients57772",
 | 
			
		||||
				"password":"1234567890"
 | 
			
		||||
			}`),
 | 
			
		||||
			BeforeTestFunc:  updateIdentityIndex("clients", map[string]string{"username": ""}),
 | 
			
		||||
			ExpectedStatus:  400,
 | 
			
		||||
			ExpectedContent: []string{`"data":{}`},
 | 
			
		||||
			ExpectedEvents: map[string]int{
 | 
			
		||||
				"*":                               0,
 | 
			
		||||
				"OnRecordAuthWithPasswordRequest": 1,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "with explicit identityField (case-insensitive)",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/collections/clients/auth-with-password",
 | 
			
		||||
			Body: strings.NewReader(`{
 | 
			
		||||
				"identityField": "username",
 | 
			
		||||
				"identity":"Clients57772",
 | 
			
		||||
				"password":"1234567890"
 | 
			
		||||
			}`),
 | 
			
		||||
			BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
 | 
			
		||||
			ExpectedStatus: 200,
 | 
			
		||||
			ExpectedContent: []string{
 | 
			
		||||
				`"email":"test@example.com"`,
 | 
			
		||||
				`"username":"clients57772"`,
 | 
			
		||||
				`"token":`,
 | 
			
		||||
			},
 | 
			
		||||
			NotExpectedContent: []string{
 | 
			
		||||
				// hidden fields
 | 
			
		||||
				`"tokenKey"`,
 | 
			
		||||
				`"password"`,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedEvents: map[string]int{
 | 
			
		||||
				"*":                               0,
 | 
			
		||||
				"OnRecordAuthWithPasswordRequest": 1,
 | 
			
		||||
				"OnRecordAuthRequest":             1,
 | 
			
		||||
				"OnRecordEnrich":                  1,
 | 
			
		||||
				// authOrigin track
 | 
			
		||||
				"OnModelCreate":               1,
 | 
			
		||||
				"OnModelCreateExecute":        1,
 | 
			
		||||
				"OnModelAfterCreateSuccess":   1,
 | 
			
		||||
				"OnModelValidate":             1,
 | 
			
		||||
				"OnRecordCreate":              1,
 | 
			
		||||
				"OnRecordCreateExecute":       1,
 | 
			
		||||
				"OnRecordAfterCreateSuccess":  1,
 | 
			
		||||
				"OnRecordValidate":            1,
 | 
			
		||||
				"OnMailerSend":                1,
 | 
			
		||||
				"OnMailerRecordAuthAlertSend": 1,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "without explicit identityField and non-email field (case-insensitive)",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/collections/clients/auth-with-password",
 | 
			
		||||
			Body: strings.NewReader(`{
 | 
			
		||||
				"identity":"Clients57772",
 | 
			
		||||
				"password":"1234567890"
 | 
			
		||||
			}`),
 | 
			
		||||
			BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
 | 
			
		||||
			ExpectedStatus: 200,
 | 
			
		||||
			ExpectedContent: []string{
 | 
			
		||||
				`"email":"test@example.com"`,
 | 
			
		||||
				`"username":"clients57772"`,
 | 
			
		||||
				`"token":`,
 | 
			
		||||
			},
 | 
			
		||||
			NotExpectedContent: []string{
 | 
			
		||||
				// hidden fields
 | 
			
		||||
				`"tokenKey"`,
 | 
			
		||||
				`"password"`,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedEvents: map[string]int{
 | 
			
		||||
				"*":                               0,
 | 
			
		||||
				"OnRecordAuthWithPasswordRequest": 1,
 | 
			
		||||
				"OnRecordAuthRequest":             1,
 | 
			
		||||
				"OnRecordEnrich":                  1,
 | 
			
		||||
				// authOrigin track
 | 
			
		||||
				"OnModelCreate":               1,
 | 
			
		||||
				"OnModelCreateExecute":        1,
 | 
			
		||||
				"OnModelAfterCreateSuccess":   1,
 | 
			
		||||
				"OnModelValidate":             1,
 | 
			
		||||
				"OnRecordCreate":              1,
 | 
			
		||||
				"OnRecordCreateExecute":       1,
 | 
			
		||||
				"OnRecordAfterCreateSuccess":  1,
 | 
			
		||||
				"OnRecordValidate":            1,
 | 
			
		||||
				"OnMailerSend":                1,
 | 
			
		||||
				"OnMailerRecordAuthAlertSend": 1,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:   "without explicit identityField and email field (case-insensitive)",
 | 
			
		||||
			Method: http.MethodPost,
 | 
			
		||||
			URL:    "/api/collections/clients/auth-with-password",
 | 
			
		||||
			Body: strings.NewReader(`{
 | 
			
		||||
				"identity":"tESt@example.com",
 | 
			
		||||
				"password":"1234567890"
 | 
			
		||||
			}`),
 | 
			
		||||
			BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"email": "nocase"}),
 | 
			
		||||
			ExpectedStatus: 200,
 | 
			
		||||
			ExpectedContent: []string{
 | 
			
		||||
				`"email":"test@example.com"`,
 | 
			
		||||
				`"username":"clients57772"`,
 | 
			
		||||
				`"token":`,
 | 
			
		||||
			},
 | 
			
		||||
			NotExpectedContent: []string{
 | 
			
		||||
				// hidden fields
 | 
			
		||||
				`"tokenKey"`,
 | 
			
		||||
				`"password"`,
 | 
			
		||||
			},
 | 
			
		||||
			ExpectedEvents: map[string]int{
 | 
			
		||||
				"*":                               0,
 | 
			
		||||
				"OnRecordAuthWithPasswordRequest": 1,
 | 
			
		||||
				"OnRecordAuthRequest":             1,
 | 
			
		||||
				"OnRecordEnrich":                  1,
 | 
			
		||||
				// authOrigin track
 | 
			
		||||
				"OnModelCreate":               1,
 | 
			
		||||
				"OnModelCreateExecute":        1,
 | 
			
		||||
				"OnModelAfterCreateSuccess":   1,
 | 
			
		||||
				"OnModelValidate":             1,
 | 
			
		||||
				"OnRecordCreate":              1,
 | 
			
		||||
				"OnRecordCreateExecute":       1,
 | 
			
		||||
				"OnRecordAfterCreateSuccess":  1,
 | 
			
		||||
				"OnRecordValidate":            1,
 | 
			
		||||
				"OnMailerSend":                1,
 | 
			
		||||
				"OnMailerRecordAuthAlertSend": 1,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		// rate limit checks
 | 
			
		||||
		// -----------------------------------------------------------
 | 
			
		||||
		{
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -989,7 +989,7 @@ func (c *Collection) initTokenKeyField() {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	// ensure that there is a unique index for the field
 | 
			
		||||
	if !dbutils.HasSingleColumnUniqueIndex(FieldNameTokenKey, c.Indexes) {
 | 
			
		||||
	if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameTokenKey); !ok {
 | 
			
		||||
		c.Indexes = append(c.Indexes, fmt.Sprintf(
 | 
			
		||||
			"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`)",
 | 
			
		||||
			c.fieldIndexName(FieldNameTokenKey),
 | 
			
		||||
| 
						 | 
				
			
			@ -1015,7 +1015,7 @@ func (c *Collection) initEmailField() {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	// ensure that there is a unique index for the email field
 | 
			
		||||
	if !dbutils.HasSingleColumnUniqueIndex(FieldNameEmail, c.Indexes) {
 | 
			
		||||
	if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameEmail); !ok {
 | 
			
		||||
		c.Indexes = append(c.Indexes, fmt.Sprintf(
 | 
			
		||||
			"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`) WHERE `%s` != ''",
 | 
			
		||||
			c.fieldIndexName(FieldNameEmail),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -456,7 +456,7 @@ func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
 | 
			
		|||
				SetParams(map[string]any{"fieldName": name})
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !dbutils.HasSingleColumnUniqueIndex(name, cv.new.Indexes) {
 | 
			
		||||
		if _, ok := dbutils.FindSingleColumnUniqueIndex(cv.new.Indexes, name); !ok {
 | 
			
		||||
			return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
 | 
			
		||||
				SetParams(map[string]any{"fieldName": name})
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			@ -666,7 +666,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
 | 
			
		|||
	if cv.new.IsAuth() {
 | 
			
		||||
		requiredNames := []string{FieldNameTokenKey, FieldNameEmail}
 | 
			
		||||
		for _, name := range requiredNames {
 | 
			
		||||
			if !dbutils.HasSingleColumnUniqueIndex(name, indexes) {
 | 
			
		||||
			if _, ok := dbutils.FindSingleColumnUniqueIndex(indexes, name); !ok {
 | 
			
		||||
				return validation.NewError(
 | 
			
		||||
					"validation_missing_required_unique_index",
 | 
			
		||||
					`Missing required unique index for field "{{.fieldName}}".`,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -505,7 +505,8 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
 | 
			
		|||
			isBackRelMultiple := backRelField.IsMultiple()
 | 
			
		||||
			if !isBackRelMultiple {
 | 
			
		||||
				// additionally check if the rel field has a single column unique index
 | 
			
		||||
				isBackRelMultiple = !dbutils.HasSingleColumnUniqueIndex(backRelField.Name, backCollection.Indexes)
 | 
			
		||||
				_, hasUniqueIndex := dbutils.FindSingleColumnUniqueIndex(backCollection.Indexes, backRelField.Name)
 | 
			
		||||
				isBackRelMultiple = !hasUniqueIndex
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if !isBackRelMultiple {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,6 +9,7 @@ import (
 | 
			
		|||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/pocketbase/dbx"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/dbutils"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/inflector"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/list"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/search"
 | 
			
		||||
| 
						 | 
				
			
			@ -527,20 +528,34 @@ func (app *BaseApp) FindAuthRecordByToken(token string, validTypes ...string) (*
 | 
			
		|||
 | 
			
		||||
// FindAuthRecordByEmail finds the auth record associated with the provided email.
 | 
			
		||||
//
 | 
			
		||||
// The email check would be case-insensitive if the related collection
 | 
			
		||||
// email unique index has COLLATE NOCASE specified for the email column.
 | 
			
		||||
//
 | 
			
		||||
// Returns an error if it is not an auth collection or the record is not found.
 | 
			
		||||
func (app *BaseApp) FindAuthRecordByEmail(collectionModelOrIdentifier any, email string) (*Record, error) {
 | 
			
		||||
	collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to fetch auth collection: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !collection.IsAuth() {
 | 
			
		||||
		return nil, fmt.Errorf("%q is not an auth collection", collection.Name)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	record := &Record{}
 | 
			
		||||
 | 
			
		||||
	var expr dbx.Expression
 | 
			
		||||
 | 
			
		||||
	index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, FieldNameEmail)
 | 
			
		||||
	if ok && strings.EqualFold(index.Columns[0].Collate, "nocase") {
 | 
			
		||||
		// case-insensitive search
 | 
			
		||||
		expr = dbx.NewExp("[["+FieldNameEmail+"]] = {:email} COLLATE NOCASE", dbx.Params{"email": email})
 | 
			
		||||
	} else {
 | 
			
		||||
		expr = dbx.HashExp{FieldNameEmail: email}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = app.RecordQuery(collection).
 | 
			
		||||
		AndWhere(dbx.HashExp{FieldNameEmail: email}).
 | 
			
		||||
		AndWhere(expr).
 | 
			
		||||
		Limit(1).
 | 
			
		||||
		One(record)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -143,7 +143,7 @@ func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFun
 | 
			
		|||
			MaxSelect:    2147483647,
 | 
			
		||||
			CollectionId: indirectRel.Id,
 | 
			
		||||
		}
 | 
			
		||||
		if dbutils.HasSingleColumnUniqueIndex(indirectRelField.GetName(), indirectRel.Indexes) {
 | 
			
		||||
		if _, ok := dbutils.FindSingleColumnUniqueIndex(indirectRel.Indexes, indirectRelField.GetName()); ok {
 | 
			
		||||
			relField.MaxSelect = 1
 | 
			
		||||
		}
 | 
			
		||||
		relCollection = indirectRel
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,7 @@ import (
 | 
			
		|||
	"github.com/pocketbase/dbx"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/core"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tests"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/dbutils"
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/types"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -966,23 +967,46 @@ func TestFindAuthRecordByToken(t *testing.T) {
 | 
			
		|||
func TestFindAuthRecordByEmail(t *testing.T) {
 | 
			
		||||
	t.Parallel()
 | 
			
		||||
 | 
			
		||||
	app, _ := tests.NewTestApp()
 | 
			
		||||
	defer app.Cleanup()
 | 
			
		||||
 | 
			
		||||
	scenarios := []struct {
 | 
			
		||||
		collectionIdOrName string
 | 
			
		||||
		email              string
 | 
			
		||||
		nocaseIndex        bool
 | 
			
		||||
		expectError        bool
 | 
			
		||||
	}{
 | 
			
		||||
		{"missing", "test@example.com", true},
 | 
			
		||||
		{"demo2", "test@example.com", true},
 | 
			
		||||
		{"users", "missing@example.com", true},
 | 
			
		||||
		{"users", "test@example.com", false},
 | 
			
		||||
		{"clients", "test2@example.com", false},
 | 
			
		||||
		{"missing", "test@example.com", false, true},
 | 
			
		||||
		{"demo2", "test@example.com", false, true},
 | 
			
		||||
		{"users", "missing@example.com", false, true},
 | 
			
		||||
		{"users", "test@example.com", false, false},
 | 
			
		||||
		{"clients", "test2@example.com", false, false},
 | 
			
		||||
		// case-insensitive tests
 | 
			
		||||
		{"clients", "TeSt2@example.com", false, true},
 | 
			
		||||
		{"clients", "TeSt2@example.com", true, false},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, s := range scenarios {
 | 
			
		||||
		t.Run(fmt.Sprintf("%s_%s", s.collectionIdOrName, s.email), func(t *testing.T) {
 | 
			
		||||
			app, _ := tests.NewTestApp()
 | 
			
		||||
			defer app.Cleanup()
 | 
			
		||||
 | 
			
		||||
			collection, _ := app.FindCollectionByNameOrId(s.collectionIdOrName)
 | 
			
		||||
			if collection != nil {
 | 
			
		||||
				emailIndex, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, core.FieldNameEmail)
 | 
			
		||||
				if ok {
 | 
			
		||||
					if s.nocaseIndex {
 | 
			
		||||
						emailIndex.Columns[0].Collate = "nocase"
 | 
			
		||||
					} else {
 | 
			
		||||
						emailIndex.Columns[0].Collate = ""
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					collection.RemoveIndex(emailIndex.IndexName)
 | 
			
		||||
					collection.Indexes = append(collection.Indexes, emailIndex.Build())
 | 
			
		||||
					err := app.Save(collection)
 | 
			
		||||
					if err != nil {
 | 
			
		||||
						t.Fatalf("Failed to update email index: %v", err)
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			record, err := app.FindAuthRecordByEmail(s.collectionIdOrName, s.email)
 | 
			
		||||
 | 
			
		||||
			hasErr := err != nil
 | 
			
		||||
| 
						 | 
				
			
			@ -994,7 +1018,7 @@ func TestFindAuthRecordByEmail(t *testing.T) {
 | 
			
		|||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if record.Email() != s.email {
 | 
			
		||||
			if !strings.EqualFold(record.Email(), s.email) {
 | 
			
		||||
				t.Fatalf("Expected record with email %s, got %s", s.email, record.Email())
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,13 +21,13 @@ type IndexColumn struct {
 | 
			
		|||
 | 
			
		||||
// Index represents a single parsed SQL CREATE INDEX expression.
 | 
			
		||||
type Index struct {
 | 
			
		||||
	Unique     bool          `json:"unique"`
 | 
			
		||||
	Optional   bool          `json:"optional"`
 | 
			
		||||
	SchemaName string        `json:"schemaName"`
 | 
			
		||||
	IndexName  string        `json:"indexName"`
 | 
			
		||||
	TableName  string        `json:"tableName"`
 | 
			
		||||
	Columns    []IndexColumn `json:"columns"`
 | 
			
		||||
	Where      string        `json:"where"`
 | 
			
		||||
	Columns    []IndexColumn `json:"columns"`
 | 
			
		||||
	Unique     bool          `json:"unique"`
 | 
			
		||||
	Optional   bool          `json:"optional"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// IsValid checks if the current Index contains the minimum required fields to be considered valid.
 | 
			
		||||
| 
						 | 
				
			
			@ -193,15 +193,25 @@ func ParseIndex(createIndexExpr string) Index {
 | 
			
		|||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// HasColumnUniqueIndex loosely checks whether the specified column has
 | 
			
		||||
// a single column unique index (WHERE statements are ignored).
 | 
			
		||||
func HasSingleColumnUniqueIndex(column string, indexes []string) bool {
 | 
			
		||||
// FindSingleColumnUniqueIndex returns the first matching single column unique index.
 | 
			
		||||
func FindSingleColumnUniqueIndex(indexes []string, column string) (Index, bool) {
 | 
			
		||||
	var index Index
 | 
			
		||||
 | 
			
		||||
	for _, idx := range indexes {
 | 
			
		||||
		parsed := ParseIndex(idx)
 | 
			
		||||
		if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, column) {
 | 
			
		||||
			return true
 | 
			
		||||
		index := ParseIndex(idx)
 | 
			
		||||
		if index.Unique && len(index.Columns) == 1 && strings.EqualFold(index.Columns[0].Name, column) {
 | 
			
		||||
			return index, true
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return false
 | 
			
		||||
	return index, false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Deprecated: Use `_, ok := FindSingleColumnUniqueIndex(indexes, column)` instead.
 | 
			
		||||
//
 | 
			
		||||
// HasColumnUniqueIndex loosely checks whether the specified column has
 | 
			
		||||
// a single column unique index (WHERE statements are ignored).
 | 
			
		||||
func HasSingleColumnUniqueIndex(column string, indexes []string) bool {
 | 
			
		||||
	_, ok := FindSingleColumnUniqueIndex(indexes, column)
 | 
			
		||||
	return ok
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ import (
 | 
			
		|||
	"bytes"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/pocketbase/pocketbase/tools/dbutils"
 | 
			
		||||
| 
						 | 
				
			
			@ -312,3 +313,93 @@ func TestHasSingleColumnUniqueIndex(t *testing.T) {
 | 
			
		|||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestFindSingleColumnUniqueIndex(t *testing.T) {
 | 
			
		||||
	scenarios := []struct {
 | 
			
		||||
		name     string
 | 
			
		||||
		column   string
 | 
			
		||||
		indexes  []string
 | 
			
		||||
		expected bool
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			"empty indexes",
 | 
			
		||||
			"test",
 | 
			
		||||
			nil,
 | 
			
		||||
			false,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			"empty column",
 | 
			
		||||
			"",
 | 
			
		||||
			[]string{
 | 
			
		||||
				"CREATE UNIQUE INDEX `index1` ON `example` (`test`)",
 | 
			
		||||
			},
 | 
			
		||||
			false,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			"mismatched column",
 | 
			
		||||
			"test",
 | 
			
		||||
			[]string{
 | 
			
		||||
				"CREATE UNIQUE INDEX `index1` ON `example` (`test2`)",
 | 
			
		||||
			},
 | 
			
		||||
			false,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			"non unique index",
 | 
			
		||||
			"test",
 | 
			
		||||
			[]string{
 | 
			
		||||
				"CREATE INDEX `index1` ON `example` (`test`)",
 | 
			
		||||
			},
 | 
			
		||||
			false,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			"matching columnd and unique index",
 | 
			
		||||
			"test",
 | 
			
		||||
			[]string{
 | 
			
		||||
				"CREATE UNIQUE INDEX `index1` ON `example` (`test`)",
 | 
			
		||||
			},
 | 
			
		||||
			true,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			"multiple columns",
 | 
			
		||||
			"test",
 | 
			
		||||
			[]string{
 | 
			
		||||
				"CREATE UNIQUE INDEX `index1` ON `example` (`test`, `test2`)",
 | 
			
		||||
			},
 | 
			
		||||
			false,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			"multiple indexes",
 | 
			
		||||
			"test",
 | 
			
		||||
			[]string{
 | 
			
		||||
				"CREATE UNIQUE INDEX `index1` ON `example` (`test`, `test2`)",
 | 
			
		||||
				"CREATE UNIQUE INDEX `index2` ON `example` (`test`)",
 | 
			
		||||
			},
 | 
			
		||||
			true,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			"partial unique index",
 | 
			
		||||
			"test",
 | 
			
		||||
			[]string{
 | 
			
		||||
				"CREATE UNIQUE INDEX `index` ON `example` (`test`) where test != ''",
 | 
			
		||||
			},
 | 
			
		||||
			true,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, s := range scenarios {
 | 
			
		||||
		t.Run(s.name, func(t *testing.T) {
 | 
			
		||||
			index, exists := dbutils.FindSingleColumnUniqueIndex(s.indexes, s.column)
 | 
			
		||||
			if exists != s.expected {
 | 
			
		||||
				t.Fatalf("Expected exists %v got %v", s.expected, exists)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if !exists && len(index.Columns) > 0 {
 | 
			
		||||
				t.Fatal("Expected index.Columns to be empty")
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if exists && !strings.EqualFold(index.Columns[0].Name, s.column) {
 | 
			
		||||
				t.Fatalf("Expected to find column %q in %v", s.column, index)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue