- pact.MCPSubjectChanged and pier.Event gain TokenID and ClientID; a narrowed revoked event closes only matching sessions and stateless servers - cabana fires the admin token revoke with the token row id - towel.WithClientIP/ClientIP; pier puts the address (surf applies http.trusted_proxies) on the resolve context and ResolveBackendToken logs it
112 lines
4.6 KiB
Go
112 lines
4.6 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/towel"
|
|
)
|
|
|
|
// resolverTokenPath is the path logTokenAuthAt records for a token check that
|
|
// has no request behind it (an MCP session opened with a personal token).
|
|
const resolverTokenPath = "mcp"
|
|
|
|
// ResolveBackendToken resolves a g15_ personal API token outside any request:
|
|
// the same hash lookup, constant-time compare, usability window, admin load
|
|
// and last_used_at stamp as the backend guard, but without the request-method
|
|
// scope filter. It returns the admin principal (without Scope), the token row
|
|
// and the scope names stored on the token, so an in-process dispatcher can
|
|
// compute the limit per dispatched route method with ScopeLimitFor. A
|
|
// malformed, unknown, revoked or expired token, a missing or deactivated
|
|
// admin and an application without cabana activated all answer
|
|
// ErrUnauthenticated; the audit line is logged under path "mcp".
|
|
func ResolveBackendToken(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *BackendAPIToken, []string, error) {
|
|
// The caller's client address, when it put one on the context (pier
|
|
// does), so a failed attempt is traceable to an address.
|
|
remote, _ := towel.ClientIP(ctx)
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
guard := publishedResolverGuard(app)
|
|
if guard == nil || !wellFormedAPIToken(raw) || guard.store == nil {
|
|
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, 0)
|
|
return nil, nil, nil, ErrUnauthenticated
|
|
}
|
|
now := time.Now().UTC()
|
|
hash := hashAPIToken(raw)
|
|
row, err := guard.store.FindUsableByHash(ctx, hash, now)
|
|
if err != nil || row == nil {
|
|
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, 0)
|
|
return nil, nil, nil, ErrUnauthenticated
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) != 1 {
|
|
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, row.ID)
|
|
return nil, nil, nil, ErrUnauthenticated
|
|
}
|
|
if guard.users == nil {
|
|
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, row.ID)
|
|
return nil, nil, nil, ErrUnauthenticated
|
|
}
|
|
principal, err := guard.users.FindByID(ctx, row.BackendUserID)
|
|
if err != nil || principal == nil {
|
|
logTokenAuthAt("", resolverTokenPath, remote, "failed", row.BackendUserID, row.ID)
|
|
return nil, nil, nil, ErrUnauthenticated
|
|
}
|
|
stored := row.Scopes.Get()
|
|
if !row.Scopes.Valid {
|
|
stored = nil
|
|
}
|
|
// Personal API tokens keep the request locale (see authenticateToken).
|
|
principal.PreferredLocale = ""
|
|
stampLastUsed(ctx, guard.stamps, guard.store, row.ID, now)
|
|
logTokenAuthAt("", resolverTokenPath, remote, "success", principal.ID, row.ID)
|
|
return principal, row, append([]string(nil), stored...), nil
|
|
}
|
|
|
|
// ScopeLimitFor filters the stored scope names of a personal API token by one
|
|
// HTTP method, exactly as the backend guard does for a request: the limit to
|
|
// put on bouncer.Principal.Scope, the number of names the registry knows and
|
|
// the number of those that allow the method. A nil limit with known 0 means
|
|
// no stored scope exists any more (unauthenticated); known > 0 with methodOK
|
|
// 0 means the token has no scope for that method (token_scope). The second
|
|
// and third results are 0 when cabana is not activated on app.
|
|
func ScopeLimitFor(app *backpack.App, scopes []string, method string) (*bouncer.ScopeLimit, int, int) {
|
|
guard := publishedResolverGuard(app)
|
|
if guard == nil || guard.reg == nil {
|
|
return nil, 0, 0
|
|
}
|
|
return methodFilterScopes(guard.reg.tokenScopes, scopes, method)
|
|
}
|
|
|
|
// BackendPrincipal loads the admin principal with the given id through the
|
|
// backend users provider cabana published at activation: Backend,
|
|
// IsSuperuser and PermissionGrants are set as for a signed-in admin. It
|
|
// answers nil, nil when the admin does not exist or is deactivated, and an
|
|
// error when cabana is not activated on app or the database is unavailable.
|
|
func BackendPrincipal(ctx context.Context, app *backpack.App, id uint) (*bouncer.Principal, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
guard := publishedResolverGuard(app)
|
|
if guard == nil || guard.users == nil {
|
|
return nil, ErrUnauthenticated
|
|
}
|
|
return guard.users.FindByID(ctx, id)
|
|
}
|
|
|
|
// publishedResolverGuard returns the composite guard behind the published
|
|
// cookie-less resolver, or nil when cabana has not activated on app.
|
|
func publishedResolverGuard(app *backpack.App) *compositeGuard {
|
|
if app == nil {
|
|
return nil
|
|
}
|
|
res, ok := app.Lookup[*backendResolver]()
|
|
if !ok || res == nil || res.guard == nil {
|
|
return nil
|
|
}
|
|
return res.guard
|
|
}
|