mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
应对管理员访问凭证失守导致的数据外泄风险,新增三层防护: 审计日志(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
249 lines
8.2 KiB
Go
249 lines
8.2 KiB
Go
package middleware
|
||
|
||
import (
|
||
"bytes"
|
||
"io"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// AuditLogMiddleware 管理面操作审计中间件类型(用于 wire 注入区分)。
|
||
type AuditLogMiddleware gin.HandlerFunc
|
||
|
||
// 审计相关 gin context 覆写键:handler / 认证中间件可通过这些键补充审计信息。
|
||
const (
|
||
auditCtxKeyAction = "audit_action"
|
||
auditCtxKeyActorID = "audit_actor_id"
|
||
auditCtxKeyActorEmail = "audit_actor_email"
|
||
auditCtxKeySkip = "audit_skip"
|
||
// ContextKeyAuthEmail 认证中间件写入的用户邮箱(审计用)。
|
||
ContextKeyAuthEmail = "auth_email"
|
||
// ContextKeySessionID 认证中间件写入的会话 ID(refresh token family)。
|
||
ContextKeySessionID = "session_id"
|
||
)
|
||
|
||
// SetAuditAction 允许 handler / 中间件为当前请求指定审计动作名(覆盖自动推导)。
|
||
func SetAuditAction(c *gin.Context, action string) {
|
||
c.Set(auditCtxKeyAction, action)
|
||
}
|
||
|
||
// SetAuditActor 允许 handler 在认证上下文缺失时(如登录接口)补充操作者身份。
|
||
func SetAuditActor(c *gin.Context, userID int64, email string) {
|
||
if userID > 0 {
|
||
c.Set(auditCtxKeyActorID, userID)
|
||
}
|
||
if email != "" {
|
||
c.Set(auditCtxKeyActorEmail, email)
|
||
}
|
||
}
|
||
|
||
// SkipAudit 跳过当前请求的审计记录。
|
||
func SkipAudit(c *gin.Context) {
|
||
c.Set(auditCtxKeySkip, true)
|
||
}
|
||
|
||
// auditSensitiveReads 需要审计的敏感 GET 读取(method+FullPath → 动作名)。
|
||
var auditSensitiveReads = map[string]string{
|
||
"GET /api/v1/admin/accounts/data": "admin.accounts.export",
|
||
"GET /api/v1/admin/proxies/data": "admin.proxies.export",
|
||
"GET /api/v1/admin/redeem-codes/export": "admin.redeem_codes.export",
|
||
"GET /api/v1/admin/backups/:id/download-url": "admin.backups.download",
|
||
"GET /api/v1/admin/settings/admin-api-key": "admin.admin_api_key.read",
|
||
"GET /api/v1/admin/users/:id/api-keys": "admin.users.api_keys.read",
|
||
"GET /api/v1/admin/groups/:id/api-keys": "admin.groups.api_keys.read",
|
||
"GET /api/v1/admin/backups/s3-config": "admin.backups.s3_config.read",
|
||
"GET /api/v1/admin/data-management/s3/config": "admin.data_management.s3_config.read",
|
||
}
|
||
|
||
// auditActionOverrides 变更类请求的动作名精确映射(未命中时自动推导)。
|
||
var auditActionOverrides = map[string]string{
|
||
"POST /api/v1/auth/login": service.AuditActionLogin,
|
||
"POST /api/v1/auth/login/2fa": service.AuditActionLogin2FA,
|
||
"POST /api/v1/auth/register": service.AuditActionRegister,
|
||
"POST /api/v1/auth/refresh": service.AuditActionTokenRefresh,
|
||
"POST /api/v1/user/totp/step-up": service.AuditActionStepUpVerify,
|
||
"POST /api/v1/admin/audit-logs/clear": service.AuditActionAuditLogClear,
|
||
"POST /api/v1/admin/accounts/data": "admin.accounts.import",
|
||
"POST /api/v1/admin/backups": "admin.backups.create",
|
||
"POST /api/v1/admin/backups/:id/restore": "admin.backups.restore",
|
||
"DELETE /api/v1/admin/backups/:id": "admin.backups.delete",
|
||
"PUT /api/v1/admin/backups/s3-config": "admin.backups.s3_config.update",
|
||
"POST /api/v1/admin/settings/admin-api-key/regenerate": "admin.admin_api_key.regenerate",
|
||
"DELETE /api/v1/admin/settings/admin-api-key": "admin.admin_api_key.delete",
|
||
}
|
||
|
||
// NewAuditLogMiddleware 创建审计中间件。
|
||
// 记录范围:变更类请求(POST/PUT/PATCH/DELETE)+ 白名单内的敏感 GET 读取。
|
||
// 该中间件应挂在认证中间件之前,以便同时记录认证失败的尝试。
|
||
func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddleware {
|
||
return AuditLogMiddleware(func(c *gin.Context) {
|
||
routeKey := c.Request.Method + " " + c.FullPath()
|
||
|
||
record := false
|
||
action := ""
|
||
switch c.Request.Method {
|
||
case "POST", "PUT", "PATCH", "DELETE":
|
||
record = true
|
||
if v, ok := auditActionOverrides[routeKey]; ok {
|
||
action = v
|
||
}
|
||
case "GET":
|
||
if v, ok := auditSensitiveReads[routeKey]; ok {
|
||
record = true
|
||
action = v
|
||
}
|
||
}
|
||
if !record {
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// 捕获请求体(读出后回填,避免影响后续 ShouldBindJSON)。
|
||
var bodyRedacted string
|
||
if c.Request.Body != nil && c.Request.Method != "GET" {
|
||
raw, err := io.ReadAll(c.Request.Body)
|
||
if err == nil {
|
||
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
|
||
bodyRedacted = service.RedactAuditBody(raw, c.GetHeader("Content-Type"))
|
||
}
|
||
}
|
||
|
||
start := time.Now()
|
||
c.Next()
|
||
|
||
if c.GetBool(auditCtxKeySkip) {
|
||
return
|
||
}
|
||
|
||
status := c.Writer.Status()
|
||
// token 刷新成功属于高频常规操作,只记录失败(潜在攻击信号)。
|
||
if routeKey == "POST /api/v1/auth/refresh" && status < 400 {
|
||
return
|
||
}
|
||
|
||
entry := &service.AuditLog{
|
||
CreatedAt: time.Now().UTC(),
|
||
Action: action,
|
||
Method: c.Request.Method,
|
||
Path: c.FullPath(),
|
||
ClientIP: ip.GetTrustedClientIP(c),
|
||
UserAgent: c.Request.UserAgent(),
|
||
RequestBody: bodyRedacted,
|
||
StatusCode: status,
|
||
LatencyMs: time.Since(start).Milliseconds(),
|
||
}
|
||
if entry.Path == "" {
|
||
entry.Path = c.Request.URL.Path
|
||
}
|
||
if entry.Action == "" {
|
||
entry.Action = deriveAuditAction(c.Request.Method, entry.Path)
|
||
}
|
||
if v, ok := c.Get(auditCtxKeyAction); ok {
|
||
if s, ok := v.(string); ok && s != "" {
|
||
entry.Action = s
|
||
}
|
||
}
|
||
if requestID, ok := c.Request.Context().Value(ctxkey.RequestID).(string); ok {
|
||
entry.RequestID = requestID
|
||
}
|
||
|
||
// 操作者身份:优先取认证中间件写入的上下文,其次取 handler 覆写(登录等场景)。
|
||
if subject, ok := GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
|
||
uid := subject.UserID
|
||
entry.ActorUserID = &uid
|
||
}
|
||
if role, ok := GetUserRoleFromContext(c); ok {
|
||
entry.ActorRole = role
|
||
}
|
||
entry.ActorEmail = c.GetString(ContextKeyAuthEmail)
|
||
entry.AuthMethod = c.GetString("auth_method")
|
||
if entry.AuthMethod == "" && entry.ActorUserID != nil {
|
||
entry.AuthMethod = service.AuditAuthMethodJWT
|
||
}
|
||
if v, ok := c.Get(auditCtxKeyActorID); ok {
|
||
if id, ok := v.(int64); ok && id > 0 {
|
||
entry.ActorUserID = &id
|
||
}
|
||
}
|
||
if v, ok := c.Get(auditCtxKeyActorEmail); ok {
|
||
if s, ok := v.(string); ok && s != "" {
|
||
entry.ActorEmail = s
|
||
}
|
||
}
|
||
|
||
// 请求头凭证掩码(仅保留首尾)。
|
||
entry.CredentialMasked = MaskedRequestCredential(c)
|
||
|
||
extra := map[string]any{}
|
||
if len(c.Params) > 0 {
|
||
params := make(map[string]string, len(c.Params))
|
||
for _, p := range c.Params {
|
||
params[p.Key] = p.Value
|
||
}
|
||
extra["params"] = params
|
||
}
|
||
if q := service.RedactAuditQuery(c.Request.URL.RawQuery); q != "" {
|
||
extra["query"] = q
|
||
}
|
||
if len(extra) > 0 {
|
||
entry.Extra = extra
|
||
}
|
||
|
||
auditService.Record(entry)
|
||
})
|
||
}
|
||
|
||
// MaskedRequestCredential 提取请求头中的凭证并做首尾掩码。
|
||
func MaskedRequestCredential(c *gin.Context) string {
|
||
if apiKey := strings.TrimSpace(c.GetHeader("x-api-key")); apiKey != "" {
|
||
return "x-api-key " + service.MaskAuditCredential(apiKey)
|
||
}
|
||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||
if authHeader == "" {
|
||
return ""
|
||
}
|
||
parts := strings.SplitN(authHeader, " ", 2)
|
||
if len(parts) == 2 {
|
||
return parts[0] + " " + service.MaskAuditCredential(strings.TrimSpace(parts[1]))
|
||
}
|
||
return service.MaskAuditCredential(authHeader)
|
||
}
|
||
|
||
// deriveAuditAction 由 method + 路由模板自动推导动作名,
|
||
// 例:PUT /api/v1/admin/accounts/:id → admin.accounts.update
|
||
func deriveAuditAction(method, fullPath string) string {
|
||
path := strings.TrimPrefix(fullPath, "/api/v1/")
|
||
path = strings.Trim(path, "/")
|
||
segs := strings.Split(path, "/")
|
||
parts := make([]string, 0, len(segs))
|
||
for _, seg := range segs {
|
||
if seg == "" || strings.HasPrefix(seg, ":") || strings.HasPrefix(seg, "*") {
|
||
continue
|
||
}
|
||
parts = append(parts, strings.ReplaceAll(seg, "-", "_"))
|
||
}
|
||
verb := ""
|
||
switch method {
|
||
case "POST":
|
||
verb = "create"
|
||
case "PUT", "PATCH":
|
||
verb = "update"
|
||
case "DELETE":
|
||
verb = "delete"
|
||
case "GET":
|
||
verb = "read"
|
||
default:
|
||
verb = strings.ToLower(method)
|
||
}
|
||
if len(parts) == 0 {
|
||
return verb
|
||
}
|
||
return strings.Join(parts, ".") + "." + verb
|
||
}
|