mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-06 08:49:24 +00:00
用户可高频刷面板接口(usage/dashboard 等重聚合查询)直接打爆数据库:
现有限流器只覆盖登录/注册等公开认证入口,登录后的全部面板端点无任何限流。
三层防护(阈值均可在后台可视化配置,panel_rate_limit_settings):
1. 认证面板接口按「用户 ID」限流,与来源 IP 无关——反向代理/NAT 共享出口
(所有请求源地址坍缩为 127.0.0.1 等)不会互相误伤:
- Global 档(默认 240 rpm/账号):user/auth/payment/admin 全部登录后路由
- Heavy 档(默认 60 rpm/账号):/usage、/usage/dashboard/*、
/user/api-keys/:id/usage/daily 等重 SQL 聚合端点叠加计数
- 管理员默认豁免(可关闭)
2. 无认证公开接口(/api/v1/settings/*,每次请求都查 DB)按安全客户端 IP
限流(默认 300 rpm/IP);回环/私网/链路本地地址(反代内部转发地址)
一律跳过计数,杜绝把整条反代链路合并进同一个桶造成大面积误拦截。
3. 修复既有隐患:auth 入口限流的 IP 取值从 c.ClientIP() 切换到与审计日志/
会话绑定/API Key ACL 同源的安全客户端 IP 解析(尊重后台「信任反代转发
IP」开关快照)。原实现下默认反代部署(未配置 server.trusted_proxies)
所有用户共享同一个登录限流桶,既会全员误拦也可被单人恶意占满形成登录
DoS;开关关闭时行为与原来完全一致。
工程约束:
- 配置热路径走进程内缓存(atomic.Value + singleflight,60s TTL),
限流中间件零 DB 访问;保存后当前节点立即生效
- 面板限流 Redis 故障 fail-open(auth 入口保持原有 fail-close)
- 429 响应携带 Retry-After;错误码 RATE_LIMITED
- 支付 webhook / 公开支付回调有意不挂限流
- 新增 GET/PUT /api/v1/admin/settings/panel-rate-limit;设置页安全 tab
新增「面板接口限流」卡片(zh/en i18n 全量)
测试:rate_limiter/panel_rate_limit/setting_panel_rate_limit 单测全绿;
routes、handler/admin、-tags unit 契约测试通过;前端 vue-tsc/ESLint/
SettingsView spec(26/26,含新增交互用例)/i18n 守卫全部通过。
239 lines
10 KiB
Go
239 lines
10 KiB
Go
package routes
|
||
|
||
import (
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||
"github.com/Wei-Shaw/sub2api/internal/middleware"
|
||
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/redis/go-redis/v9"
|
||
)
|
||
|
||
// RegisterAuthRoutes 注册认证相关路由
|
||
func RegisterAuthRoutes(
|
||
v1 *gin.RouterGroup,
|
||
h *handler.Handlers,
|
||
jwtAuth servermiddleware.JWTAuthMiddleware,
|
||
auditLog servermiddleware.AuditLogMiddleware,
|
||
redisClient *redis.Client,
|
||
settingService *service.SettingService,
|
||
panelRateLimiter *servermiddleware.PanelRateLimiter,
|
||
) {
|
||
// 创建速率限制器
|
||
rateLimiter := middleware.NewRateLimiter(redisClient)
|
||
|
||
// 公开接口
|
||
auth := v1.Group("/auth")
|
||
auth.Use(servermiddleware.BackendModeAuthGuard(settingService))
|
||
// 认证事件(登录/注册/2FA/token 刷新失败)入审计
|
||
auth.Use(gin.HandlerFunc(auditLog))
|
||
{
|
||
// 注册/登录/2FA/验证码发送均属于高风险入口,增加服务端兜底限流(Redis 故障时 fail-close)
|
||
auth.POST("/register", rateLimiter.LimitWithOptions("auth-register", 5, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}), h.Auth.Register)
|
||
auth.POST("/login", rateLimiter.LimitWithOptions("auth-login", 20, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}), h.Auth.Login)
|
||
auth.POST("/login/2fa", rateLimiter.LimitWithOptions("auth-login-2fa", 20, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}), h.Auth.Login2FA)
|
||
auth.POST("/send-verify-code", rateLimiter.LimitWithOptions("auth-send-verify-code", 5, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}), h.Auth.SendVerifyCode)
|
||
// Token刷新接口添加速率限制:每分钟最多 30 次(Redis 故障时 fail-close)
|
||
auth.POST("/refresh", rateLimiter.LimitWithOptions("refresh-token", 30, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}), h.Auth.RefreshToken)
|
||
// 登出接口(公开,允许未认证用户调用以撤销Refresh Token)
|
||
auth.POST("/logout", h.Auth.Logout)
|
||
// 优惠码验证接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close)
|
||
auth.POST("/validate-promo-code", rateLimiter.LimitWithOptions("validate-promo", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}), h.Auth.ValidatePromoCode)
|
||
// 邀请码验证接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close)
|
||
auth.POST("/validate-invitation-code", rateLimiter.LimitWithOptions("validate-invitation", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}), h.Auth.ValidateInvitationCode)
|
||
// 忘记密码接口添加速率限制:每分钟最多 5 次(Redis 故障时 fail-close)
|
||
auth.POST("/forgot-password", rateLimiter.LimitWithOptions("forgot-password", 5, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}), h.Auth.ForgotPassword)
|
||
// 重置密码接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close)
|
||
auth.POST("/reset-password", rateLimiter.LimitWithOptions("reset-password", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}), h.Auth.ResetPassword)
|
||
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
||
auth.GET("/oauth/github/start", h.Auth.GitHubOAuthStart)
|
||
auth.GET("/oauth/github/callback", h.Auth.GitHubOAuthCallback)
|
||
auth.POST("/oauth/github/complete-registration",
|
||
rateLimiter.LimitWithOptions("oauth-github-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CompleteGitHubOAuthRegistration,
|
||
)
|
||
auth.GET("/oauth/google/start", h.Auth.GoogleOAuthStart)
|
||
auth.GET("/oauth/google/callback", h.Auth.GoogleOAuthCallback)
|
||
auth.POST("/oauth/google/complete-registration",
|
||
rateLimiter.LimitWithOptions("oauth-google-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CompleteGoogleOAuthRegistration,
|
||
)
|
||
auth.GET("/oauth/linuxdo/bind/start", func(c *gin.Context) {
|
||
query := c.Request.URL.Query()
|
||
query.Set("intent", "bind_current_user")
|
||
c.Request.URL.RawQuery = query.Encode()
|
||
h.Auth.LinuxDoOAuthStart(c)
|
||
})
|
||
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
||
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
|
||
auth.GET("/oauth/wechat/bind/start", func(c *gin.Context) {
|
||
query := c.Request.URL.Query()
|
||
query.Set("intent", "bind_current_user")
|
||
c.Request.URL.RawQuery = query.Encode()
|
||
h.Auth.WeChatOAuthStart(c)
|
||
})
|
||
auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback)
|
||
auth.GET("/oauth/wechat/payment/start", h.Auth.WeChatPaymentOAuthStart)
|
||
auth.GET("/oauth/wechat/payment/callback", h.Auth.WeChatPaymentOAuthCallback)
|
||
auth.POST("/oauth/pending/exchange",
|
||
rateLimiter.LimitWithOptions("oauth-pending-exchange", 20, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.ExchangePendingOAuthCompletion,
|
||
)
|
||
auth.POST("/oauth/pending/send-verify-code",
|
||
rateLimiter.LimitWithOptions("oauth-pending-send-verify-code", 5, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.SendPendingOAuthVerifyCode,
|
||
)
|
||
auth.POST("/oauth/pending/create-account",
|
||
rateLimiter.LimitWithOptions("oauth-pending-create-account", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CreatePendingOAuthAccount,
|
||
)
|
||
auth.POST("/oauth/pending/bind-login",
|
||
rateLimiter.LimitWithOptions("oauth-pending-bind-login", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.BindPendingOAuthLogin,
|
||
)
|
||
auth.POST("/oauth/linuxdo/complete-registration",
|
||
rateLimiter.LimitWithOptions("oauth-linuxdo-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CompleteLinuxDoOAuthRegistration,
|
||
)
|
||
auth.POST("/oauth/linuxdo/bind-login",
|
||
rateLimiter.LimitWithOptions("oauth-linuxdo-bind-login", 20, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.BindLinuxDoOAuthLogin,
|
||
)
|
||
auth.POST("/oauth/linuxdo/create-account",
|
||
rateLimiter.LimitWithOptions("oauth-linuxdo-create-account", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CreateLinuxDoOAuthAccount,
|
||
)
|
||
auth.POST("/oauth/wechat/complete-registration",
|
||
rateLimiter.LimitWithOptions("oauth-wechat-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CompleteWeChatOAuthRegistration,
|
||
)
|
||
auth.POST("/oauth/wechat/bind-login",
|
||
rateLimiter.LimitWithOptions("oauth-wechat-bind-login", 20, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.BindWeChatOAuthLogin,
|
||
)
|
||
auth.POST("/oauth/wechat/create-account",
|
||
rateLimiter.LimitWithOptions("oauth-wechat-create-account", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CreateWeChatOAuthAccount,
|
||
)
|
||
auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart)
|
||
auth.GET("/oauth/oidc/bind/start", func(c *gin.Context) {
|
||
query := c.Request.URL.Query()
|
||
query.Set("intent", "bind_current_user")
|
||
c.Request.URL.RawQuery = query.Encode()
|
||
h.Auth.OIDCOAuthStart(c)
|
||
})
|
||
auth.GET("/oauth/oidc/callback", h.Auth.OIDCOAuthCallback)
|
||
auth.POST("/oauth/oidc/complete-registration",
|
||
rateLimiter.LimitWithOptions("oauth-oidc-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CompleteOIDCOAuthRegistration,
|
||
)
|
||
auth.POST("/oauth/oidc/bind-login",
|
||
rateLimiter.LimitWithOptions("oauth-oidc-bind-login", 20, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.BindOIDCOAuthLogin,
|
||
)
|
||
auth.POST("/oauth/oidc/create-account",
|
||
rateLimiter.LimitWithOptions("oauth-oidc-create-account", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CreateOIDCOAuthAccount,
|
||
)
|
||
auth.GET("/oauth/dingtalk/start", h.Auth.DingTalkOAuthStart)
|
||
auth.GET("/oauth/dingtalk/bind/start", func(c *gin.Context) {
|
||
query := c.Request.URL.Query()
|
||
query.Set("intent", "bind_current_user")
|
||
c.Request.URL.RawQuery = query.Encode()
|
||
h.Auth.DingTalkOAuthStart(c)
|
||
})
|
||
auth.GET("/oauth/dingtalk/callback", h.Auth.DingTalkOAuthCallback)
|
||
auth.POST("/oauth/dingtalk/complete-registration",
|
||
rateLimiter.LimitWithOptions("oauth-dingtalk-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CompleteDingTalkOAuthRegistration,
|
||
)
|
||
auth.POST("/oauth/dingtalk/bind-login",
|
||
rateLimiter.LimitWithOptions("oauth-dingtalk-bind-login", 20, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.BindDingTalkOAuthLogin,
|
||
)
|
||
auth.POST("/oauth/dingtalk/create-account",
|
||
rateLimiter.LimitWithOptions("oauth-dingtalk-create-account", 10, time.Minute, middleware.RateLimitOptions{
|
||
FailureMode: middleware.RateLimitFailClose,
|
||
}),
|
||
h.Auth.CreateDingTalkOAuthAccount,
|
||
)
|
||
}
|
||
|
||
// 公开设置(无需认证):每次请求都会查询 DB,按客户端 IP 兜底限流,
|
||
// 防止匿名高频刷接口打爆数据库(反代内部地址会被自动跳过,不会误伤)。
|
||
settings := v1.Group("/settings")
|
||
settings.Use(panelRateLimiter.PublicIP())
|
||
{
|
||
settings.GET("/public", h.Setting.GetPublicSettings)
|
||
settings.GET("/email-unsubscribe", h.Setting.UnsubscribeNotificationEmail)
|
||
}
|
||
|
||
// 需要认证的当前用户信息
|
||
authenticated := v1.Group("")
|
||
authenticated.Use(gin.HandlerFunc(jwtAuth))
|
||
authenticated.Use(servermiddleware.BackendModeUserGuard(settingService))
|
||
// 面板全局按用户限流
|
||
authenticated.Use(panelRateLimiter.Global())
|
||
{
|
||
authenticated.GET("/auth/me", h.Auth.GetCurrentUser)
|
||
// 撤销所有会话(需要认证)
|
||
authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions)
|
||
authenticated.POST("/auth/oauth/bind-token", h.Auth.PrepareOAuthBindAccessTokenCookie)
|
||
}
|
||
}
|