Files
ai-gateway-go/internal/apikey/admin_http.go
T
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

205 lines
7.8 KiB
Go

package apikey
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"aigateway.local/core/internal/identity"
"aigateway.local/core/internal/platform/apiresponse"
)
type AdminHTTPHandler struct {
repository *Repository
authenticator *Authenticator
usage *UsageStore
identity *identity.Service
mux *http.ServeMux
}
type createRequest struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
RequestsPerMinute int `json:"requests_per_minute"`
MonthlyRequestQuota int64 `json:"monthly_request_quota"`
MonthlyTokenQuota int64 `json:"monthly_token_quota"`
ExpiresAt *time.Time `json:"expires_at"`
}
type limitsRequest struct {
RequestsPerMinute int `json:"requests_per_minute"`
MonthlyRequestQuota int64 `json:"monthly_request_quota"`
MonthlyTokenQuota int64 `json:"monthly_token_quota"`
}
func NewAdminHTTPHandler(repository *Repository, authenticator *Authenticator, identityService *identity.Service) *AdminHTTPHandler {
h := &AdminHTTPHandler{repository: repository, authenticator: authenticator, identity: identityService, mux: http.NewServeMux()}
h.mux.HandleFunc("GET /api/v1/admin/api-keys", h.list)
h.mux.HandleFunc("POST /api/v1/admin/api-keys", h.create)
h.mux.HandleFunc("PUT /api/v1/admin/api-keys/{api_key_id}/limits", h.updateLimits)
h.mux.HandleFunc("DELETE /api/v1/admin/api-keys/{api_key_id}", h.revoke)
return h
}
func (h *AdminHTTPHandler) SetUsageStore(store *UsageStore) { h.usage = store }
func (h *AdminHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
h.mux.ServeHTTP(writer, request)
}
func (h *AdminHTTPHandler) list(writer http.ResponseWriter, request *http.Request) {
if _, ok := h.requirePermission(writer, request, identity.PermissionAPIKeyRead); !ok {
return
}
records, err := h.repository.List(request.Context())
if err != nil {
h.writeError(writer, err)
return
}
ids := make([]string, 0, len(records))
for _, record := range records {
ids = append(ids, record.ID)
}
usage, err := h.usage.MonthlyTokens(request.Context(), ids, time.Now())
if err != nil {
h.writeError(writer, err)
return
}
items := make([]map[string]any, 0, len(records))
for _, record := range records {
item := publicRecord(record)
item["monthly_token_usage"] = usage[record.ID]
items = append(items, item)
}
apiresponse.OK(writer, items)
}
func (h *AdminHTTPHandler) updateLimits(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requirePermission(writer, request, identity.PermissionAPIKeyManage)
if !ok {
return
}
var input limitsRequest
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
decoder.DisallowUnknownFields()
if decoder.Decode(&input) != nil || !validLimits(input.RequestsPerMinute, input.MonthlyRequestQuota, input.MonthlyTokenQuota) {
apiresponse.Error(writer, http.StatusBadRequest, "API Key 限流或月配额无效")
return
}
record, hash, err := h.repository.UpdateLimits(request.Context(), request.PathValue("api_key_id"), input.RequestsPerMinute, input.MonthlyRequestQuota, input.MonthlyTokenQuota, account.ID)
if err != nil {
h.writeError(writer, err)
return
}
// 缓存失效失败不视为操作失败:数据库已生效(权威源),缓存最迟在 TTL 后
// 自动过期;若在此报错,运维会误以为限流更新失败而重试。
if err := h.authenticator.Invalidate(request.Context(), hash); err != nil {
h.authenticator.logger.Warn("api key limits cache invalidation failed; key stays cached until TTL", "error", err)
}
response := publicRecord(record)
if h.usage != nil {
usage, usageErr := h.usage.MonthlyTokens(request.Context(), []string{record.ID}, time.Now())
if usageErr == nil {
response["monthly_token_usage"] = usage[record.ID]
}
}
apiresponse.OK(writer, response)
}
func validLimits(requestsPerMinute int, monthlyRequestQuota, monthlyTokenQuota int64) bool {
return requestsPerMinute >= 0 && requestsPerMinute <= 1_000_000 &&
monthlyRequestQuota >= 0 && monthlyRequestQuota <= 1_000_000_000_000 &&
monthlyTokenQuota >= 0 && monthlyTokenQuota <= 1_000_000_000_000_000
}
func (h *AdminHTTPHandler) create(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requirePermission(writer, request, identity.PermissionAPIKeyManage)
if !ok {
return
}
var input createRequest
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
decoder.DisallowUnknownFields()
if decoder.Decode(&input) != nil {
apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效")
return
}
input.Name = strings.TrimSpace(input.Name)
if input.Name == "" || len(input.Name) > 128 || len(input.Scopes) == 0 || input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) {
apiresponse.Error(writer, http.StatusBadRequest, "名称、权限范围或过期时间无效")
return
}
if !validLimits(input.RequestsPerMinute, input.MonthlyRequestQuota, input.MonthlyTokenQuota) {
apiresponse.Error(writer, http.StatusBadRequest, "API Key 限流或月配额无效")
return
}
for _, scope := range input.Scopes {
if scope != "gateway:invoke" && scope != "*" {
apiresponse.Error(writer, http.StatusBadRequest, "包含不支持的权限范围")
return
}
}
record, secret, err := h.repository.Create(request.Context(), input.Name, input.Scopes, input.RequestsPerMinute, input.MonthlyRequestQuota, input.MonthlyTokenQuota, input.ExpiresAt, account.ID)
if err != nil {
h.writeError(writer, err)
return
}
response := publicRecord(record)
response["key"] = secret
response["warning"] = "密钥只显示一次,请立即安全保存"
apiresponse.OK(writer, response)
}
func (h *AdminHTTPHandler) revoke(writer http.ResponseWriter, request *http.Request) {
account, ok := h.requirePermission(writer, request, identity.PermissionAPIKeyManage)
if !ok {
return
}
hash, err := h.repository.Revoke(request.Context(), request.PathValue("api_key_id"), account.ID)
if err != nil {
h.writeError(writer, err)
return
}
// 同上:撤销已在数据库生效,缓存失效失败仅记录,不误报为撤销失败。
if err := h.authenticator.Invalidate(request.Context(), hash); err != nil {
h.authenticator.logger.Warn("api key revocation cache invalidation failed; key stays cached until TTL", "error", err)
}
apiresponse.OK(writer, map[string]bool{"revoked": true})
}
func (h *AdminHTTPHandler) requirePermission(writer http.ResponseWriter, request *http.Request, permission string) (identity.Account, bool) {
account, err := h.identity.Authenticate(request.Context(), identity.KindAdmin, request.Header.Get("Authorization"))
if err != nil {
apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期")
return identity.Account{}, false
}
if !identity.HasPermission(account, permission) {
apiresponse.Error(writer, http.StatusForbidden, "缺少 API Key 操作权限")
return identity.Account{}, false
}
return account, true
}
func (h *AdminHTTPHandler) writeError(writer http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrInvalid):
apiresponse.Error(writer, http.StatusNotFound, "API Key 不存在或已撤销")
case errors.Is(err, ErrStore):
apiresponse.Error(writer, http.StatusServiceUnavailable, "API Key 服务暂不可用")
default:
apiresponse.Error(writer, http.StatusInternalServerError, "API Key 处理失败")
}
}
func publicRecord(record Record) map[string]any {
return map[string]any{
"id": record.ID, "name": record.Name, "key_prefix": record.KeyPrefix,
"scopes": record.Scopes, "enabled": record.Enabled, "expires_at": record.ExpiresAt,
"requests_per_minute": record.RequestsPerMinute, "monthly_request_quota": record.MonthlyRequestQuota,
"monthly_token_quota": record.MonthlyTokenQuota,
"last_used_at": record.LastUsedAt, "created_at": record.CreatedAt,
}
}