* fix:修复权限问题 * fix:修复项目key类型错误问题 * fix:修复rbac权限问题 * fix:修复api密钥未及时失效的问题 * improve:优化渠道调度稳定性 * improve:优化路由 * fix:修复项目预算控制缺陷 * fix:加强限流等安全措施 * feat:添加限流相关字段的前端展示及接口字段 * improve:优化注册时密码要求,只需要字母和数字即可 * feat(setting):添加注册限流设置 * improve:优化前端数字类型转换 * chore:rbac权限放行 --------- Co-authored-by: chengqian <wuchengqian@kkidc.com> Co-authored-by: qian <wuchengqian5@163.com>
111 lines
3.5 KiB
Go
111 lines
3.5 KiB
Go
package middleware
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/gogf/gf/v2/net/ghttp"
|
||
|
||
"github.com/qianfree/team-api/internal/consts"
|
||
"github.com/qianfree/team-api/internal/logic/relay"
|
||
)
|
||
|
||
// ApiKeyAuth 是 API Key 认证中间件,用于 AI 代理端点。
|
||
// 支持以下认证方式(按优先级):
|
||
// - x-goog-api-key: sk-xxx(Gemini 原生格式)
|
||
// - Authorization: Bearer sk-xxx(OpenAI 兼容格式)
|
||
// - x-api-key: sk-xxx(Anthropic Claude 原生格式)
|
||
// - ?key=xxx query parameter(Gemini SDK 格式,仅 /v1beta 路径)
|
||
//
|
||
// 验证通过后注入 TenantID/UserID/ApiKeyID。
|
||
// 认证失败时使用供应商原生错误格式(/v1/messages 用 Claude 格式,其余用 OpenAI 格式)。
|
||
func ApiKeyAuth(r *ghttp.Request) {
|
||
tokenStr := strings.TrimSpace(r.GetHeader("x-goog-api-key"))
|
||
if tokenStr == "" {
|
||
tokenStr = extractBearerToken(r)
|
||
}
|
||
if tokenStr == "" {
|
||
tokenStr = strings.TrimSpace(r.GetHeader("x-api-key"))
|
||
}
|
||
// Gemini 原生 API 通过 query parameter 传递密钥:?key=xxx(仅 /v1beta 路径)
|
||
if tokenStr == "" && strings.HasPrefix(r.URL.Path, "/v1beta") {
|
||
tokenStr = strings.TrimSpace(r.Get("key").String())
|
||
}
|
||
if tokenStr == "" {
|
||
writeRelayAuthError(r, http.StatusUnauthorized, "authentication_error", "missing API key")
|
||
return
|
||
}
|
||
|
||
apiKeyInfo, err := relay.ValidateApiKey(r.Context(), tokenStr)
|
||
if err != nil {
|
||
code := http.StatusUnauthorized
|
||
errType := "authentication_error"
|
||
msg := "invalid API key"
|
||
|
||
switch err {
|
||
case consts.ErrKeyExpired:
|
||
errType = "authentication_error"
|
||
msg = "API key has expired"
|
||
case consts.ErrKeyDisabled:
|
||
code = http.StatusForbidden
|
||
errType = "permission_error"
|
||
msg = "API key is disabled"
|
||
case consts.ErrTenantSuspended:
|
||
code = http.StatusForbidden
|
||
errType = "permission_error"
|
||
msg = "tenant is suspended"
|
||
case consts.ErrProjectNotActive:
|
||
code = http.StatusForbidden
|
||
errType = "permission_error"
|
||
msg = "project is not active"
|
||
}
|
||
|
||
writeRelayAuthError(r, code, errType, msg)
|
||
return
|
||
}
|
||
|
||
// Set auth context
|
||
r.SetCtxVar(CtxKeyTenantID, apiKeyInfo.TenantID)
|
||
r.SetCtxVar(CtxKeyUserID, apiKeyInfo.UserID)
|
||
r.SetCtxVar(CtxKeyApiKeyID, apiKeyInfo.ID)
|
||
r.SetCtxVar(CtxKeyProjectID, apiKeyInfo.ProjectID)
|
||
r.SetCtxVar("ApiKeyScope", apiKeyInfo.Scope)
|
||
r.SetCtxVar(CtxKeyApiKeyRateLimitQps, apiKeyInfo.RateLimitQps)
|
||
r.SetCtxVar(CtxKeyApiKeyRateLimitConcurrency, apiKeyInfo.RateLimitConcurrency)
|
||
r.SetCtxVar(CtxKeyApiKeyIpWhitelist, apiKeyInfo.IpWhitelist)
|
||
r.SetCtxVar(CtxKeyApiKeyTotalQuota, apiKeyInfo.TotalQuota)
|
||
r.SetCtxVar(CtxKeyApiKeyUsedQuota, apiKeyInfo.UsedQuota)
|
||
|
||
r.Middleware.Next()
|
||
}
|
||
|
||
// writeRelayAuthError 写入供应商原生格式的认证错误响应
|
||
// /v1/messages 使用 Claude 格式,其余使用 OpenAI 格式
|
||
func writeRelayAuthError(r *ghttp.Request, statusCode int, errType, message string) {
|
||
r.Response.Header().Set("Content-Type", "application/json")
|
||
r.Response.WriteHeader(statusCode)
|
||
|
||
path := r.URL.Path
|
||
if path == "/v1/messages" {
|
||
// Claude 格式: {"type": "error", "error": {"type": "...", "message": "..."}}
|
||
body, _ := json.Marshal(map[string]any{
|
||
"type": "error",
|
||
"error": map[string]string{
|
||
"type": errType,
|
||
"message": message,
|
||
},
|
||
})
|
||
r.Response.Write(body)
|
||
} else {
|
||
// OpenAI 格式: {"error": {"type": "...", "message": "..."}}
|
||
body, _ := json.Marshal(map[string]any{
|
||
"error": map[string]string{
|
||
"type": errType,
|
||
"message": message,
|
||
},
|
||
})
|
||
r.Response.Write(body)
|
||
}
|
||
}
|