Files
superidou 9501751792 0.10.1: 安全与业务逻辑加固、新品牌与部署加固
三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
  与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
  撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
  全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
  >4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
  后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
  nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
2026-08-13 10:50:51 +08:00

127 lines
4.3 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 {
// cacheTTL 是撤销/限流变更的最坏传播窗口:数据库提交后 Invalidate 会
// 立即删除缓存键,TTL 只是 Redis 故障时的兜底,因此保持短小。
return &Authenticator{repository: repository, redis: client, bootstrap: bootstrap, cacheTTL: 10 * 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 }