feat(16.3-01): interactive-only gates, ResolveBackend, last_used stamp
- Refuse g15_ tokens on account/avatar/refresh/logout; gate field-types and markdown - Publish cookie-less ResolveBackend; debounce last_used_at once per minute - Reserve account vendor segment; TestTokenGatesSmoke covers the deny path Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,7 +6,9 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
@@ -96,9 +98,10 @@ func issueAPIToken(db *gorm.DB, ownerID uint, name string, scopes []string, expi
|
||||
return plain, row, nil
|
||||
}
|
||||
|
||||
// tokenStore looks up usable personal API tokens by hash.
|
||||
// tokenStore looks up usable personal API tokens by hash and stamps last_used_at.
|
||||
type tokenStore interface {
|
||||
FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error)
|
||||
StampLastUsed(ctx context.Context, id uint, now time.Time) error
|
||||
}
|
||||
|
||||
type gormTokenStore struct {
|
||||
@@ -122,3 +125,33 @@ func (s gormTokenStore) FindUsableByHash(ctx context.Context, hash string, now t
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s gormTokenStore) StampLastUsed(ctx context.Context, id uint, now time.Time) error {
|
||||
if s.db == nil || id == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).Exec(
|
||||
`UPDATE backend_api_tokens SET last_used_at = ?
|
||||
WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
|
||||
now, id, now.Add(-time.Minute),
|
||||
).Error
|
||||
}
|
||||
|
||||
// lastStamp gates last_used_at writes to at most once per minute per token id
|
||||
// in this process; the SQL UPDATE is additionally conditional.
|
||||
var lastStamp sync.Map // uint -> time.Time
|
||||
|
||||
func stampLastUsed(ctx context.Context, store tokenStore, id uint, now time.Time) {
|
||||
if store == nil || id == 0 {
|
||||
return
|
||||
}
|
||||
if v, ok := lastStamp.Load(id); ok {
|
||||
if now.Sub(v.(time.Time)) < time.Minute {
|
||||
return
|
||||
}
|
||||
}
|
||||
lastStamp.Store(id, now)
|
||||
if err := store.StampLastUsed(ctx, id, now); err != nil {
|
||||
slog.Default().WarnContext(ctx, "cabana: token last_used_at stamp failed", "token_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
if !tokenRouteGate(principal, w) {
|
||||
return
|
||||
}
|
||||
var body AdminMarkdownPreviewRequest
|
||||
if err := s.decodeStrictBody(w, r, &body); err != nil {
|
||||
writeCRUDError(w, err)
|
||||
|
||||
@@ -867,6 +867,9 @@ func (s *service) fieldTypesList(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
if !tokenRouteGate(principal, w) {
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, s.fieldTypeAssets(), nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,14 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
} else if owner != "summercms.cabana" {
|
||||
return nil, fmt.Errorf("cabana: backend guard: guard %q is already registered by %s; the admin API needs its own audience-checking guard under that name", "backend", owner)
|
||||
}
|
||||
// Cookie-less resolver for plugin routes (Bearer JWT or g15_ token only).
|
||||
if _, published := app.Lookup[*backendResolver](); !published {
|
||||
cookieLessJWT := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated)
|
||||
resolverGuard := newCompositeGuard(cookieLessJWT, store, users, reg)
|
||||
if err := app.Publish(&backendResolver{guard: resolverGuard}); err != nil {
|
||||
return nil, fmt.Errorf("cabana: backend resolver: %w", err)
|
||||
}
|
||||
}
|
||||
mw, err := guards.Middleware("backend")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -241,11 +249,11 @@ func (s *service) mount(r pact.Router) {
|
||||
// Login is exempt from the CSRF header: without it the response is a
|
||||
// Bearer body and no cookie is set, so a cross-site post gains nothing.
|
||||
g.Post("/login", s.login, throttle)
|
||||
g.Post("/refresh", requireAjax(s.refresh))
|
||||
g.Post("/refresh", interactiveOnly(requireAjax(s.refresh)))
|
||||
// Logout reads and verifies the token itself (see service.logout), so
|
||||
// it is mounted outside the backend guard, which rejects an expired
|
||||
// access token that is still refreshable.
|
||||
g.Post("/logout", requireAjax(s.logout))
|
||||
g.Post("/logout", interactiveOnly(requireAjax(s.logout)))
|
||||
})
|
||||
// The string bundle is public: the login screen needs it before auth.
|
||||
r.GroupRaw(api, nil, func(g pact.Router) {
|
||||
@@ -254,9 +262,10 @@ func (s *service) mount(r pact.Router) {
|
||||
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
||||
g.Get("/auth/me", s.me)
|
||||
// My account: the signed-in admin edits their own profile.
|
||||
g.Put("/account", requireAjax(s.accountUpdate))
|
||||
g.Post("/account/avatar", requireAjax(s.avatarUpload))
|
||||
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
|
||||
// interactiveOnly refuses personal API tokens before requireAjax.
|
||||
g.Put("/account", interactiveOnly(requireAjax(s.accountUpdate)))
|
||||
g.Post("/account/avatar", interactiveOnly(requireAjax(s.avatarUpload)))
|
||||
g.Delete("/account/avatar", interactiveOnly(requireAjax(s.avatarRemove)))
|
||||
g.Get("/navigation", s.navigation)
|
||||
// Plugin field types (pact.HasFormFieldTypes): the SPA loads their
|
||||
// scripts and styles once after sign-in.
|
||||
|
||||
@@ -41,14 +41,14 @@ func collectControllers(plugins []party.Plugin) ([]controllerRef, error) {
|
||||
// reservedVendorSegments are the first path segments under the admin prefix
|
||||
// that the SPA and API own. A controller ID maps to /{vendor}/{plugin}/..., so
|
||||
// a vendor with one of these names would collide with them.
|
||||
var reservedVendorSegments = map[string]bool{"api": true, "assets": true, "login": true, "settings": true}
|
||||
var reservedVendorSegments = map[string]bool{"api": true, "assets": true, "login": true, "settings": true, "account": true}
|
||||
|
||||
func checkReservedSegments(items []controllerRef) error {
|
||||
for _, item := range items {
|
||||
id := item.ctl.ID()
|
||||
vendor, _, _ := strings.Cut(id, ".")
|
||||
if reservedVendorSegments[vendor] {
|
||||
return fmt.Errorf("cabana: controller %s uses the reserved admin path segment %q (reserved: api, assets, login, settings)", id, vendor)
|
||||
return fmt.Errorf("cabana: controller %s uses the reserved admin path segment %q (reserved: api, assets, login, settings, account)", id, vendor)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
@@ -114,6 +115,7 @@ func (g *compositeGuard) authenticateToken(r *http.Request, raw string) (*bounce
|
||||
return nil, nil, newScopeError()
|
||||
}
|
||||
principal.Scope = limit
|
||||
stampLastUsed(r.Context(), g.store, row.ID, now)
|
||||
logTokenAuth(r, "success", principal.ID, row.ID)
|
||||
return principal, row, nil
|
||||
}
|
||||
@@ -141,6 +143,84 @@ func authorizationBearer(r *http.Request) string {
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
// interactiveOnly refuses personal API tokens (credential or raw g15_ Bearer)
|
||||
// with 403 forbidden details.reason interactive_only. Used on account, token,
|
||||
// refresh and logout routes that must stay session/JWT-only.
|
||||
func interactiveOnly(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if cred, _ := bouncer.Credential(r.Context()); cred != nil {
|
||||
if _, ok := cred.(*BackendAPIToken); ok {
|
||||
WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{
|
||||
"reason": "interactive_only",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(authorizationBearer(r), APITokenPrefix) {
|
||||
WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{
|
||||
"reason": "interactive_only",
|
||||
})
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// tokenRouteGate refuses a non-full scoped principal with 403 token_scope
|
||||
// (same as Allows(principal, nil) refusing). Used on routes that never call
|
||||
// Allows, such as /field-types and /markdown/preview.
|
||||
func tokenRouteGate(principal *bouncer.Principal, w http.ResponseWriter) bool {
|
||||
if principal != nil && principal.Scope != nil && !principal.Scope.Full {
|
||||
WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{
|
||||
"reason": "token_scope",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// backendResolver is the published cookie-less composite guard used by
|
||||
// ResolveBackend on plugin routes outside the admin middleware.
|
||||
type backendResolver struct {
|
||||
guard *compositeGuard
|
||||
}
|
||||
|
||||
// ResolveBackend authenticates r from the Authorization Bearer header only
|
||||
// (admin JWT or g15_ personal API token — never the admin cookie). For a
|
||||
// token it requires a method-allowed scope that is full or matches every
|
||||
// cover code. Routes registered before cabana.Activate must call this per
|
||||
// request (lookup the published resolver each time). Returns
|
||||
// ErrUnauthenticated or ErrTokenScope.
|
||||
func ResolveBackend(app *backpack.App, r *http.Request, cover ...string) (*http.Request, *bouncer.Principal, error) {
|
||||
if app == nil || r == nil || authorizationBearer(r) == "" {
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
res, ok := app.Lookup[*backendResolver]()
|
||||
if !ok || res == nil || res.guard == nil {
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
principal, cred, err := res.guard.AuthenticateCredential(r)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrTokenScope) {
|
||||
return nil, nil, ErrTokenScope
|
||||
}
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
if principal == nil {
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
if principal.Scope != nil && !principal.Scope.Full {
|
||||
if !scopeCoversAll(principal.Scope, cover) {
|
||||
return nil, nil, ErrTokenScope
|
||||
}
|
||||
}
|
||||
ctx := bouncer.WithUser(r.Context(), principal)
|
||||
if cred != nil {
|
||||
ctx = bouncer.WithCredential(ctx, cred)
|
||||
}
|
||||
return r.WithContext(ctx), principal, nil
|
||||
}
|
||||
|
||||
func logTokenAuth(r *http.Request, outcome string, adminID, tokenID uint) {
|
||||
remote := ""
|
||||
method := ""
|
||||
|
||||
@@ -3,6 +3,7 @@ package cabana
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -10,13 +11,16 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
type memTokenStore struct {
|
||||
mu sync.Mutex
|
||||
byHash map[string]*BackendAPIToken
|
||||
mu sync.Mutex
|
||||
byHash map[string]*BackendAPIToken
|
||||
stampN int
|
||||
stampIDs []uint
|
||||
}
|
||||
|
||||
func (s *memTokenStore) FindUsableByHash(_ context.Context, hash string, now time.Time) (*BackendAPIToken, error) {
|
||||
@@ -36,6 +40,14 @@ func (s *memTokenStore) FindUsableByHash(_ context.Context, hash string, now tim
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (s *memTokenStore) StampLastUsed(_ context.Context, id uint, _ time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stampN++
|
||||
s.stampIDs = append(s.stampIDs, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memTokenStore) put(row *BackendAPIToken) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -207,3 +219,146 @@ func TestTokenTracerSmoke(t *testing.T) {
|
||||
t.Fatalf("mint shape %q", demoTok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenGatesSmoke(t *testing.T) {
|
||||
cat := tracerCatalog()
|
||||
reg := &Registry{tokenScopes: cat}
|
||||
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
|
||||
dev := &bouncer.Principal{
|
||||
ID: 1,
|
||||
Backend: true,
|
||||
PermissionGrants: map[string]bool{
|
||||
"acme.demo.access": true,
|
||||
"acme.demo.reports": true,
|
||||
},
|
||||
}
|
||||
users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}
|
||||
guard := newCompositeGuard(&stubJWTGuard{}, store, users, reg)
|
||||
|
||||
mint := func(owner uint, scopes []string, id uint) string {
|
||||
plain, hash, hint, err := mintAPIToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.put(&BackendAPIToken{
|
||||
ID: id,
|
||||
BackendUserID: owner,
|
||||
Name: "t",
|
||||
TokenHash: hash,
|
||||
TokenHint: hint,
|
||||
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
|
||||
})
|
||||
return plain
|
||||
}
|
||||
|
||||
starTok := mint(1, []string{"*"}, 100)
|
||||
demoTok := mint(1, []string{"demo"}, 101)
|
||||
reportsTok := mint(1, []string{"demo:reports"}, 102)
|
||||
adminReadTok := mint(1, []string{"admin:read"}, 103)
|
||||
|
||||
// interactiveOnly on PUT /account and refresh with raw g15_ Bearer.
|
||||
accountH := interactiveOnly(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/account", nil)
|
||||
req = req.WithContext(bouncer.WithCredential(req.Context(), &BackendAPIToken{ID: 100}))
|
||||
accountH(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("account with token cred: %d", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Details map[string]any `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Error.Details["reason"] != "interactive_only" {
|
||||
t.Fatalf("account reason: %v %s", err, rec.Body.String())
|
||||
}
|
||||
|
||||
refreshH := interactiveOnly(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+starTok)
|
||||
refreshH(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("refresh with g15_: %d", rec.Code)
|
||||
}
|
||||
|
||||
// fieldTypesList gate
|
||||
svc := &service{reg: reg}
|
||||
runFieldTypes := func(token string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodGet, "/field-types", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
p, cred, err := guard.AuthenticateCredential(r)
|
||||
if err != nil || p == nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
ctx := bouncer.WithUser(r.Context(), p)
|
||||
if cred != nil {
|
||||
ctx = bouncer.WithCredential(ctx, cred)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
svc.fieldTypesList(rec, r.WithContext(ctx))
|
||||
return rec
|
||||
}
|
||||
if rec := runFieldTypes(demoTok); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("demo field-types: want 403, got %d", rec.Code)
|
||||
} else if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Error.Details["reason"] != "token_scope" {
|
||||
t.Fatalf("field-types reason: %v %s", err, rec.Body.String())
|
||||
}
|
||||
if rec := runFieldTypes(adminReadTok); rec.Code != http.StatusOK {
|
||||
t.Fatalf("admin:read field-types: want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// ResolveBackend
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(&backendResolver{guard: guard}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/plugin", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+reportsTok)
|
||||
if _, _, err := ResolveBackend(app, r, "acme.demo.reports"); !errors.Is(err, ErrTokenScope) {
|
||||
t.Fatalf("reports POST cover: want ErrTokenScope, got %v", err)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/plugin", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+demoTok)
|
||||
if _, _, err := ResolveBackend(app, r, "acme.other.*"); !errors.Is(err, ErrTokenScope) {
|
||||
t.Fatalf("demo cover other: want ErrTokenScope, got %v", err)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/plugin", nil)
|
||||
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("no bearer: want ErrUnauthenticated, got %v", err)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/plugin", nil)
|
||||
r.Header.Set("Authorization", "Bearer g15_x")
|
||||
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("malformed: want ErrUnauthenticated, got %v", err)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/plugin", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+demoTok)
|
||||
out, p, err := ResolveBackend(app, r, "acme.demo.*")
|
||||
if err != nil || p == nil || out == nil {
|
||||
t.Fatalf("demo GET cover: %v", err)
|
||||
}
|
||||
if _, ok := bouncer.Credential(out.Context()); !ok {
|
||||
t.Fatal("credential missing on resolved request")
|
||||
}
|
||||
|
||||
// Stamp debounce: two auths within a minute → one store stamp.
|
||||
lastStamp.Delete(uint(101))
|
||||
store.stampN = 0
|
||||
r = httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+demoTok)
|
||||
if _, _, err := guard.AuthenticateCredential(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := guard.AuthenticateCredential(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.stampN != 1 {
|
||||
t.Fatalf("stamp calls = %d, want 1", store.stampN)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user