feat(07-02): serve login, logout, fetch, and refresh
Wire the user API session loop to the JWT blacklist and a pre-password throttle gate so a logged-out token cannot be fetched again. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
96
plugin.go
96
plugin.go
@@ -1,8 +1,14 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes"
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
|
||||
@@ -11,6 +17,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
"git.golem15.com/golem15/summercms/surf"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -21,15 +28,18 @@ var (
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasMiddleware = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasRoutes = (*Plugin)(nil)
|
||||
_ surf.BucketProvider = (*Plugin)(nil)
|
||||
_ bouncer.UserProvider = classes.GormUsers{}
|
||||
)
|
||||
|
||||
//go:embed config
|
||||
var configFS embed.FS
|
||||
|
||||
// Plugin is the stub golem15.user plugin.
|
||||
// Plugin is the golem15.user plugin.
|
||||
type Plugin struct {
|
||||
app *backpack.App
|
||||
app *backpack.App
|
||||
sweepStop chan struct{}
|
||||
}
|
||||
|
||||
func (p *Plugin) ID() string { return "golem15.user" }
|
||||
@@ -58,7 +68,87 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, nil))
|
||||
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)
|
||||
}
|
||||
return reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token"))
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user