- 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>
247 lines
7.5 KiB
Go
247 lines
7.5 KiB
Go
package cabana
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"errors"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
)
|
|
|
|
// ErrUnauthenticated is returned by ResolveBackend when the request has no
|
|
// usable Bearer JWT or personal API token.
|
|
var ErrUnauthenticated = errors.New("cabana: unauthenticated")
|
|
|
|
// ErrTokenScope is returned when a personal API token is authenticated but
|
|
// its scopes do not allow the request method or cover codes.
|
|
var ErrTokenScope = errors.New("cabana: token scope")
|
|
|
|
// scopeError wraps ErrTokenScope so WriteUnauthorized can answer 403.
|
|
// errors.Is(err, ErrTokenScope) is true.
|
|
type scopeError struct {
|
|
err error
|
|
}
|
|
|
|
func (e *scopeError) Error() string {
|
|
if e == nil || e.err == nil {
|
|
return ErrTokenScope.Error()
|
|
}
|
|
return e.err.Error()
|
|
}
|
|
|
|
func (e *scopeError) Unwrap() error { return ErrTokenScope }
|
|
|
|
func newScopeError() error { return &scopeError{err: ErrTokenScope} }
|
|
|
|
// compositeGuard routes Bearer g15_… values to the personal API token path and
|
|
// every other request to the wrapped JWT guard. WriteUnauthorized answers 403
|
|
// for a scopeError and the usual 401 unauthenticated envelope otherwise.
|
|
type compositeGuard struct {
|
|
jwt bouncer.Guard
|
|
store tokenStore
|
|
users bouncer.UserProvider
|
|
reg *Registry
|
|
}
|
|
|
|
var (
|
|
_ bouncer.CredentialGuard = (*compositeGuard)(nil)
|
|
_ bouncer.UnauthorizedWriter = (*compositeGuard)(nil)
|
|
)
|
|
|
|
func newCompositeGuard(jwt bouncer.Guard, store tokenStore, users bouncer.UserProvider, reg *Registry) *compositeGuard {
|
|
return &compositeGuard{jwt: jwt, store: store, users: users, reg: reg}
|
|
}
|
|
|
|
func (g *compositeGuard) AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error) {
|
|
if g == nil || r == nil {
|
|
return nil, nil, ErrUnauthenticated
|
|
}
|
|
raw := authorizationBearer(r)
|
|
if strings.HasPrefix(raw, APITokenPrefix) {
|
|
return g.authenticateToken(r, raw)
|
|
}
|
|
if g.jwt == nil {
|
|
return nil, nil, ErrUnauthenticated
|
|
}
|
|
p, err := g.jwt.Authenticate(r)
|
|
return p, nil, err
|
|
}
|
|
|
|
func (g *compositeGuard) authenticateToken(r *http.Request, raw string) (*bouncer.Principal, any, error) {
|
|
if !wellFormedAPIToken(raw) {
|
|
logTokenAuth(r, "failed", 0, 0)
|
|
return nil, nil, ErrUnauthenticated
|
|
}
|
|
if g.store == nil {
|
|
logTokenAuth(r, "failed", 0, 0)
|
|
return nil, nil, ErrUnauthenticated
|
|
}
|
|
now := time.Now().UTC()
|
|
hash := hashAPIToken(raw)
|
|
row, err := g.store.FindUsableByHash(r.Context(), hash, now)
|
|
if err != nil || row == nil {
|
|
logTokenAuth(r, "failed", 0, 0)
|
|
return nil, nil, ErrUnauthenticated
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) != 1 {
|
|
logTokenAuth(r, "failed", 0, row.ID)
|
|
return nil, nil, ErrUnauthenticated
|
|
}
|
|
if g.users == nil {
|
|
logTokenAuth(r, "failed", 0, row.ID)
|
|
return nil, nil, ErrUnauthenticated
|
|
}
|
|
principal, err := g.users.FindByID(r.Context(), row.BackendUserID)
|
|
if err != nil || principal == nil {
|
|
logTokenAuth(r, "failed", row.BackendUserID, row.ID)
|
|
return nil, nil, ErrUnauthenticated
|
|
}
|
|
stored := row.Scopes.Get()
|
|
if !row.Scopes.Valid {
|
|
stored = nil
|
|
}
|
|
limit, known, methodOK := methodFilterScopes(g.reg.tokenScopes, stored, r.Method)
|
|
if known == 0 {
|
|
logTokenAuth(r, "failed", principal.ID, row.ID)
|
|
return nil, nil, ErrUnauthenticated
|
|
}
|
|
if methodOK == 0 {
|
|
logTokenAuth(r, "denied", principal.ID, row.ID)
|
|
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
|
|
}
|
|
|
|
// WriteUnauthorized answers 403 forbidden with details.reason token_scope for
|
|
// a scopeError; every other failure uses the 401 unauthenticated envelope.
|
|
func (g *compositeGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
|
|
if errors.Is(err, ErrTokenScope) {
|
|
WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{
|
|
"reason": "token_scope",
|
|
})
|
|
return
|
|
}
|
|
writeUnauthenticated(w, err)
|
|
}
|
|
|
|
func authorizationBearer(r *http.Request) string {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
token, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
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 := ""
|
|
path := ""
|
|
if r != nil {
|
|
method = r.Method
|
|
if r.URL != nil {
|
|
path = r.URL.Path
|
|
}
|
|
remote = r.RemoteAddr
|
|
if host, _, err := net.SplitHostPort(remote); err == nil {
|
|
remote = host
|
|
}
|
|
}
|
|
args := []any{"outcome", outcome, "method", method, "path", path, "remote", remote}
|
|
if adminID != 0 {
|
|
args = append(args, "admin_id", adminID)
|
|
}
|
|
if tokenID != 0 {
|
|
args = append(args, "token_id", tokenID)
|
|
}
|
|
slog.Default().Info("admin.auth", args...)
|
|
}
|