Files
sub2api/backend/internal/server/middleware/audit_log.go
T

355 lines
13 KiB
Go
Raw Normal View History

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
}