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
105 lines
3.9 KiB
Go
105 lines
3.9 KiB
Go
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
|
|
}
|