mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
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>
This commit is contained in:
committed by
Clawd
co-authored by
Cursor
parent
6f43986c37
commit
29a5fcd25e
@@ -2,10 +2,12 @@ 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"
|
||||
@@ -46,10 +48,32 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
|
||||
// user/group/platform。
|
||||
SetOpsFallbackAPIKey(c, apiKey)
|
||||
|
||||
if !apiKey.IsActive() {
|
||||
// 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
|
||||
@@ -63,6 +87,12 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
|
||||
abortWithGoogleError(c, 403, message)
|
||||
return
|
||||
}
|
||||
// 专属分组授权校验:用户对该专属分组的授权被撤销后应拒绝(与主中间件一致,防止越权)。
|
||||
if !validateAPIKeyGroupAllowed(apiKey) {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable)
|
||||
abortWithGoogleError(c, 403, "API Key 所属专属分组不再允许当前用户使用")
|
||||
return
|
||||
}
|
||||
|
||||
// 简易模式:跳过余额和订阅检查
|
||||
if cfg.RunMode == config.RunModeSimple {
|
||||
@@ -78,6 +108,26 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
|
||||
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(
|
||||
|
||||
@@ -165,7 +165,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
// 最低缓存门槛,导致系统级缓存失效)。
|
||||
//
|
||||
// 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。
|
||||
isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
|
||||
var clientUserAgent string
|
||||
if c != nil {
|
||||
clientUserAgent = c.GetHeader("User-Agent")
|
||||
}
|
||||
isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(clientUserAgent, parsed.MetadataUserID)
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
||||
|
||||
if shouldMimicClaudeCode {
|
||||
@@ -190,7 +194,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
// 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。
|
||||
// 两种情况下 enforceCacheControlLimit 都会兜底处理上限。
|
||||
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
|
||||
if s.identityService != nil {
|
||||
if s.identityService != nil && c != nil {
|
||||
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
|
||||
if err == nil && fp != nil {
|
||||
// metadata 透传开启时跳过 metadata 注入
|
||||
@@ -220,7 +224,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Set(toolNameRewriteKey, rw)
|
||||
if c != nil {
|
||||
c.Set(toolNameRewriteKey, rw)
|
||||
}
|
||||
} else {
|
||||
if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -360,15 +360,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
stickyCacheMissReason = "session_limit"
|
||||
// 会话限制已满,继续到负载感知选择
|
||||
} else {
|
||||
return &AccountSelectionResult{
|
||||
Account: stickyAccount,
|
||||
WaitPlan: &AccountWaitPlan{
|
||||
AccountID: stickyAccountID,
|
||||
MaxConcurrency: stickyAccount.Concurrency,
|
||||
Timeout: cfg.StickySessionWaitTimeout,
|
||||
MaxWaiting: cfg.StickySessionMaxWaiting,
|
||||
},
|
||||
}, nil
|
||||
// 必须走 newSelectionResult 以 hydrate 账号凭证:
|
||||
// 调度快照中的账号是精简版(OAuth token 等被剥离),
|
||||
// 直接返回会导致后续转发缺少凭证而鉴权失败。
|
||||
return s.newSelectionResult(ctx, stickyAccount, false, nil, &AccountWaitPlan{
|
||||
AccountID: stickyAccountID,
|
||||
MaxConcurrency: stickyAccount.Concurrency,
|
||||
Timeout: cfg.StickySessionWaitTimeout,
|
||||
MaxWaiting: cfg.StickySessionMaxWaiting,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
stickyCacheMissReason = "wait_queue_full"
|
||||
|
||||
@@ -356,7 +356,13 @@ func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, err
|
||||
}
|
||||
|
||||
func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) {
|
||||
body, _ := s.readUpstreamErrorBody(resp)
|
||||
body, readErr := s.readUpstreamErrorBody(resp)
|
||||
if readErr != nil {
|
||||
// 读取失败时 body 可能被截断,错误分类会基于不完整数据;记录日志以便排查,
|
||||
// 避免静默吞掉导致误判。
|
||||
logger.LegacyPrintf("service.gateway", "[Forward] Failed to fully read upstream error body: Account=%d(%s) Status=%d err=%v",
|
||||
account.ID, account.Name, resp.StatusCode, readErr)
|
||||
}
|
||||
|
||||
// 调试日志:打印上游错误响应
|
||||
logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (non-retryable): Account=%d(%s) Status=%d RequestID=%s Body=%s",
|
||||
@@ -1023,11 +1029,14 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
|
||||
if _, werr := fmt.Fprint(w, string(restored)); werr != nil {
|
||||
clientDisconnected = true
|
||||
logger.LegacyPrintf("service.gateway", "Client disconnected during streaming, continuing to drain upstream for billing")
|
||||
break
|
||||
// 不 break:客户端断开后仍需继续合并本事件及后续事件的 usage,
|
||||
// 否则会漏计当前事件携带的 usage 导致少计费。后续写入由
|
||||
// clientDisconnected 守卫跳过。
|
||||
} else {
|
||||
flusher.Flush()
|
||||
lastDataAt = time.Now()
|
||||
resetKeepaliveTimer()
|
||||
}
|
||||
flusher.Flush()
|
||||
lastDataAt = time.Now()
|
||||
resetKeepaliveTimer()
|
||||
}
|
||||
if data != "" {
|
||||
if firstTokenMs == nil && data != "[DONE]" {
|
||||
|
||||
@@ -205,7 +205,9 @@ apiClient.interceptors.response.use(
|
||||
const refreshResponse = await axios.post(
|
||||
`${getAPIBaseURL()}/auth/refresh`,
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
// 显式设置超时:裸 axios 默认无限等待,若刷新请求挂起会导致 isRefreshing
|
||||
// 永远为 true,所有排队的 401 重试请求永久卡死,页面 loading 无法恢复。
|
||||
{ headers: { 'Content-Type': 'application/json' }, timeout: 30000 }
|
||||
)
|
||||
|
||||
const refreshData = refreshResponse.data as ApiResponse<{
|
||||
|
||||
@@ -275,22 +275,33 @@ async function tryRecoverPendingOrder(order: PaymentOrder): Promise<PaymentOrder
|
||||
}
|
||||
}
|
||||
|
||||
let pollInFlight = false
|
||||
async function pollStatus() {
|
||||
if (!props.orderId || outcome.value) return
|
||||
let order = await paymentStore.pollOrderStatus(props.orderId)
|
||||
if (!order) return
|
||||
order = await tryRecoverPendingOrder(order)
|
||||
if (isSuccessStatus(order.status)) {
|
||||
cleanup()
|
||||
paidOrder.value = order
|
||||
setOutcome('success')
|
||||
emit('success')
|
||||
} else if (order.status === 'CANCELLED') {
|
||||
cleanup()
|
||||
setOutcome('cancelled')
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
setOutcome('expired')
|
||||
// 防重入:接口(含 verifyOrder 二次确认)响应慢于 3 秒轮询间隔时避免并发重叠请求。
|
||||
if (pollInFlight) return
|
||||
pollInFlight = true
|
||||
try {
|
||||
let order = await paymentStore.pollOrderStatus(props.orderId)
|
||||
if (!order) return
|
||||
// 已进入终态则不再处理迟到的响应。
|
||||
if (outcome.value) return
|
||||
order = await tryRecoverPendingOrder(order)
|
||||
if (outcome.value) return
|
||||
if (isSuccessStatus(order.status)) {
|
||||
cleanup()
|
||||
paidOrder.value = order
|
||||
setOutcome('success')
|
||||
emit('success')
|
||||
} else if (order.status === 'CANCELLED') {
|
||||
cleanup()
|
||||
setOutcome('cancelled')
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
setOutcome('expired')
|
||||
}
|
||||
} finally {
|
||||
pollInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -826,6 +826,17 @@ router.beforeEach(async (to, _from, next) => {
|
||||
}
|
||||
|
||||
|
||||
// 公共设置可能尚未加载(App.vue 的 onMounted 异步拉取晚于首次导航,且纯静态部署
|
||||
// 无 __APP_CONFIG__ 注入)。此时 cachedPublicSettings 为空会把 payment/risk_control
|
||||
// 误判为“未启用”而错误拦截,故这里先确保设置加载完成。
|
||||
if ((to.meta.requiresPayment || to.meta.requiresRiskControl) && !appStore.publicSettingsLoaded) {
|
||||
try {
|
||||
await appStore.fetchPublicSettings()
|
||||
} catch (error) {
|
||||
console.warn('Failed to load public settings in route guard', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Check payment requirement (internal payment system only)
|
||||
if (to.meta.requiresPayment) {
|
||||
const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled
|
||||
|
||||
@@ -397,11 +397,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
* Clears all authentication state and persisted data
|
||||
*/
|
||||
async function logout(): Promise<void> {
|
||||
// Call API logout (revokes refresh token on server)
|
||||
await authAPI.logout()
|
||||
|
||||
// Clear state
|
||||
clearAuth()
|
||||
try {
|
||||
// Call API logout (revokes refresh token on server)
|
||||
await authAPI.logout()
|
||||
} catch (err) {
|
||||
// 服务端吊销失败(网络/5xx/超时)不应阻止本地登出,否则用户点了退出仍处于登录态。
|
||||
console.warn('Logout API call failed, clearing local session anyway', err)
|
||||
} finally {
|
||||
// Always clear local state (tokens, user data, refresh timers)
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -132,16 +132,26 @@ async function renderQR() {
|
||||
}
|
||||
}
|
||||
|
||||
let pollInFlight = false
|
||||
async function pollStatus() {
|
||||
if (!orderId.value) return
|
||||
const order = await paymentStore.pollOrderStatus(orderId.value)
|
||||
if (!order) return
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } })
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
expired.value = true
|
||||
// 防重入:接口响应慢于 3 秒轮询间隔时避免并发重叠请求与重复跳转。
|
||||
if (pollInFlight) return
|
||||
pollInFlight = true
|
||||
try {
|
||||
const order = await paymentStore.pollOrderStatus(orderId.value)
|
||||
if (!order) return
|
||||
// 定时器已被 cleanup 清除时不再执行终态跳转(响应可能在 cleanup 后才回来)。
|
||||
if (!pollTimer) return
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } })
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
expired.value = true
|
||||
}
|
||||
} finally {
|
||||
pollInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,23 +84,38 @@ const success = ref(false)
|
||||
const hint = ref(t('payment.stripePopup.redirecting'))
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let initTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let messageHandler: ((event: MessageEvent) => void) | null = null
|
||||
|
||||
function closeWindow() { window.close() }
|
||||
|
||||
function clearInitTimeout() {
|
||||
if (initTimeoutTimer) {
|
||||
clearTimeout(initTimeoutTimer)
|
||||
initTimeoutTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
messageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return
|
||||
if (event.data?.type !== 'STRIPE_POPUP_INIT') return
|
||||
window.removeEventListener('message', handler)
|
||||
// INIT 已到达,取消兜底超时,避免长时间的扫码支付被误判为超时。
|
||||
clearInitTimeout()
|
||||
if (messageHandler) {
|
||||
window.removeEventListener('message', messageHandler)
|
||||
messageHandler = null
|
||||
}
|
||||
initStripe(event.data.clientSecret, event.data.publishableKey)
|
||||
}
|
||||
window.addEventListener('message', handler)
|
||||
window.addEventListener('message', messageHandler)
|
||||
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// 仅兜底“父窗口始终未发 STRIPE_POPUP_INIT”的场景。
|
||||
initTimeoutTimer = setTimeout(() => {
|
||||
if (!error.value && !success.value) {
|
||||
error.value = t('payment.stripePopup.timeout')
|
||||
}
|
||||
@@ -108,7 +123,12 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
clearInitTimeout()
|
||||
if (messageHandler) {
|
||||
window.removeEventListener('message', messageHandler)
|
||||
messageHandler = null
|
||||
}
|
||||
})
|
||||
|
||||
async function initStripe(clientSecret: string, publishableKey: string) {
|
||||
@@ -149,10 +169,15 @@ async function initStripe(clientSecret: string, publishableKey: string) {
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
let inFlight = false
|
||||
pollTimer = setInterval(async () => {
|
||||
// 防重入:接口响应慢于轮询间隔时避免并发重叠请求。
|
||||
if (inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const token = document.cookie.split('; ').find(c => c.startsWith('token='))?.split('=')[1]
|
||||
|| localStorage.getItem('token') || ''
|
||||
// access token 存储在 localStorage 的 'auth_token' 键下(见 api/client.ts),
|
||||
// 之前误读 'token' 导致轮询请求不带认证、永远 401,支付成功无法被检测到。
|
||||
const token = localStorage.getItem('auth_token') || ''
|
||||
const res = await fetch(buildApiUrl(`/payment/orders/${orderId}`), {
|
||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
credentials: 'include',
|
||||
@@ -165,7 +190,9 @@ function startPolling() {
|
||||
success.value = true
|
||||
setTimeout(closeWindow, 2000)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch { /* ignore */ } finally {
|
||||
inFlight = false
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user