From 183583e0e93bc611ab4dfaa6695f29f5ba723d1c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 11 Oct 2026 01:47:52 +0200 Subject: [PATCH] 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 --- modules/cabana/api_tokens.go | 35 ++++++- modules/cabana/field_markdown.go | 3 + modules/cabana/field_type.go | 3 + modules/cabana/http.go | 19 +++- modules/cabana/registry.go | 4 +- modules/cabana/token_guard.go | 80 +++++++++++++++ modules/cabana/token_smoke_test.go | 159 ++++++++++++++++++++++++++++- 7 files changed, 293 insertions(+), 10 deletions(-) diff --git a/modules/cabana/api_tokens.go b/modules/cabana/api_tokens.go index b3639fa..07a968c 100644 --- a/modules/cabana/api_tokens.go +++ b/modules/cabana/api_tokens.go @@ -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) + } +} diff --git a/modules/cabana/field_markdown.go b/modules/cabana/field_markdown.go index 95ac6b7..97b470d 100644 --- a/modules/cabana/field_markdown.go +++ b/modules/cabana/field_markdown.go @@ -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) diff --git a/modules/cabana/field_type.go b/modules/cabana/field_type.go index 61ed9b1..4ca05b8 100644 --- a/modules/cabana/field_type.go +++ b/modules/cabana/field_type.go @@ -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) } diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 9f6b267..f072118 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -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. diff --git a/modules/cabana/registry.go b/modules/cabana/registry.go index 02b057e..25dcc18 100644 --- a/modules/cabana/registry.go +++ b/modules/cabana/registry.go @@ -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 diff --git a/modules/cabana/token_guard.go b/modules/cabana/token_guard.go index c683d30..e953cd2 100644 --- a/modules/cabana/token_guard.go +++ b/modules/cabana/token_guard.go @@ -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 := "" diff --git a/modules/cabana/token_smoke_test.go b/modules/cabana/token_smoke_test.go index 9a198eb..3c2dacf 100644 --- a/modules/cabana/token_smoke_test.go +++ b/modules/cabana/token_smoke_test.go @@ -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) + } +}