Files
summercms/modules/cabana/token_guard.go
Jakub Zych b9273c1da7 feat(16.3-01): tracer — g15_ token scopes through composite backend guard
- Add pact.TokenScope/HasTokenScopes and bouncer.ScopeLimit on Principal
- Append backend_api_tokens migration; Allows evaluates scope before superuser
- Composite guard routes Bearer g15_… to token lookup; smoke tests cover path

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:46:32 +02:00

167 lines
4.6 KiB
Go

package cabana
import (
"crypto/subtle"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"time"
"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
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)
}
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...)
}