Every command gets long help and examples, descriptions in imperative sentence case, and a warn-only TestCommandsDocumented over bonfire.CheckDocs. The plugin logs through a diary component logger instead of the process default logger. Requires SummerCMS v0.4.0; command names and flags unchanged. user:token:create and user:token:list gain --json, user:backfill-handles a progress bar; planning IDs leave the descriptions.
194 lines
7.2 KiB
Go
194 lines
7.2 KiB
Go
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
|
|
// PasswordLess creates the account with has_self_set_password=false: its
|
|
// password is random and was never known to the user (social sign-in,
|
|
// PHP password_bootstrap_source oauth_registration). The zero value
|
|
// keeps true.
|
|
PasswordLess bool
|
|
}
|
|
|
|
// 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 = !opts.PasswordLess
|
|
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
|
|
}
|
|
if err := classes.AssignHandle(ctx, db, user); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return user, nil, nil
|
|
}
|
|
|
|
// FinishLogin runs the steps password Login takes after it has
|
|
// authenticated a user, for a caller that authenticated the user another way
|
|
// (a social sign-in). It restores a soft-deleted account and sends the
|
|
// reactivate mail, records ip as last_ip_address, and touches last_seen. It
|
|
// checks no password, throttle, activation or ban and fires no event; the
|
|
// caller does those checks (Go has no login event). A failed last_seen
|
|
// write is logged and does not fail it, as in Login.
|
|
func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *models.User, ip string) error {
|
|
if db == nil {
|
|
return errors.New("golem15.user: gorm handle is missing")
|
|
}
|
|
if user == nil || user.ID == 0 {
|
|
return errors.New("golem15.user: finishing a login needs a stored user")
|
|
}
|
|
if user.DeletedAt.Valid {
|
|
if err := db.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
|
|
return err
|
|
}
|
|
user.DeletedAt = gorm.DeletedAt{}
|
|
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
|
}
|
|
if ip != "" {
|
|
if err := db.WithContext(ctx).Unscoped().Model(user).Update("last_ip_address", ip).Error; err != nil {
|
|
return err
|
|
}
|
|
addr := ip
|
|
user.LastIPAddress = &addr
|
|
}
|
|
if err := classes.TouchLastSeen(ctx, db, user.ID); err != nil {
|
|
pluginLog.Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err)
|
|
}
|
|
return 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})
|
|
}
|
|
|
|
// FireCreatedEvent fires classes.CreatedEvent (user.Created) for user and
|
|
// returns the listeners' error. /register ignores that error, as it does
|
|
// for FireRegisterEvent.
|
|
func FireCreatedEvent(ctx context.Context, app *backpack.App, user *models.User) error {
|
|
if app == nil || app.Events == nil {
|
|
return nil
|
|
}
|
|
return app.Events.Fire(ctx, &classes.CreatedEvent{User: user})
|
|
}
|
|
|
|
// FireDeletingEvent fires classes.DeletingEvent (user.Deleting) for user
|
|
// inside the delete transaction. FormAfterDelete returns the listeners'
|
|
// error so a failed cascade rolls the delete back.
|
|
func FireDeletingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error {
|
|
if app == nil || app.Events == nil {
|
|
return nil
|
|
}
|
|
return app.Events.Fire(ctx, &classes.DeletingEvent{User: user, Tx: tx})
|
|
}
|
|
|
|
// FireDeactivatingEvent fires classes.DeactivatingEvent (user.Deactivating)
|
|
// for user inside the deactivation transaction and returns the listeners'
|
|
// error, so a failed listener rolls the deactivation back.
|
|
func FireDeactivatingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error {
|
|
if app == nil || app.Events == nil {
|
|
return nil
|
|
}
|
|
return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx})
|
|
}
|
|
|
|
// 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)
|
|
}
|