Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SessionBindingContext 全局中间件:将请求的客户端 IP 与 User-Agent 注入
|
||||
// request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定,
|
||||
// 同时作为审计日志、会话绑定校验的统一客户端 IP 来源。
|
||||
// IP 取值与 API Key IP 限制共用转发 IP 开关:开启时旧版原始转发头逻辑
|
||||
// 接管解析,关闭时使用 Gin 的 server.trusted_proxies 可信代理链。
|
||||
func SessionBindingContext(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
forwardedIPSettings := cfg.ForwardedClientIPSettings()
|
||||
ip.SetForwardedIPSettings(c, forwardedIPSettings.TrustForwardedIP, forwardedIPSettings.Headers)
|
||||
userAgent := normalizePersistentText(c.Request.UserAgent(), maxPersistentUserAgentBytes)
|
||||
c.Request.Header.Set("User-Agent", userAgent)
|
||||
binding := &service.SessionBinding{
|
||||
IP: ip.GetSecurityClientIP(c, forwardedIPSettings.TrustForwardedIP),
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// requestSessionBinding 返回当前请求的会话指纹,优先取 SessionBindingContext
|
||||
// 注入的解析结果(保证与 token 签发路径取值一致);注入缺失时使用安全回退。
|
||||
func requestSessionBinding(c *gin.Context) *service.SessionBinding {
|
||||
if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil {
|
||||
return binding
|
||||
}
|
||||
return &service.SessionBinding{
|
||||
IP: ip.GetTrustedClientIP(c),
|
||||
UserAgent: normalizePersistentText(c.Request.UserAgent(), maxPersistentUserAgentBytes),
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityClientIP 返回当前请求用于安全敏感记录(审计日志等)的客户端 IP。
|
||||
// 与会话绑定、API Key IP 限制共用同一套客户端 IP 来源。
|
||||
func SecurityClientIP(c *gin.Context) string {
|
||||
if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil &&
|
||||
strings.TrimSpace(binding.IP) != "" {
|
||||
return binding.IP
|
||||
}
|
||||
return ip.GetTrustedClientIP(c)
|
||||
}
|
||||
|
||||
// enforceSessionBinding 校验 access token 的会话指纹(IP/UA 绑定)。
|
||||
// 指纹不匹配时:撤销该会话家族的所有 refresh token、写入审计安全事件、返回 401。
|
||||
// 返回 false 表示请求已被中断。
|
||||
//
|
||||
// 兼容性:claims.BindingHash 为空(功能上线前签发的旧 token)时放行,
|
||||
// 该会话在下一次 refresh 轮转时会自动获得绑定。
|
||||
func enforceSessionBinding(
|
||||
c *gin.Context,
|
||||
authService *service.AuthService,
|
||||
settingService *service.SettingService,
|
||||
auditService *service.AuditLogService,
|
||||
claims *service.JWTClaims,
|
||||
) bool {
|
||||
if settingService == nil || !settingService.IsSessionBindingEnabled(c.Request.Context()) {
|
||||
return true
|
||||
}
|
||||
if claims == nil || claims.BindingHash == "" {
|
||||
return true
|
||||
}
|
||||
binding := requestSessionBinding(c)
|
||||
current := binding.Hash()
|
||||
if current == "" || current == claims.BindingHash {
|
||||
return true
|
||||
}
|
||||
|
||||
if authService != nil {
|
||||
_ = authService.RevokeSessionFamily(c.Request.Context(), claims.SessionID)
|
||||
}
|
||||
if auditService != nil {
|
||||
uid := claims.UserID
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = c.Request.URL.Path
|
||||
}
|
||||
auditService.Record(&service.AuditLog{
|
||||
ActorUserID: &uid,
|
||||
ActorEmail: claims.Email,
|
||||
ActorRole: claims.Role,
|
||||
AuthMethod: service.AuditAuthMethodJWT,
|
||||
Action: service.AuditActionSessionBindingMismatch,
|
||||
Method: c.Request.Method,
|
||||
Path: path,
|
||||
ClientIP: binding.IP,
|
||||
UserAgent: normalizePersistentText(c.Request.UserAgent(), maxPersistentUserAgentBytes),
|
||||
StatusCode: 401,
|
||||
})
|
||||
}
|
||||
AbortWithError(c, 401, "SESSION_BINDING_MISMATCH", "Session network fingerprint changed, please login again")
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user