package middleware import ( "bytes" "io" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "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" auditCtxKeyExtra = "audit_extra" // 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) } // auditExtraAllowedKeys is deliberately narrow: handlers may only attach // scalar, non-secret operation summaries. Request bodies and arbitrary maps // are never accepted through this channel. var auditExtraAllowedKeys = map[string]struct{}{ "result": {}, "error_code": {}, "enabled": {}, "blocking_enabled": {}, "config_version": {}, "endpoint_count": {}, "scanner_count": {}, "all_groups": {}, "group_count": {}, "guard_endpoint_id": {}, "http_status": {}, "latency_ms": {}, "token_applied": {}, "retryable": {}, "event_id": {}, "requested_count": {}, "deleted_events": {}, "deleted_jobs": {}, "matched_count": {}, "snapshot_max_id": {}, "filter_hash": {}, "confirm": {}, } // SetAuditExtra adds allowlisted, scalar details to the current audit entry. // It is safe to call more than once; later values replace earlier ones. func SetAuditExtra(c *gin.Context, fields map[string]any) { if c == nil || len(fields) == 0 { return } current := map[string]any{} if value, ok := c.Get(auditCtxKeyExtra); ok { if existing, ok := value.(map[string]any); ok { for key, item := range existing { current[key] = item } } } for key, value := range fields { if _, ok := auditExtraAllowedKeys[key]; !ok || !isAuditExtraScalar(value) { continue } if text, ok := value.(string); ok { value = truncateAuditExtraString(text, 128) } current[key] = value } c.Set(auditCtxKeyExtra, current) } func isAuditExtraScalar(value any) bool { switch value.(type) { case string, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: return true default: return false } } func truncateAuditExtraString(value string, limit int) string { value = strings.TrimSpace(value) runes := []rune(value) if len(runes) <= limit { return value } return string(runes[:limit]) } // 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/passkey/login/finish": service.AuditActionLogin, "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", "PUT /api/v1/admin/prompt-audit/config": "admin.prompt_audit.config.update", "POST /api/v1/admin/prompt-audit/endpoints/probe": "admin.prompt_audit.endpoint.probe", "DELETE /api/v1/admin/prompt-audit/events/:id": "admin.prompt_audit.event.delete", "POST /api/v1/admin/prompt-audit/events/batch-delete": "admin.prompt_audit.events.batch_delete", "POST /api/v1/admin/prompt-audit/events/delete-preview": "admin.prompt_audit.events.delete_preview", "POST /api/v1/admin/prompt-audit/events/delete-by-filter": "admin.prompt_audit.events.filter_delete", } // auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。 // 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。 var auditBodyOmittedRoutes = map[string]struct{}{ "POST /api/v1/auth/passkey/login/finish": {}, "POST /api/v1/user/passkeys/register/finish": {}, "POST /api/v1/admin/accounts/import/codex-session": {}, "PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session": {}, "PUT /api/v1/admin/prompt-audit/config": {}, "POST /api/v1/admin/prompt-audit/endpoints/probe": {}, "DELETE /api/v1/admin/prompt-audit/events/:id": {}, "POST /api/v1/admin/prompt-audit/events/batch-delete": {}, "POST /api/v1/admin/prompt-audit/events/delete-preview": {}, "POST /api/v1/admin/prompt-audit/events/delete-by-filter": {}, } // NewAuditLogMiddleware 创建审计中间件。 // 记录范围:变更类请求(POST/PUT/PATCH/DELETE)+ 白名单内的敏感 GET 读取。 // 挂载位置:admin / user / admin-payment 组挂在各自认证中间件之后(只审计已认证请求, // 未过认证的 401/403 不入库);auth 组(登录/注册/刷新)无前置认证,天然记录失败尝试。 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 _, omit := auditBodyOmittedRoutes[routeKey]; omit { bodyRedacted = "" } else if c.Request.Body != nil && c.Request.Method != "GET" { orig := c.Request.Body raw, err := io.ReadAll(io.LimitReader(orig, service.AuditRequestBodyCaptureLimit+1)) if err == nil { c.Request.Body = &restoredBody{ Reader: io.MultiReader(bytes.NewReader(raw), orig), closer: orig, } 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: SecurityClientIP(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 value, ok := c.Get(auditCtxKeyExtra); ok { if details, ok := value.(map[string]any); ok { for key, item := range details { extra[key] = item } } } 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) }) } // restoredBody 把审计中间件按上限读出的前缀与未读完的原始 body 拼接回填, // 保证 handler 读到完整请求体;Close 委托给原始 body。 type restoredBody struct { io.Reader closer io.Closer } func (b *restoredBody) Close() error { return b.closer.Close() } // 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 }