From d80d7990f6f8b743c4df2f0fa9538d9e2acf780e Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 3 Oct 2026 07:12:00 +0200 Subject: [PATCH] feat: carry the register input on RegisterEvent and export the registration steps - RegisterEvent.Payload: a copy of the register input without password and password_confirmation (Winter's golem15.user.register data array) - RegisterUser, RegisterOptions, FireRegisterEvent, IssueToken and APIArray: the steps POST /register runs, now called by Register itself; its status codes and bodies are unchanged - README documents the exports and Payload --- README.md | 10 ++- classes/events.go | 9 ++- controllers/api_controller.go | 44 ++----------- controllers/registration.go | 121 ++++++++++++++++++++++++++++++++++ register_test.go | 93 ++++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 42 deletions(-) create mode 100644 controllers/registration.go diff --git a/README.md b/README.md index 340fe5c..ff0dfb5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `golem15 - **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. - **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No route of this plugin reads or writes groups, and the user payload still answers `"groups":[]`. - **Mail templates.** Activation, reset and reactivation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). -- **Extension events.** `RegisterEvent` fires after a user row is created; `GetApiArrayEvent` lets other plugins merge keys into the user payload. +- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never). `GetApiArrayEvent` lets other plugins merge keys into the user payload. +- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload. - **OAuth providers.** `GET /oauth-providers` lists configured social login providers (none yet: it returns an empty list). ## Usage @@ -92,7 +93,12 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle Packages: -- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserGroupCodes` and `HasGroupCode`, the activation and reset code helpers, the throttle helpers, mail locale helpers and the two events. +- `controllers`: the route handlers above, plus the registration steps `POST /register` runs, for other plugins: + - `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP. + - `FireRegisterEvent(ctx, app, user, fields)` fires `RegisterEvent` with `Payload` set to a copy of `fields` without `password` and `password_confirmation`, and returns the listeners' error (`/register` ignores it). Fire it after the user row is committed. + - `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do. + - `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners. +- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserGroupCodes` and `HasGroupCode`, the activation and reset code helpers, the throttle helpers, mail locale helpers and the two events. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. - `models`: `User`, `Throttle`, `Organisation`, `UserGroup`. - `updates`: the gormigrate migrations, returned by `updates.All()`. - `console`: `RequirePasswordChange`, the `user:require-password-change` command. diff --git a/classes/events.go b/classes/events.go index 2ddae87..fc75496 100644 --- a/classes/events.go +++ b/classes/events.go @@ -16,7 +16,14 @@ func (e *GetApiArrayEvent) Collected() map[string]any { return e.data } -// RegisterEvent is fired after a user row is created. No listener consumes it yet. +// RegisterEvent is fired after a user row is created (Winter's +// golem15.user.register). type RegisterEvent struct { User *models.User + // Payload is a copy of the register input, mirroring the data array + // Winter passes with golem15.user.register, so a listener can read + // extra keys a client sent (an invitation token, for example). + // password and password_confirmation are never included, in plaintext + // or hashed. Listeners must treat it as read-only input. + Payload map[string]any } diff --git a/controllers/api_controller.go b/controllers/api_controller.go index a4d5e15..4228893 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -601,7 +601,7 @@ func MarketingConsent(app *backpack.App) http.HandlerFunc { // Register creates an account and follows activate_mode: auto, user, or admin. func Register(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - gdb, secret, ok := sessionDeps(w, app) + gdb, _, ok := sessionDeps(w, app) if !ok { return } @@ -623,8 +623,7 @@ func Register(app *backpack.App) http.HandlerFunc { if _, exists := fields["password_confirmation"]; !exists { fields["password_confirmation"] = fields["password"] } - rules := (models.User{}).Rules() - errs, err := lagoon.Validate(r.Context(), gdb, models.User{}, rules, fields, appTranslator(app)) + user, errs, err := RegisterUser(r.Context(), app, gdb, fields, RegisterOptions{Activate: mode == "auto", IP: ip}) if err != nil { writeOpaque500(w) return @@ -636,40 +635,7 @@ func Register(app *backpack.App) http.HandlerFunc { }) return } - password := strField(fields, "password") - cost := 10 - if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 { - cost = app.Config.Int("golem15.user.password.bcrypt_cost") - } - hashed, err := bouncer.HashPassword(cost, password) - if err != nil { - writeOpaque500(w) - return - } - fields["password"] = hashed - if ip != "" { - fields["created_ip_address"] = ip - fields["last_ip_address"] = ip - } - user := &models.User{} - production := app.Config == nil || !app.Config.Bool("app.debug") - if err := lagoon.Fill(user, user.Fillable(), fields, production); err != nil { - writeOpaque500(w) - return - } - user.HasSelfSetPassword = true - if mode == "auto" { - user.IsActivated = true - now := time.Now() - user.ActivatedAt = &now - } - if err := gdb.WithContext(r.Context()).Create(user).Error; err != nil { - writeOpaque500(w) - return - } - if app.Events != nil { - _ = app.Events.Fire(r.Context(), &classes.RegisterEvent{User: user}) - } + _ = FireRegisterEvent(r.Context(), app, user, fields) ctx := r.Context() if mode == "user" { vars := mailVars(displayName(user), "", "") @@ -685,12 +651,12 @@ func Register(app *backpack.App) http.HandlerFunc { return } if mode == "auto" || !require { - token, err := mintFor(app, secret, user, requestURL(r)) + token, err := IssueToken(app, user, r) if err != nil { writeOpaque500(w) return } - payload, err := apiArray(ctx, app, user) + payload, err := APIArray(ctx, app, user) if err != nil { writeOpaque500(w) return diff --git a/controllers/registration.go b/controllers/registration.go new file mode 100644 index 0000000..6575805 --- /dev/null +++ b/controllers/registration.go @@ -0,0 +1,121 @@ +package controllers + +import ( + "context" + "errors" + "maps" + "net/http" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/lagoon" + "gorm.io/gorm" +) + +// The registration steps of POST /register, exported so another plugin can +// create a user exactly as /register does (same validation, password +// hashing, register event, token and user payload) without duplicating +// them. Register itself calls these functions. + +// RegisterOptions tunes RegisterUser. Activate creates the account already +// activated (activate_mode auto, or a caller that activates explicitly). +// IP, when set, is recorded as created_ip_address and last_ip_address. +type RegisterOptions struct { + Activate bool + IP string +} + +// RegisterUser validates fields against the user model's rules, hashes the +// password with golem15.user.password.bcrypt_cost (default 10), fills the +// fillable columns and creates the users row on db, which may be a +// transaction. Validation failures are returned as Laravel's errors object +// (attribute to messages) with a nil user and nothing written; they are not +// written to a response. fields is not modified. The register event is not +// fired: call FireRegisterEvent once the row is committed. +func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields map[string]any, opts RegisterOptions) (*models.User, map[string][]string, error) { + if db == nil { + return nil, nil, errors.New("golem15.user: gorm handle is missing") + } + input := maps.Clone(fields) + if input == nil { + input = map[string]any{} + } + errs, err := lagoon.Validate(ctx, db, models.User{}, (models.User{}).Rules(), input, appTranslator(app)) + if err != nil { + return nil, nil, err + } + if len(errs) > 0 { + return nil, errs, nil + } + cost := 10 + if app != nil && app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 { + cost = app.Config.Int("golem15.user.password.bcrypt_cost") + } + hashed, err := bouncer.HashPassword(cost, strField(input, "password")) + if err != nil { + return nil, nil, err + } + input["password"] = hashed + if opts.IP != "" { + input["created_ip_address"] = opts.IP + input["last_ip_address"] = opts.IP + } + user := &models.User{} + production := app == nil || app.Config == nil || !app.Config.Bool("app.debug") + if err := lagoon.Fill(user, user.Fillable(), input, production); err != nil { + return nil, nil, err + } + user.HasSelfSetPassword = true + if opts.Activate { + user.IsActivated = true + now := time.Now() + user.ActivatedAt = &now + } + if err := db.WithContext(ctx).Create(user).Error; err != nil { + return nil, nil, err + } + return user, nil, nil +} + +// FireRegisterEvent fires classes.RegisterEvent for user with Payload set +// to a copy of fields without password and password_confirmation, and +// returns the listeners' error. /register ignores that error, as Winter +// does not stop a registration for a failing listener. +func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User, fields map[string]any) error { + if app == nil || app.Events == nil { + return nil + } + payload := maps.Clone(fields) + if payload == nil { + payload = map[string]any{} + } + delete(payload, "password") + delete(payload, "password_confirmation") + return app.Events.Fire(ctx, &classes.RegisterEvent{User: user, Payload: payload}) +} + +// IssueToken mints the session JWT for user exactly as /register and +// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request +// URL as the issuer. +func IssueToken(app *backpack.App, user *models.User, r *http.Request) (string, error) { + if user == nil || r == nil { + return "", errors.New("golem15.user: a token needs a user and a request") + } + secret, err := classes.JWTSecret(app) + if err != nil { + return "", err + } + return mintFor(app, secret, user, requestURL(r)) +} + +// APIArray is the user payload the API answers (getApiArray in PHP), +// extended by GetApiArrayEvent listeners. +func APIArray(ctx context.Context, app *backpack.App, user *models.User) (map[string]any, error) { + if user == nil { + return nil, errors.New("golem15.user: the user payload needs a user") + } + return apiArray(ctx, app, user) +} diff --git a/register_test.go b/register_test.go index 7612a5b..e1c4117 100644 --- a/register_test.go +++ b/register_test.go @@ -155,6 +155,99 @@ func TestRegisterEvent(t *testing.T) { } } +// TestRegisterEventPayload: a listener sees the register input, extra keys +// included, and never password or password_confirmation; a listener that +// only reads User still compiles and runs; the exported steps register, +// fire, mint and serialize exactly as /register does. +func TestRegisterEventPayload(t *testing.T) { + app, gdb := sessionApp(t, "0s") + disableRegisterThrottle(t, app) + var got map[string]any + var userOnly string + app.Events.Listen[*classes.RegisterEvent]("payload-test", func(_ context.Context, e *classes.RegisterEvent) error { + got = e.Payload + return nil + }) + // A listener written before Payload existed: it reads User only. + app.Events.Listen[*classes.RegisterEvent]("user-only-test", func(_ context.Context, e *classes.RegisterEvent) error { + userOnly = e.User.Email + return nil + }) + body := `{"email":"payload@example.test","password":"correct-horse","password_confirmation":"correct-horse","name":"Pay","invitation_token":"abc123"}` + rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", body) + if rec.Code != http.StatusOK { + t.Fatalf("register %d %s", rec.Code, rec.Body.String()) + } + if userOnly != "payload@example.test" { + t.Fatalf("User-only listener saw %q", userOnly) + } + if got["email"] != "payload@example.test" || got["invitation_token"] != "abc123" || got["name"] != "Pay" { + t.Fatalf("payload = %#v", got) + } + for _, k := range []string{"password", "password_confirmation"} { + if _, ok := got[k]; ok { + t.Fatalf("payload carries %s: %#v", k, got) + } + } + for _, v := range got { + if s, ok := v.(string); ok && (strings.Contains(s, "correct-horse") || strings.HasPrefix(s, "$2")) { + t.Fatalf("payload carries a password or hash: %#v", got) + } + } + // Without an explicit confirmation /register defaults it; neither key + // reaches the payload. + got = nil + if rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"payload2@example.test","password":"correct-horse"}`); rec.Code != http.StatusOK { + t.Fatalf("register %d %s", rec.Code, rec.Body.String()) + } + if len(got) != 1 || got["email"] != "payload2@example.test" { + t.Fatalf("payload = %#v", got) + } + + // The exported steps: RegisterUser leaves the input untouched and + // returns validation errors without writing; FireRegisterEvent strips + // the passwords; IssueToken and APIArray match /register. + fields := map[string]any{"email": "exported@example.test", "password": "correct-horse", "password_confirmation": "correct-horse", "extra": "x"} + user, errs, err := controllers.RegisterUser(t.Context(), app, gdb, fields, controllers.RegisterOptions{Activate: true, IP: "10.1.2.3"}) + if err != nil || len(errs) != 0 || user == nil { + t.Fatalf("RegisterUser = %v, %v, %v", user, errs, err) + } + if fields["password"] != "correct-horse" { + t.Fatalf("RegisterUser modified the input: %#v", fields) + } + var stored models.User + if err := gdb.Where("id = ?", user.ID).Take(&stored).Error; err != nil { + t.Fatal(err) + } + if !stored.IsActivated || stored.ActivatedAt == nil || !stored.HasSelfSetPassword || stored.Password == "correct-horse" || + stored.CreatedIPAddress == nil || *stored.CreatedIPAddress != "10.1.2.3" { + t.Fatalf("stored user = %+v", stored) + } + got = nil + if err := controllers.FireRegisterEvent(t.Context(), app, user, fields); err != nil { + t.Fatal(err) + } + if len(got) != 2 || got["extra"] != "x" || got["email"] != "exported@example.test" { + t.Fatalf("FireRegisterEvent payload = %#v", got) + } + req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/register", nil) + token, err := controllers.IssueToken(app, user, req) + if err != nil || token == "" { + t.Fatalf("IssueToken = %q, %v", token, err) + } + if fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token); fetch.Code != http.StatusOK { + t.Fatalf("fetch with the issued token %d %s", fetch.Code, fetch.Body.String()) + } + payload, err := controllers.APIArray(t.Context(), app, user) + if err != nil || payload["email"] != "exported@example.test" || payload["is_activated"] != true { + t.Fatalf("APIArray = %#v, %v", payload, err) + } + _, errs, err = controllers.RegisterUser(t.Context(), app, gdb, map[string]any{"email": "exported@example.test", "password": "correct-horse"}, controllers.RegisterOptions{}) + if err != nil || len(errs) == 0 { + t.Fatalf("a duplicate email must fail validation: %v %v", errs, err) + } +} + func TestRegisterImportDirection(t *testing.T) { cmd := exec.Command("go", "list", "-deps", "./...") cmd.Dir = moduleRoot(t)