应对管理员访问凭证失守导致的数据外泄风险,新增三层防护: 审计日志(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
82 lines
2.6 KiB
Go
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
|
|
}
|