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:
Jakub Zych
2026-09-22 14:40:18 +02:00
parent 00e6c51abd
commit 6bceb8167c
8 changed files with 1187 additions and 19 deletions

View File

@@ -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 }