Files
sub2api/backend/internal/server/middleware/audit_log.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

355 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 认证中间件写入的会话 IDrefresh 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 = "<credential-bearing body omitted>"
} 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
}