mirror of
https://github.com/pocketbase/pocketbase.git
synced 2025-03-19 22:19:23 +02:00
added record.SetRandomPassword() helper and updated oauth2 autogenerated password handling
This commit is contained in:
parent
d8c0b11271
commit
d34c8ec048
@ -18,6 +18,9 @@
|
|||||||
|
|
||||||
- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)).
|
- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)).
|
||||||
|
|
||||||
|
- Added `record.SetRandomPassword()` to simplify random password generation usually used in the OAuth2 or OTP record creation flows.
|
||||||
|
_The generated ~30 chars random password is assigned directly as bcrypt hash and ignores the `password` field plain value validators like min/max length or regex pattern._
|
||||||
|
|
||||||
- ⚠️ Removed the "dry submit" when executing the collections Create API rule
|
- ⚠️ Removed the "dry submit" when executing the collections Create API rule
|
||||||
(you can find more details why this change was introduced and how it could affect your app in https://github.com/pocketbase/pocketbase/discussions/6073).
|
(you can find more details why this change was introduced and how it could affect your app in https://github.com/pocketbase/pocketbase/discussions/6073).
|
||||||
For most users it should be non-breaking change, BUT if you have Create API rules that uses self-references or view counters you may have to adjust them manually.
|
For most users it should be non-breaking change, BUT if you have Create API rules that uses self-references or view counters you may have to adjust them manually.
|
||||||
|
@ -16,7 +16,6 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/tools/auth"
|
"github.com/pocketbase/pocketbase/tools/auth"
|
||||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -224,12 +223,6 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
|||||||
|
|
||||||
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
||||||
|
|
||||||
// set a random password if none is set
|
|
||||||
if v, _ := payload[core.FieldNamePassword].(string); v == "" {
|
|
||||||
payload[core.FieldNamePassword] = security.RandomString(30)
|
|
||||||
payload[core.FieldNamePassword+"Confirm"] = payload[core.FieldNamePassword]
|
|
||||||
}
|
|
||||||
|
|
||||||
// map known fields (unless the field was explicitly submitted as part of CreateData)
|
// map known fields (unless the field was explicitly submitted as part of CreateData)
|
||||||
if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
|
if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
|
||||||
payload[e.Collection.OAuth2.MappedFields.Id] = e.OAuth2User.Id
|
payload[e.Collection.OAuth2.MappedFields.Id] = e.OAuth2User.Id
|
||||||
@ -292,7 +285,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
|||||||
// set random password for users with unverified email
|
// set random password for users with unverified email
|
||||||
// (this is in case a malicious actor has registered previously with the user email)
|
// (this is in case a malicious actor has registered previously with the user email)
|
||||||
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
|
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
|
||||||
e.Record.SetPassword(security.RandomString(30))
|
e.Record.SetRandomPassword()
|
||||||
needUpdate = true
|
needUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -958,6 +958,8 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
"createData": {
|
"createData": {
|
||||||
"email": "invalid",
|
"email": "invalid",
|
||||||
"emailVisibility": true,
|
"emailVisibility": true,
|
||||||
|
"password": "1234567890",
|
||||||
|
"passwordConfirm": "1234567890",
|
||||||
"name": "test_name",
|
"name": "test_name",
|
||||||
"username": "test_username",
|
"username": "test_username",
|
||||||
"rel": "0yxhwia2amd8gec"
|
"rel": "0yxhwia2amd8gec"
|
||||||
@ -1027,6 +1029,16 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
"OnModelValidate": 4,
|
"OnModelValidate": 4,
|
||||||
"OnRecordValidate": 4,
|
"OnRecordValidate": 4,
|
||||||
},
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||||
|
user, err := app.FindFirstRecordByData("users", "username", "test_username")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.ValidatePassword("1234567890") {
|
||||||
|
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
|
Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
|
||||||
|
@ -94,7 +94,7 @@ func recordsList(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
// Add a randomized throttle in case of too many empty search filter attempts.
|
// Add a randomized throttle in case of too many empty search filter attempts.
|
||||||
//
|
//
|
||||||
// This is just for extra precaution since security researches raised concern regarding the possibity of eventual
|
// This is just for extra precaution since security researches raised concern regarding the possibility of eventual
|
||||||
// timing attacks because the List API rule acts also as filter and executes in a single run with the client-side filters.
|
// timing attacks because the List API rule acts also as filter and executes in a single run with the client-side filters.
|
||||||
// This is by design and it is an accepted trade off between performance, usability and correctness.
|
// This is by design and it is an accepted trade off between performance, usability and correctness.
|
||||||
//
|
//
|
||||||
@ -220,6 +220,16 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
|||||||
return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err))
|
return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// set a random password for the OAuth2 ignoring its plain password validators
|
||||||
|
var skipPlainPasswordRecordValidators bool
|
||||||
|
if requestInfo.Context == core.RequestInfoContextOAuth2 {
|
||||||
|
if _, ok := data[core.FieldNamePassword]; !ok {
|
||||||
|
data[core.FieldNamePassword] = security.RandomString(30)
|
||||||
|
data[core.FieldNamePassword+"Confirm"] = data[core.FieldNamePassword]
|
||||||
|
skipPlainPasswordRecordValidators = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// replace modifiers fields so that the resolved value is always
|
// replace modifiers fields so that the resolved value is always
|
||||||
// available when accessing requestInfo.Body
|
// available when accessing requestInfo.Body
|
||||||
requestInfo.Body = data
|
requestInfo.Body = data
|
||||||
@ -230,6 +240,13 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
|||||||
}
|
}
|
||||||
form.Load(data)
|
form.Load(data)
|
||||||
|
|
||||||
|
if skipPlainPasswordRecordValidators {
|
||||||
|
// unset the plain value to skip the plain password field validators
|
||||||
|
if raw, ok := record.GetRaw(core.FieldNamePassword).(*core.PasswordFieldValue); ok {
|
||||||
|
raw.Plain = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var isOptFinalizerCalled bool
|
var isOptFinalizerCalled bool
|
||||||
|
|
||||||
event := new(core.RecordRequestEvent)
|
event := new(core.RecordRequestEvent)
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
|
import "github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
|
||||||
// Email returns the "email" record field value (usually available with Auth collections).
|
// Email returns the "email" record field value (usually available with Auth collections).
|
||||||
func (m *Record) Email() string {
|
func (m *Record) Email() string {
|
||||||
return m.GetString(FieldNameEmail)
|
return m.GetString(FieldNameEmail)
|
||||||
@ -51,6 +53,25 @@ func (m *Record) SetPassword(password string) {
|
|||||||
m.Set(FieldNamePassword, password)
|
m.Set(FieldNamePassword, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRandomPassword sets the "password" auth record field to a random autogenerated value.
|
||||||
|
//
|
||||||
|
// The autogenerated password is ~30 characters and it is set directly as hash,
|
||||||
|
// aka. the field plain password value validators (length, pattern, etc.) are ignored
|
||||||
|
// (this is usually used as part of the auto created OTP or OAuth2 user flows).
|
||||||
|
func (m *Record) SetRandomPassword() string {
|
||||||
|
pass := security.RandomString(30)
|
||||||
|
|
||||||
|
m.Set(FieldNamePassword, pass)
|
||||||
|
m.RefreshTokenKey() // manually refresh the token key because the plain password is resetted
|
||||||
|
|
||||||
|
// unset the plain value to skip the field validators
|
||||||
|
if raw, ok := m.GetRaw(FieldNamePassword).(*PasswordFieldValue); ok {
|
||||||
|
raw.Plain = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return pass
|
||||||
|
}
|
||||||
|
|
||||||
// ValidatePassword validates a plain password against the "password" record field.
|
// ValidatePassword validates a plain password against the "password" record field.
|
||||||
//
|
//
|
||||||
// Returns false if the password is incorrect.
|
// Returns false if the password is incorrect.
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
package core_test
|
package core_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -117,3 +119,42 @@ func TestRecordPassword(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecordSetRandomPassword(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
oldTokenKey := "old_tokenKey"
|
||||||
|
record := core.NewRecord(core.NewAuthCollection("test"))
|
||||||
|
record.SetTokenKey(oldTokenKey)
|
||||||
|
|
||||||
|
pass := record.SetRandomPassword()
|
||||||
|
|
||||||
|
if pass == "" {
|
||||||
|
t.Fatal("Expected non-empty generated random password")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !record.ValidatePassword(pass) {
|
||||||
|
t.Fatal("Expected the generated random password to be valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.TokenKey() == oldTokenKey {
|
||||||
|
t.Fatal("Expected token key to change")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, ok := record.Collection().Fields.GetByName(core.FieldNamePassword).(*core.PasswordField)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Expected *core.PasswordField")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that the field validators will be ignored
|
||||||
|
f.Min = 1
|
||||||
|
f.Max = 2
|
||||||
|
f.Pattern = `\d+`
|
||||||
|
|
||||||
|
if err := f.ValidateValue(context.Background(), app, record); err != nil {
|
||||||
|
t.Fatalf("Expected password field plain value validators to be ignored, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -27,9 +27,9 @@ type RecordUpsert struct {
|
|||||||
accessLevel int
|
accessLevel int
|
||||||
|
|
||||||
// extra password fields
|
// extra password fields
|
||||||
Password string `form:"password" json:"password"`
|
password string
|
||||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
passwordConfirm string
|
||||||
OldPassword string `form:"oldPassword" json:"oldPassword"`
|
oldPassword string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRecordUpsert creates a new [RecordUpsert] form from the provided [core.App] and [core.Record] instances
|
// NewRecordUpsert creates a new [RecordUpsert] form from the provided [core.App] and [core.Record] instances
|
||||||
@ -93,13 +93,13 @@ func (form *RecordUpsert) Load(data map[string]any) {
|
|||||||
// load the special auth form fields
|
// load the special auth form fields
|
||||||
if isAuth {
|
if isAuth {
|
||||||
if v, ok := data["password"]; ok {
|
if v, ok := data["password"]; ok {
|
||||||
form.Password = cast.ToString(v)
|
form.password = cast.ToString(v)
|
||||||
}
|
}
|
||||||
if v, ok := data["passwordConfirm"]; ok {
|
if v, ok := data["passwordConfirm"]; ok {
|
||||||
form.PasswordConfirm = cast.ToString(v)
|
form.passwordConfirm = cast.ToString(v)
|
||||||
}
|
}
|
||||||
if v, ok := data["oldPassword"]; ok {
|
if v, ok := data["oldPassword"]; ok {
|
||||||
form.OldPassword = cast.ToString(v)
|
form.oldPassword = cast.ToString(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
excludeFields = append(excludeFields, "passwordConfirm", "oldPassword") // skip non-schema password fields
|
excludeFields = append(excludeFields, "passwordConfirm", "oldPassword") // skip non-schema password fields
|
||||||
@ -137,9 +137,9 @@ func (form *RecordUpsert) validateFormFields() error {
|
|||||||
validateData := map[string]any{
|
validateData := map[string]any{
|
||||||
"email": form.record.Email(),
|
"email": form.record.Email(),
|
||||||
"verified": form.record.Verified(),
|
"verified": form.record.Verified(),
|
||||||
"password": form.Password,
|
"password": form.password,
|
||||||
"passwordConfirm": form.PasswordConfirm,
|
"passwordConfirm": form.passwordConfirm,
|
||||||
"oldPassword": form.OldPassword,
|
"oldPassword": form.oldPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
return validation.Validate(validateData,
|
return validation.Validate(validateData,
|
||||||
@ -165,17 +165,17 @@ func (form *RecordUpsert) validateFormFields() error {
|
|||||||
validation.Key(
|
validation.Key(
|
||||||
"password",
|
"password",
|
||||||
validation.When(
|
validation.When(
|
||||||
(isNew || form.PasswordConfirm != "" || form.OldPassword != ""),
|
(isNew || form.passwordConfirm != "" || form.oldPassword != ""),
|
||||||
validation.Required,
|
validation.Required,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
validation.Key(
|
validation.Key(
|
||||||
"passwordConfirm",
|
"passwordConfirm",
|
||||||
validation.When(
|
validation.When(
|
||||||
(isNew || form.Password != "" || form.OldPassword != ""),
|
(isNew || form.password != "" || form.oldPassword != ""),
|
||||||
validation.Required,
|
validation.Required,
|
||||||
),
|
),
|
||||||
validation.By(validators.Equal(form.Password)),
|
validation.By(validators.Equal(form.password)),
|
||||||
),
|
),
|
||||||
validation.Key(
|
validation.Key(
|
||||||
"oldPassword",
|
"oldPassword",
|
||||||
@ -183,7 +183,7 @@ func (form *RecordUpsert) validateFormFields() error {
|
|||||||
// - form.HasManageAccess() is not satisfied
|
// - form.HasManageAccess() is not satisfied
|
||||||
// - changing the existing password
|
// - changing the existing password
|
||||||
validation.When(
|
validation.When(
|
||||||
!isNew && !form.HasManageAccess() && (form.Password != "" || form.PasswordConfirm != ""),
|
!isNew && !form.HasManageAccess() && (form.password != "" || form.passwordConfirm != ""),
|
||||||
validation.Required,
|
validation.Required,
|
||||||
validation.By(form.checkOldPassword),
|
validation.By(form.checkOldPassword),
|
||||||
),
|
),
|
||||||
|
@ -64,15 +64,15 @@ func TestRecordUpsertLoad(t *testing.T) {
|
|||||||
`"text":"test_text"`,
|
`"text":"test_text"`,
|
||||||
`"number":456`,
|
`"number":456`,
|
||||||
`"select_many":["optionB","optionC"]`,
|
`"select_many":["optionB","optionC"]`,
|
||||||
`"password":""`,
|
|
||||||
`"oldPassword":""`,
|
|
||||||
`"passwordConfirm":""`,
|
|
||||||
`"created":""`,
|
`"created":""`,
|
||||||
`"updated":""`,
|
`"updated":""`,
|
||||||
`"json":null`,
|
`"json":null`,
|
||||||
},
|
},
|
||||||
notExpected: []string{
|
notExpected: []string{
|
||||||
`"custom"`,
|
`"custom"`,
|
||||||
|
`"password"`,
|
||||||
|
`"oldPassword"`,
|
||||||
|
`"passwordConfirm"`,
|
||||||
`"select_many-"`,
|
`"select_many-"`,
|
||||||
`"select_many+"`,
|
`"select_many+"`,
|
||||||
},
|
},
|
||||||
@ -89,9 +89,11 @@ func TestRecordUpsertLoad(t *testing.T) {
|
|||||||
record: core.NewRecord(usersCol),
|
record: core.NewRecord(usersCol),
|
||||||
expected: []string{
|
expected: []string{
|
||||||
`"email":"test@example.com"`,
|
`"email":"test@example.com"`,
|
||||||
`"oldPassword":"123"`,
|
|
||||||
`"password":"456"`,
|
`"password":"456"`,
|
||||||
`"passwordConfirm":"789"`,
|
},
|
||||||
|
notExpected: []string{
|
||||||
|
`"oldPassword"`,
|
||||||
|
`"passwordConfirm"`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -110,8 +112,10 @@ func TestRecordUpsertLoad(t *testing.T) {
|
|||||||
`"email":"test@example.com"`,
|
`"email":"test@example.com"`,
|
||||||
`"tokenKey":""`,
|
`"tokenKey":""`,
|
||||||
`"password":"456"`,
|
`"password":"456"`,
|
||||||
`"oldPassword":"123"`,
|
},
|
||||||
`"passwordConfirm":"789"`,
|
notExpected: []string{
|
||||||
|
`"oldPassword"`,
|
||||||
|
`"passwordConfirm"`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -130,8 +134,10 @@ func TestRecordUpsertLoad(t *testing.T) {
|
|||||||
`"email":"test@example.com"`,
|
`"email":"test@example.com"`,
|
||||||
`"tokenKey":"abc"`,
|
`"tokenKey":"abc"`,
|
||||||
`"password":"456"`,
|
`"password":"456"`,
|
||||||
`"oldPassword":"123"`,
|
},
|
||||||
`"passwordConfirm":"789"`,
|
notExpected: []string{
|
||||||
|
`"oldPassword"`,
|
||||||
|
`"passwordConfirm"`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -168,11 +174,7 @@ func TestRecordUpsertLoad(t *testing.T) {
|
|||||||
|
|
||||||
form.Load(s.data)
|
form.Load(s.data)
|
||||||
|
|
||||||
loaded := map[string]any{
|
loaded := map[string]any{}
|
||||||
"oldPassword": form.OldPassword,
|
|
||||||
"password": form.Password,
|
|
||||||
"passwordConfirm": form.PasswordConfirm,
|
|
||||||
}
|
|
||||||
maps.Copy(loaded, s.record.FieldsData())
|
maps.Copy(loaded, s.record.FieldsData())
|
||||||
maps.Copy(loaded, s.record.CustomData())
|
maps.Copy(loaded, s.record.CustomData())
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user