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:
10
README.md
10
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
121
controllers/registration.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user