193 lines
6.1 KiB
Go
193 lines
6.1 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"kra/internal/config"
|
|
"kra/internal/routecatalog"
|
|
"kra/internal/service/dto"
|
|
systemservice "kra/internal/service/system"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed"
|
|
|
|
func OperationAudit(runtime *config.Store, recorder *systemservice.AuditRecorder) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if runtime == nil || recorder == nil {
|
|
c.Next()
|
|
return
|
|
}
|
|
routePath := c.FullPath()
|
|
if routePath == "" {
|
|
routePath = c.Request.URL.Path
|
|
}
|
|
path := c.Request.URL.Path
|
|
if !routecatalog.ShouldAudit(c.Request.Method, routePath) {
|
|
c.Next()
|
|
return
|
|
}
|
|
var requestBody []byte
|
|
maxBytes := 1024
|
|
if snapshot := runtime.Snapshot(); snapshot != nil && snapshot.Admin != nil && snapshot.Admin.Zap != nil && snapshot.Admin.Zap.AccessLogMaxBytes > 0 {
|
|
maxBytes = snapshot.Admin.Zap.AccessLogMaxBytes
|
|
}
|
|
if c.Request.Method == http.MethodGet {
|
|
requestBody = operationQueryBody(c.Request.URL.RawQuery)
|
|
} else if value, ok := c.Get(ctxReqBodyKey); ok {
|
|
requestBody = []byte(stringValue(value))
|
|
} else if c.Request.Body != nil {
|
|
// Fallback for tests or custom middleware chains that omit AccessLog.
|
|
if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
|
|
requestBody = []byte("[文件]")
|
|
} else {
|
|
limited := http.MaxBytesReader(c.Writer, c.Request.Body, defaultRequestBodyLimit)
|
|
var err error
|
|
requestBody, err = io.ReadAll(limited)
|
|
if err != nil {
|
|
var tooLarge *http.MaxBytesError
|
|
if errors.As(err, &tooLarge) {
|
|
c.AbortWithStatus(http.StatusRequestEntityTooLarge)
|
|
} else {
|
|
c.AbortWithStatus(http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
|
|
}
|
|
}
|
|
// The reference middleware captures up to the global 1 MiB response
|
|
// safety limit and only applies the configured operation-log limit when a
|
|
// download response is recorded. Using maxBytes here would silently
|
|
// truncate ordinary JSON responses before that decision is possible.
|
|
started := time.Now()
|
|
c.Next()
|
|
userID := uint(0)
|
|
if claims := Claims(c); claims != nil {
|
|
userID = claims.ID
|
|
}
|
|
requestID, _ := c.Get("request_id")
|
|
status := c.Writer.Status()
|
|
responseBody := ""
|
|
if value, ok := c.Get(ctxRespBufferKey); ok {
|
|
if body, bok := value.(*bytes.Buffer); bok {
|
|
responseBody = body.String()
|
|
}
|
|
}
|
|
if isDownloadResponse(c) && len(responseBody) > maxBytes {
|
|
responseBody = "[超出记录长度]"
|
|
}
|
|
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
|
|
operationBody := ""
|
|
capturedBody := false
|
|
if c.Request.Method != http.MethodGet {
|
|
if value, ok := c.Get(ctxReqBodyKey); ok {
|
|
// AccessLog has already captured, redacted, summarized, and bounded the
|
|
// request body. Treat that value as final so audit does not process it a
|
|
// second time.
|
|
operationBody = stringValue(value)
|
|
capturedBody = true
|
|
} else {
|
|
operationBody = operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes)
|
|
}
|
|
} else {
|
|
operationBody = operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes)
|
|
}
|
|
if !capturedBody && routecatalog.BodyPolicyFor(c.Request.Method, path) == routecatalog.BodyPolicyPaymentConfig {
|
|
operationBody = paymentConfigSummary(requestBody)
|
|
}
|
|
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
|
|
// Preserve the business response, but expose audit persistence failures
|
|
// to the global access/error logging pipeline.
|
|
c.Set(ctxOperationAuditPersistFailedKey, true)
|
|
_ = c.Error(fmt.Errorf("operation audit persist: %w", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func operationQueryBody(raw string) []byte {
|
|
parsed, _ := url.ParseQuery(raw)
|
|
values := make(map[string]string, len(parsed))
|
|
for key, items := range parsed {
|
|
if len(items) > 0 {
|
|
value := items[len(items)-1]
|
|
if isSensitivePayloadKey(key) {
|
|
value = redactedValue
|
|
}
|
|
values[key] = value
|
|
}
|
|
}
|
|
body, _ := json.Marshal(&values)
|
|
return body
|
|
}
|
|
|
|
func operationRequestBody(raw []byte, contentType string, limit int) string {
|
|
if strings.Contains(contentType, "multipart/form-data") {
|
|
return "[文件]"
|
|
}
|
|
text := string(raw)
|
|
if strings.Contains(strings.ToLower(contentType), "json") && text != "" {
|
|
var value any
|
|
if json.Unmarshal(raw, &value) == nil {
|
|
maskOperationBody(value)
|
|
if encoded, err := json.Marshal(value); err == nil {
|
|
text = string(encoded)
|
|
}
|
|
}
|
|
}
|
|
if len(text) > limit {
|
|
return "[超出记录长度]"
|
|
}
|
|
return text
|
|
}
|
|
|
|
func maskOperationBody(value any) {
|
|
switch current := value.(type) {
|
|
case map[string]any:
|
|
for key, item := range current {
|
|
if isSensitivePayloadKey(key) {
|
|
current[key] = redactedValue
|
|
continue
|
|
}
|
|
maskOperationBody(item)
|
|
}
|
|
case []any:
|
|
for _, item := range current {
|
|
maskOperationBody(item)
|
|
}
|
|
}
|
|
}
|
|
|
|
// operationDownloadHeaders lists the response headers whose presence marks a
|
|
// download: the value is the substring that identifies it.
|
|
var operationDownloadHeaders = [...]struct{ header, marker string }{
|
|
{"Pragma", "public"},
|
|
{"Expires", "0"},
|
|
{"Cache-Control", "must-revalidate, post-check=0, pre-check=0"},
|
|
{"Content-Type", "application/force-download"},
|
|
{"Content-Type", "application/octet-stream"},
|
|
{"Content-Type", "application/vnd.ms-excel"},
|
|
{"Content-Type", "application/download"},
|
|
{"Content-Disposition", "attachment"},
|
|
{"Content-Transfer-Encoding", "binary"},
|
|
}
|
|
|
|
func isDownloadResponse(c *gin.Context) bool {
|
|
header := c.Writer.Header()
|
|
for _, candidate := range operationDownloadHeaders {
|
|
if strings.Contains(header.Get(candidate.header), candidate.marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|