Files
sub2api/backend/internal/server/middleware/api_key_auth_google.go
T
superman2003andCursor 29a5fcd25e fix(gateway,frontend): 修复鉴权绕过与前端支付/会话缺陷
后端:
- Gemini /v1beta 鉴权中间件补齐主中间件的授权校验: API Key 的 IP 白/黑名单、
  专属分组授权、运行时过期/配额二次检查, 修复经 Gemini 端点绕过 IP ACL、
  越权访问专属分组、以及状态未刷新时的配额/有效期绕过窗口。
- 粘性会话等待计划分支改走 newSelectionResult 以 hydrate 账号凭证, 修复调度
  快照中账号凭证被剥离导致等待路径转发鉴权失败。
- SSE 流式转发客户端断开时不再 break 跳过当前事件 usage 合并, 修复少计费。
- Forward 对 nil gin.Context 的防御补齐; 上游错误体读取失败时记录日志避免静默。

前端:
- logout 将本地会话清理移入 finally, 服务端吊销失败也保证本地登出。
- Stripe 弹窗轮询改用正确的 auth_token 键并加防重入; 收到 INIT 后清除兜底
  超时定时器, onUnmounted 清理 message 监听器。
- token 刷新请求补充 30s 超时, 避免挂起导致请求队列与 loading 永久卡死。
- 路由守卫在公共设置未加载时先 await fetchPublicSettings, 避免 payment/
  risk_control 被误判为未启用而错误拦截。
- 支付状态轮询回调补充防重入与终态守卫。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 09:06:56 +08:00

229 lines
7.5 KiB
Go

package middleware
import (
"errors"
"fmt"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// APIKeyAuthGoogle is a Google-style error wrapper for API key auth.
func APIKeyAuthGoogle(apiKeyService *service.APIKeyService, cfg *config.Config) gin.HandlerFunc {
return APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg)
}
// APIKeyAuthWithSubscriptionGoogle behaves like ApiKeyAuthWithSubscription but returns Google-style errors:
// {"error":{"code":401,"message":"...","status":"UNAUTHENTICATED"}}
//
// It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations.
func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
if v := strings.TrimSpace(c.Query("api_key")); v != "" {
abortWithGoogleError(c, 400, "Query parameter api_key is deprecated. Use Authorization header or key instead.")
return
}
apiKeyString := extractAPIKeyForGoogle(c)
if apiKeyString == "" {
abortWithGoogleError(c, 401, "API key is required")
return
}
apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString)
if err != nil {
if errors.Is(err, service.ErrAPIKeyNotFound) {
abortWithGoogleError(c, 401, "Invalid API key")
return
}
abortWithGoogleError(c, 500, "Failed to validate API key")
return
}
// 同 api_key_auth.go:早退中断前也写入 Ops 回退 key,便于错误日志展示
// user/group/platform。
SetOpsFallbackAPIKey(c, apiKey)
// disabled / 未知状态 → 无条件拦截(expired 和 quota_exhausted 留给计费阶段,
// 与主中间件 api_key_auth.go 保持一致)。
if !apiKey.IsActive() &&
apiKey.Status != service.StatusAPIKeyExpired &&
apiKey.Status != service.StatusAPIKeyQuotaExhausted {
abortWithGoogleError(c, 401, "API key is disabled")
return
}
// 检查 IP 限制(白名单/黑名单)。与主中间件保持一致,避免 Gemini 端点绕过 Key 的 IP ACL。
if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 {
clientIP := ip.GetTrustedClientIP(c)
if cfg.TrustForwardedIPForAPIKeyACL() {
clientIP = ip.GetClientIP(c)
}
allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist)
if !allowed {
if clientIP == "" {
clientIP = "unknown"
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonIPRestriction)
abortWithGoogleError(c, 403, fmt.Sprintf("Access denied. Your IP is %s", clientIP))
return
}
}
if apiKey.User == nil {
abortWithGoogleError(c, 401, "User associated with API key not found")
return
}
if !apiKey.User.IsActive() {
abortWithGoogleError(c, 401, "User account is not active")
return
}
if _, message, ok := validateAPIKeyGroupAvailable(apiKey); !ok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable)
abortWithGoogleError(c, 403, message)
return
}
// 专属分组授权校验:用户对该专属分组的授权被撤销后应拒绝(与主中间件一致,防止越权)。
if !validateAPIKeyGroupAllowed(apiKey) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable)
abortWithGoogleError(c, 403, "API Key 所属专属分组不再允许当前用户使用")
return
}
// 简易模式:跳过余额和订阅检查
if cfg.RunMode == config.RunModeSimple {
c.Set(string(ContextKeyAPIKey), apiKey)
c.Set(string(ContextKeyUser), AuthSubject{
UserID: apiKey.User.ID,
Concurrency: apiKey.User.Concurrency,
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
setGroupContext(c, apiKey.Group)
_ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
c.Next()
return
}
// Key 状态检查(状态字段可能因后台异步刷新而滞后,故显式拦截)。
switch apiKey.Status {
case service.StatusAPIKeyQuotaExhausted:
abortWithGoogleError(c, 429, "API key 额度已用完")
return
case service.StatusAPIKeyExpired:
abortWithGoogleError(c, 403, "API key 已过期")
return
}
// 运行时过期/配额检查(即使状态是 active,也要检查时间和用量,与主中间件一致)。
if apiKey.IsExpired() {
abortWithGoogleError(c, 403, "API key 已过期")
return
}
if apiKey.IsQuotaExhausted() {
abortWithGoogleError(c, 429, "API key 额度已用完")
return
}
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
if isSubscriptionType && subscriptionService != nil {
subscription, err := subscriptionService.GetActiveSubscription(
c.Request.Context(),
apiKey.User.ID,
apiKey.Group.ID,
)
if err != nil {
abortWithGoogleError(c, 403, "No active subscription found for this group")
return
}
needsMaintenance, err := subscriptionService.ValidateAndCheckLimits(subscription, apiKey.Group)
if err != nil {
status := 403
if errors.Is(err, service.ErrDailyLimitExceeded) ||
errors.Is(err, service.ErrWeeklyLimitExceeded) ||
errors.Is(err, service.ErrMonthlyLimitExceeded) {
status = 429
}
abortWithGoogleError(c, status, err.Error())
return
}
c.Set(string(ContextKeySubscription), subscription)
if needsMaintenance {
maintenanceCopy := *subscription
subscriptionService.DoWindowMaintenance(&maintenanceCopy)
}
} else {
if apiKeyBalanceBelowAuthThreshold(apiKey.User.Balance, cfg) {
abortWithGoogleError(c, 403, "Insufficient account balance")
return
}
}
c.Set(string(ContextKeyAPIKey), apiKey)
c.Set(string(ContextKeyUser), AuthSubject{
UserID: apiKey.User.ID,
Concurrency: apiKey.User.Concurrency,
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
setGroupContext(c, apiKey.Group)
_ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
c.Next()
}
}
// extractAPIKeyForGoogle extracts API key for Google/Gemini endpoints.
// Priority: x-goog-api-key > Authorization: Bearer > x-api-key > query key
// This allows OpenClaw and other clients using Bearer auth to work with Gemini endpoints.
func extractAPIKeyForGoogle(c *gin.Context) string {
// 1) preferred: Gemini native header
if k := strings.TrimSpace(c.GetHeader("x-goog-api-key")); k != "" {
return k
}
// 2) fallback: Authorization: Bearer <key>
auth := strings.TrimSpace(c.GetHeader("Authorization"))
if auth != "" {
parts := strings.SplitN(auth, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
if k := strings.TrimSpace(parts[1]); k != "" {
return k
}
}
}
// 3) x-api-key header (backward compatibility)
if k := strings.TrimSpace(c.GetHeader("x-api-key")); k != "" {
return k
}
// 4) query parameter key (for specific paths)
if allowGoogleQueryKey(c.Request.URL.Path) {
if v := strings.TrimSpace(c.Query("key")); v != "" {
return v
}
}
return ""
}
func allowGoogleQueryKey(path string) bool {
return strings.HasPrefix(path, "/v1beta") || strings.HasPrefix(path, "/antigravity/v1beta")
}
func abortWithGoogleError(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{
"error": gin.H{
"code": status,
"message": message,
"status": googleapi.HTTPStatusToGoogleStatus(status),
},
})
c.Abort()
}