diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e32dc89..e914e08f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ - Added Bitbucket OAuth2 provider ([#3948](https://github.com/pocketbase/pocketbase/pull/3948); thanks @aabajyan). +- Mark user as verified on confirm password reset ([#4066](https://github.com/pocketbase/pocketbase/issues/4066)). + _If the user email has changed after issuing the reset token (eg. updated from the Admin UI), then the `verified` user state remains unchanged._ + - Added `TestMailer.SentMessages` field that holds all sent test app emails until cleanup. - Minor Admin UI improvements (reduced the min table row height, added new TinyMCE codesample languages, etc.) diff --git a/apis/record_auth_test.go b/apis/record_auth_test.go index 95b93da2..37fe1119 100644 --- a/apis/record_auth_test.go +++ b/apis/record_auth_test.go @@ -644,7 +644,7 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) { }, }, { - Name: "valid token and data", + Name: "valid token and data (unverified user)", Method: http.MethodPost, Url: "/api/collections/users/confirm-password-reset", Body: strings.NewReader(`{ @@ -659,6 +659,132 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) { "OnRecordBeforeConfirmPasswordResetRequest": 1, "OnRecordAfterConfirmPasswordResetRequest": 1, }, + BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatalf("Failed to fetch confirm password user: %v", err) + } + + if user.Verified() { + t.Fatalf("Expected the user to be unverified") + } + }, + AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) { + user, err := app.Dao().FindAuthRecordByToken( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", + app.Settings().RecordPasswordResetToken.Secret, + ) + if err == nil { + t.Fatalf("Expected the password reset token to be invalidated") + } + + user, err = app.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatalf("Failed to fetch confirm password user: %v", err) + } + + if !user.Verified() { + t.Fatalf("Expected the user to be marked as verified") + } + }, + }, + { + Name: "valid token and data (unverified user with different email from the one in the token)", + Method: http.MethodPost, + Url: "/api/collections/users/confirm-password-reset", + Body: strings.NewReader(`{ + "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", + "password":"12345678", + "passwordConfirm":"12345678" + }`), + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnModelAfterUpdate": 1, + "OnModelBeforeUpdate": 1, + "OnRecordBeforeConfirmPasswordResetRequest": 1, + "OnRecordAfterConfirmPasswordResetRequest": 1, + }, + BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatalf("Failed to fetch confirm password user: %v", err) + } + + if user.Verified() { + t.Fatalf("Expected the user to be unverified") + } + + // manually change the email to check whether the verified state will be updated + user.SetEmail("test_update@example.com") + if err := app.Dao().WithoutHooks().SaveRecord(user); err != nil { + t.Fatalf("Failed to update user test email") + } + }, + AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) { + user, err := app.Dao().FindAuthRecordByToken( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", + app.Settings().RecordPasswordResetToken.Secret, + ) + if err == nil { + t.Fatalf("Expected the password reset token to be invalidated") + } + + user, err = app.Dao().FindAuthRecordByEmail("users", "test_update@example.com") + if err != nil { + t.Fatalf("Failed to fetch confirm password user: %v", err) + } + + if user.Verified() { + t.Fatalf("Expected the user to remain unverified") + } + }, + }, + { + Name: "valid token and data (verified user)", + Method: http.MethodPost, + Url: "/api/collections/users/confirm-password-reset", + Body: strings.NewReader(`{ + "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", + "password":"12345678", + "passwordConfirm":"12345678" + }`), + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnModelAfterUpdate": 1, + "OnModelBeforeUpdate": 1, + "OnRecordBeforeConfirmPasswordResetRequest": 1, + "OnRecordAfterConfirmPasswordResetRequest": 1, + }, + BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatalf("Failed to fetch confirm password user: %v", err) + } + + // ensure that the user is already verified + user.SetVerified(true) + if err := app.Dao().WithoutHooks().SaveRecord(user); err != nil { + t.Fatalf("Failed to update user verified state") + } + }, + AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) { + user, err := app.Dao().FindAuthRecordByToken( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", + app.Settings().RecordPasswordResetToken.Secret, + ) + if err == nil { + t.Fatalf("Expected the password reset token to be invalidated") + } + + user, err = app.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatalf("Failed to fetch confirm password user: %v", err) + } + + if !user.Verified() { + t.Fatalf("Expected the user to remain verified") + } + }, }, { Name: "OnRecordAfterConfirmPasswordResetRequest error response", diff --git a/forms/record_password_reset_confirm.go b/forms/record_password_reset_confirm.go index e79a21f0..370322cd 100644 --- a/forms/record_password_reset_confirm.go +++ b/forms/record_password_reset_confirm.go @@ -6,6 +6,8 @@ import ( "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/forms/validators" "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/security" + "github.com/spf13/cast" ) // RecordPasswordResetConfirm is an auth record password reset confirmation form. @@ -91,9 +93,21 @@ func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[* return nil, err } + if !authRecord.Verified() { + payload, err := security.ParseUnverifiedJWT(form.Token) + if err != nil { + return nil, err + } + + // mark as verified if the email hasn't changed + if authRecord.Email() == cast.ToString(payload["email"]) { + authRecord.SetVerified(true) + } + } + interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error { authRecord = m - return form.dao.SaveRecord(m) + return form.dao.SaveRecord(authRecord) }, interceptors...) if interceptorsErr != nil {