Files
sm-user-plugin/plugin.go
Jakub Zych f7ca3c4f59 feat: create users with a password and an invitation in the admin
- password with confirmation on create, optional reset on update that
  ends the user's sessions; the hash is never returned
- send_invite sends one invitation with an activation link after the
  create commits
- organisation picker, avatar upload shared with the user API and the
  frontend permission editor on the user form
- an email held by a deactivated user answers 422 instead of failing
  on the unique constraint
- the privileged-member guard now also refuses a submitted password
2026-10-05 13:21:51 +02:00

239 lines
6.1 KiB
Go

package user
import (
"context"
"database/sql"
"embed"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/console"
usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/sm-user-plugin/updates"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasMiddleware = (*Plugin)(nil)
_ pact.HasMiddlewareFactories = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasRoutes = (*Plugin)(nil)
_ pact.HasMailTemplates = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
_ pact.HasCommands = (*Plugin)(nil)
_ surf.BucketProvider = (*Plugin)(nil)
_ bouncer.UserProvider = classes.GormUsers{}
)
//go:embed config
var configFS embed.FS
//go:embed views/mail
var mailFS embed.FS
//go:embed lang
var langFS embed.FS
// Plugin is the golem15.user plugin.
type Plugin struct {
app *backpack.App
sweepStop chan struct{}
}
func (p *Plugin) ID() string { return "golem15.user" }
func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(app *backpack.App) error {
p.app = app
return nil
}
func (p *Plugin) Boot(app *backpack.App) error {
p.app = app
secret, err := classes.JWTSecret(app)
if err != nil {
return err
}
if gdb, ok := app.Lookup[*gorm.DB](); ok {
if err := classes.RegisterHooks(gdb); err != nil {
return err
}
}
reg, ok := app.Lookup[*bouncer.Registry]()
if !ok || reg == nil {
reg = bouncer.NewRegistry()
if err := app.Publish(reg); err != nil {
return err
}
}
var bl bouncer.BlacklistStore
if sqlDB, ok := app.Lookup[*sql.DB](); ok && sqlDB != nil {
bl = bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")
} else if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil {
if sqlDB, err := gdb.DB(); err == nil && sqlDB != nil {
bl = bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")
}
}
if bl != nil {
if err := app.Publish[bouncer.BlacklistStore](bl); err != nil {
return err
}
p.startBlacklistSweep(bl)
}
if err := reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token")); err != nil {
return err
}
prefix, err := classes.APITokenPrefix(app)
if err != nil {
return err
}
trusted := surf.TrustedProxies(app.Config)
return reg.Register(p.ID(), "api_token", &usermiddleware.APITokenGuard{
App: app, Prefix: prefix,
ClientIP: func(r *http.Request) string { return surf.ClientIP(r, trusted) },
})
}
func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
interval := 10 * time.Minute
if p.app != nil && p.app.Config != nil {
raw := p.app.Config.String("golem15.user.jwt.blacklist_sweep_interval")
if raw != "" {
parsed, err := time.ParseDuration(raw)
if err != nil || parsed <= 0 {
return
}
interval = parsed
}
}
if p.sweepStop != nil {
return
}
p.sweepStop = make(chan struct{})
stop := p.sweepStop
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := bl.Sweep(context.Background(), time.Now()); err != nil {
slog.Default().Error("golem15.user: jwt blacklist sweep", "err", err)
}
case <-stop:
return
}
}
}()
}
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(nil)
if p.app != nil {
trusted = surf.TrustedProxies(p.app.Config)
}
secret := ""
if p.app != nil {
secret, _ = classes.JWTSecret(p.app)
}
return map[string]surf.Bucket{
"user-api": {
Max: 120,
Decay: time.Minute,
Key: func(r *http.Request) string {
if sub, err := bouncer.Verify(bearerFrom(r), secret); err == nil {
return "u:" + sub
}
return surf.ClientIP(r, trusted)
},
},
}
}
func bearerFrom(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization"))
token, ok := strings.CutPrefix(h, "Bearer ")
token = strings.TrimSpace(token)
if !ok || token == "" {
return ""
}
return token
}
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func (p *Plugin) LangFS() fs.FS { return langFS }
func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS }
func (p *Plugin) MailTemplates() []string {
return []string{
"golem15.user::mail.activate",
"golem15.user::mail.activate-en",
"golem15.user::mail.restore",
"golem15.user::mail.restore-en",
"golem15.user::mail.reactivate",
"golem15.user::mail.reactivate-en",
"golem15.user::mail.invite",
"golem15.user::mail.invite-en",
}
}
func (p *Plugin) MailLayouts() map[string]string {
return map[string]string{
"user": "golem15.user::mail.layouts.user",
}
}
func (p *Plugin) Commands() []bonfire.Command {
return append([]bonfire.Command{console.RequirePasswordChange(p.app)}, console.APITokenCommands(p.app)...)
}
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
func (p *Plugin) Models() []any { return models.All() }
func (p *Plugin) Middlewares() map[string]pact.Middleware {
if p.app == nil {
return nil
}
reg, ok := p.app.Lookup[*bouncer.Registry]()
if !ok || reg == nil {
return nil
}
out := map[string]pact.Middleware{}
if mw, err := reg.Middleware("jwt"); err == nil {
out["jwt.auth"] = mw
}
if mw, err := reg.Middleware("api_token"); err == nil {
out["user.api_token"] = mw
}
return out
}
func (p *Plugin) MiddlewareFactories() map[string]func(string) pact.Middleware {
return map[string]func(string) pact.Middleware{
"user.scope": usermiddleware.Scope,
"user.permission": usermiddleware.Permission,
}
}
func init() {
party.Register(&Plugin{})
}