Behind a reverse proxy (e.g. nginx with X-Real-IP), admin audit logs and session IP/UA binding always recorded 127.0.0.1 because they hardcoded the gin trusted_proxies chain, while API key IP restriction already honored the "trust forwarded client IP" system setting. - add ip.GetSecurityClientIP(c, trustForwarded) as the single source of truth for security-sensitive client IP selection; API key auth middlewares (main + google) refactored onto it with zero behavior change - SessionBindingContext(cfg) now resolves the client IP via the same toggle and injects it into the request context; token issuance, binding enforcement and its mismatch audit record all read the injected value, so issue/verify can never diverge - audit log middleware and audit-log clear trace record the same security client IP (middleware.SecurityClientIP), falling back to the trusted proxy chain when the injection is absent - settings UI hint (zh/en) documents the broadened toggle scope and the one-time re-login after toggling while session binding is enabled With the toggle off (default) behavior is byte-for-byte unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
103 lines
3.7 KiB
Go
103 lines
3.7 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」系统开关:
|
|
// 开启时信任反代转发头(CF-Connecting-IP / X-Real-IP / X-Forwarded-For),
|
|
// 关闭时走 trusted_proxies 解析链,避免不可信头伪造绕过绑定。
|
|
func SessionBindingContext(cfg *config.Config) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
binding := &service.SessionBinding{
|
|
IP: ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL()),
|
|
UserAgent: c.Request.UserAgent(),
|
|
}
|
|
c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding))
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// requestSessionBinding 返回当前请求的会话指纹,优先取 SessionBindingContext
|
|
// 注入的解析结果(保证与 token 签发路径取值一致);注入缺失时按 trusted_proxies
|
|
// 链回退兜底(等价于开关关闭时的行为)。
|
|
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: c.Request.UserAgent(),
|
|
}
|
|
}
|
|
|
|
// 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: c.Request.UserAgent(),
|
|
StatusCode: 401,
|
|
})
|
|
}
|
|
AbortWithError(c, 401, "SESSION_BINDING_MISMATCH", "Session network fingerprint changed, please login again")
|
|
return false
|
|
}
|