Files
sub2api/backend/internal/server/middleware/session_binding.go
T
shaw 0ddd58aaf9 feat(security): 操作审计日志 + 会话IP/UA绑定 + 敏感操作 step-up 2FA
应对管理员访问凭证失守导致的数据外泄风险,新增三层防护:

审计日志(admin-only 可见,用户不可见)
- 新增 append-only audit_logs 表(migration 180)+ 异步批量写入 + 保留期清理
- 审计中间件挂在 admin/user/auth/admin-payment 组认证之后:记录所有变更类
  请求 + 白名单敏感读取(账号/代理导出、备份下载、admin/user API key 读取)
- 请求头凭证首尾掩码;请求体 JSON 递归脱敏(api_key/password 等擦除,base_url
  保留以便追责);非 JSON body 不入库
- 无单条删除;全量清空需现场 TOTP 校验、拒绝 admin API key、未启用 2FA 不允许,
  清空后同步写入留痕记录

会话 IP/UA 绑定(默认开启,可在系统设置关闭)
- JWT 携带 session id + IP/UA 指纹哈希;IP 或 UA 任一变化即撤销会话家族并要求
  重新登录;旧 token 无指纹时放行以平滑升级

敏感操作 step-up 2FA(sudo 窗口 15 分钟)
- 账号/代理导出、DB 备份创建/下载、S3 目标修改要求近期 TOTP 二次验证;admin API
  key 一律拒绝;前端 useStepUp 组合式 + TotpStepUpDialog 弹码后自动重试
- API key 查看按需求暂不加强管控

前端:新增 /admin/audit-logs 操作日志页面(筛选/详情/2FA 清空)、侧边栏入口、
step-up 弹窗接入导出与备份流程、安全设置项(绑定开关 + 日志保留天数)、zh/en i18n
2026-07-16 13:47:50 +08:00

82 lines
2.6 KiB
Go

package middleware
import (
"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 回调)读取并写入会话绑定。
// 必须使用 GetTrustedClientIP(走 trusted_proxies 链),不可信头会导致绑定被伪造绕过。
func SessionBindingContext() gin.HandlerFunc {
return func(c *gin.Context) {
binding := &service.SessionBinding{
IP: ip.GetTrustedClientIP(c),
UserAgent: c.Request.UserAgent(),
}
c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding))
c.Next()
}
}
// currentSessionBindingHash 计算当前请求的会话指纹哈希。
func currentSessionBindingHash(c *gin.Context) string {
binding := &service.SessionBinding{
IP: ip.GetTrustedClientIP(c),
UserAgent: c.Request.UserAgent(),
}
return binding.Hash()
}
// 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
}
current := currentSessionBindingHash(c)
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: ip.GetTrustedClientIP(c),
UserAgent: c.Request.UserAgent(),
StatusCode: 401,
})
}
AbortWithError(c, 401, "SESSION_BINDING_MISMATCH", "Session network fingerprint changed, please login again")
return false
}