Files
ai-gateway-go/internal/apikey/authenticator.go
T
superidou 5759c1862e AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。
含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 11:45:54 +08:00

125 lines
4.2 KiB
Go

package apikey
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
type Authenticator struct {
repository *Repository
redis *redis.Client
bootstrap string
cacheTTL time.Duration
bootstrapUses atomic.Uint64
logger *slog.Logger
}
func NewAuthenticator(repository *Repository, client *redis.Client, bootstrap string) *Authenticator {
return &Authenticator{repository: repository, redis: client, bootstrap: bootstrap, cacheTTL: 30 * time.Second}
}
// SetLogger wires an optional logger used for best-effort cache diagnostics.
func (a *Authenticator) SetLogger(logger *slog.Logger) { a.logger = logger }
func (a *Authenticator) Authenticate(ctx context.Context, secret string) error {
_, err := a.AuthenticatePrincipal(ctx, secret)
return err
}
// invokeScope reports whether the key carries a scope that may reach the
// gateway. Besides regular gateway keys it admits the server-side
// application runtime credential ("application:run"), which is created only
// by the portal runtime (encrypted in PostgreSQL, tenant-bound, never shown
// to browsers) so that hosted application conversations can call the gateway
// on behalf of the app owner. Both call paths in the workbench runtime and
// the main proxy use this single method, so the runtime credential works
// end-to-end without widening any other surface.
func invokeScope(scopes []string) bool {
return HasScope(scopes, "gateway:invoke") || HasScope(scopes, "application:run")
}
func (a *Authenticator) AuthenticatePrincipal(ctx context.Context, secret string) (Principal, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return Principal{}, ErrInvalid
}
if a.bootstrap != "" && len(secret) == len(a.bootstrap) && subtle.ConstantTimeCompare([]byte(secret), []byte(a.bootstrap)) == 1 {
a.bootstrapUses.Add(1)
// The bootstrap credential is a real gateway key, not an anonymous
// pass. Return a stable, well-known identity so the admission and
// token-quota paths run (they allow it outright: RPM/quota are 0 by
// design for a migration key) and audit records attribute usage to
// "bootstrap" instead of an empty principal that silently skips every
// policy stage. An empty APIKeyID previously bypassed rate limiting,
// quota and audit attribution entirely.
return Principal{APIKeyID: "bootstrap", Scopes: []string{"gateway:invoke"}}, nil
}
hash, hexHash := Digest(secret)
if a.redis != nil {
payload, err := a.redis.Get(ctx, cacheKey(hexHash)).Bytes()
if err == nil {
var principal Principal
if json.Unmarshal(payload, &principal) == nil && invokeScope(principal.Scopes) {
return principal, nil
}
return Principal{}, ErrInvalid
}
if err != nil && !errors.Is(err, redis.Nil) {
return Principal{}, fmt.Errorf("%w: %v", ErrStore, err)
}
}
principal, err := a.repository.Validate(ctx, hash)
if err != nil {
return Principal{}, err
}
if !invokeScope(principal.Scopes) {
return Principal{}, ErrInvalid
}
// Best-effort cache: the database is the source of truth for validation.
// A cache write failure must not turn a key the database just accepted
// into a 503, or a transient Redis blip would take the whole gateway down.
if a.redis != nil {
payload, _ := json.Marshal(principal)
if err := a.redis.Set(ctx, cacheKey(hexHash), payload, a.cacheTTL).Err(); err != nil && a.logger != nil {
a.logger.Warn("api key cache write failed; continuing with database result", "error", err)
}
}
return principal, nil
}
func (a *Authenticator) BootstrapUses() uint64 {
if a == nil {
return 0
}
return a.bootstrapUses.Load()
}
func (a *Authenticator) Invalidate(ctx context.Context, hash []byte) error {
if a.redis == nil {
return nil
}
_, hexHash := DigestFromHash(hash)
return a.redis.Del(ctx, cacheKey(hexHash)).Err()
}
func DigestFromHash(hash []byte) ([]byte, string) {
const hex = "0123456789abcdef"
encoded := make([]byte, len(hash)*2)
for i, value := range hash {
encoded[i*2] = hex[value>>4]
encoded[i*2+1] = hex[value&15]
}
return hash, string(encoded)
}
func cacheKey(hexHash string) string { return "gateway:api-key:v1:" + hexHash }