feat(09-02): implement backend JWT lifecycle, throttle, and auth logs

- Refresh, logout, and me use a separate PostgreSQL jti blacklist and safe profile
- Login stamps last_login only after a successful check and throttles repeated attempts
This commit is contained in:
Jakub Zych
2026-09-24 17:50:41 +02:00
parent 0953308e26
commit 9740c3dcdb
2 changed files with 245 additions and 20 deletions

View File

@@ -3,6 +3,7 @@ package cabana
import (
"context"
"errors"
"fmt"
"net/http"
"reflect"
"strconv"
@@ -23,11 +24,17 @@ type Routes struct {
}
type service struct {
app *backpack.App
reg *Registry
secret string
ttl time.Duration
issuer string
app *backpack.App
reg *Registry
secret string
ttl time.Duration
refreshTTL time.Duration
grace time.Duration
bcryptCost int
loginMax int
loginDecay int
issuer string
bl bouncer.BlacklistStore
}
// Activate compiles admin controllers and, when any exist, requires
@@ -58,10 +65,7 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
return nil, err
}
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
}
bl := adminBlacklist(app)
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app}, bl, writeUnauthenticated)
if _, err := guards.Middleware("backend"); err != nil {
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
@@ -72,12 +76,19 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err != nil {
return nil, err
}
loginMax, loginDecay := adminLoginWindow(app)
svc := &service{
app: app,
reg: reg,
secret: secret,
ttl: adminTTL(app),
issuer: adminIssuer(app),
app: app,
reg: reg,
secret: secret,
ttl: adminTTL(app),
refreshTTL: adminRefreshTTL(app),
grace: adminGrace(app),
bcryptCost: adminBcryptCost(app),
loginMax: loginMax,
loginDecay: loginDecay,
issuer: adminIssuer(app),
bl: bl,
}
return &Routes{Middleware: mw, Mount: svc.mount}, nil
}
@@ -102,10 +113,14 @@ func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Princ
}
func (s *service) mount(r pact.Router) {
throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay)
r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) {
g.Post("/login", s.login)
g.Post("/login", s.login, throttle)
g.Post("/refresh", s.refresh)
})
r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) {
g.Post("/auth/logout", s.logout)
g.Get("/auth/me", s.me)
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}", s.list)
@@ -182,6 +197,7 @@ func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*Compi
return
}
if !Allows(principal, requiredOf(cc.Controller)) {
s.logAuth(r, "denied", principal.ID)
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}