- 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
239 lines
6.1 KiB
Go
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{})
|
|
}
|