Files
sub2api/backend/internal/server/middleware/session_binding.go
T

105 lines
3.9 KiB
Go
Raw Normal View History

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
}