session_binding.go 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. package middleware
  2. import (
  3. "strings"
  4. "github.com/Wei-Shaw/sub2api/internal/config"
  5. "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
  6. "github.com/Wei-Shaw/sub2api/internal/service"
  7. "github.com/gin-gonic/gin"
  8. )
  9. // SessionBindingContext 全局中间件:将请求的客户端 IP 与 User-Agent 注入
  10. // request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定,
  11. // 同时作为审计日志、会话绑定校验的统一客户端 IP 来源。
  12. // IP 取值与 API Key IP 限制共用转发 IP 开关:开启时旧版原始转发头逻辑
  13. // 接管解析,关闭时使用 Gin 的 server.trusted_proxies 可信代理链。
  14. func SessionBindingContext(cfg *config.Config) gin.HandlerFunc {
  15. return func(c *gin.Context) {
  16. forwardedIPSettings := cfg.ForwardedClientIPSettings()
  17. ip.SetForwardedIPSettings(c, forwardedIPSettings.TrustForwardedIP, forwardedIPSettings.Headers)
  18. userAgent := normalizePersistentText(c.Request.UserAgent(), maxPersistentUserAgentBytes)
  19. c.Request.Header.Set("User-Agent", userAgent)
  20. binding := &service.SessionBinding{
  21. IP: ip.GetSecurityClientIP(c, forwardedIPSettings.TrustForwardedIP),
  22. UserAgent: userAgent,
  23. }
  24. c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding))
  25. c.Next()
  26. }
  27. }
  28. // requestSessionBinding 返回当前请求的会话指纹,优先取 SessionBindingContext
  29. // 注入的解析结果(保证与 token 签发路径取值一致);注入缺失时使用安全回退。
  30. func requestSessionBinding(c *gin.Context) *service.SessionBinding {
  31. if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil {
  32. return binding
  33. }
  34. return &service.SessionBinding{
  35. IP: ip.GetTrustedClientIP(c),
  36. UserAgent: normalizePersistentText(c.Request.UserAgent(), maxPersistentUserAgentBytes),
  37. }
  38. }
  39. // SecurityClientIP 返回当前请求用于安全敏感记录(审计日志等)的客户端 IP。
  40. // 与会话绑定、API Key IP 限制共用同一套客户端 IP 来源。
  41. func SecurityClientIP(c *gin.Context) string {
  42. if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil &&
  43. strings.TrimSpace(binding.IP) != "" {
  44. return binding.IP
  45. }
  46. return ip.GetTrustedClientIP(c)
  47. }
  48. // enforceSessionBinding 校验 access token 的会话指纹(IP/UA 绑定)。
  49. // 指纹不匹配时:撤销该会话家族的所有 refresh token、写入审计安全事件、返回 401。
  50. // 返回 false 表示请求已被中断。
  51. //
  52. // 兼容性:claims.BindingHash 为空(功能上线前签发的旧 token)时放行,
  53. // 该会话在下一次 refresh 轮转时会自动获得绑定。
  54. func enforceSessionBinding(
  55. c *gin.Context,
  56. authService *service.AuthService,
  57. settingService *service.SettingService,
  58. auditService *service.AuditLogService,
  59. claims *service.JWTClaims,
  60. ) bool {
  61. if settingService == nil || !settingService.IsSessionBindingEnabled(c.Request.Context()) {
  62. return true
  63. }
  64. if claims == nil || claims.BindingHash == "" {
  65. return true
  66. }
  67. binding := requestSessionBinding(c)
  68. current := binding.Hash()
  69. if current == "" || current == claims.BindingHash {
  70. return true
  71. }
  72. if authService != nil {
  73. _ = authService.RevokeSessionFamily(c.Request.Context(), claims.SessionID)
  74. }
  75. if auditService != nil {
  76. uid := claims.UserID
  77. path := c.FullPath()
  78. if path == "" {
  79. path = c.Request.URL.Path
  80. }
  81. auditService.Record(&service.AuditLog{
  82. ActorUserID: &uid,
  83. ActorEmail: claims.Email,
  84. ActorRole: claims.Role,
  85. AuthMethod: service.AuditAuthMethodJWT,
  86. Action: service.AuditActionSessionBindingMismatch,
  87. Method: c.Request.Method,
  88. Path: path,
  89. ClientIP: binding.IP,
  90. UserAgent: normalizePersistentText(c.Request.UserAgent(), maxPersistentUserAgentBytes),
  91. StatusCode: 401,
  92. })
  93. }
  94. AbortWithError(c, 401, "SESSION_BINDING_MISMATCH", "Session network fingerprint changed, please login again")
  95. return false
  96. }