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
This commit is contained in:
Jakub Zych
2026-10-03 07:12:00 +02:00
parent c258e9fade
commit d80d7990f6
5 changed files with 235 additions and 42 deletions

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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

121
controllers/registration.go Normal file
View File

@@ -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)
}

View File

@@ -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)