179 lines
8.8 KiB
Go
179 lines
8.8 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"kra/internal/conf"
|
|
"kra/internal/service"
|
|
"kra/internal/service/dto"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
path := c.Request.URL.Path
|
|
if !recordsOperation(c.Request.Method, path) {
|
|
c.Next()
|
|
return
|
|
}
|
|
var requestBody []byte
|
|
maxBytes := 1024
|
|
if config := runtime.Admin(); config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
|
|
maxBytes = int(config.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 {
|
|
requestBody, _ = io.ReadAll(c.Request.Body)
|
|
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
|
|
} else if value, err := strconv.ParseUint(c.GetHeader("x-user-id"), 10, 64); err == nil {
|
|
userID = uint(value)
|
|
}
|
|
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()
|
|
_ = service.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: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")})
|
|
}
|
|
}
|
|
|
|
func operationQueryBody(raw string) []byte {
|
|
query, _ := url.QueryUnescape(raw)
|
|
values := make(map[string]string)
|
|
for _, item := range strings.Split(query, "&") {
|
|
parts := strings.Split(item, "=")
|
|
if len(parts) == 2 {
|
|
values[parts[0]] = parts[1]
|
|
}
|
|
}
|
|
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 {
|
|
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
|
|
if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" {
|
|
current[key] = "***"
|
|
continue
|
|
}
|
|
maskOperationBody(item)
|
|
}
|
|
case []any:
|
|
for _, item := range current {
|
|
maskOperationBody(item)
|
|
}
|
|
}
|
|
}
|
|
|
|
func isDownloadResponse(c *gin.Context) bool {
|
|
header := c.Writer.Header()
|
|
return strings.Contains(header.Get("Pragma"), "public") ||
|
|
strings.Contains(header.Get("Expires"), "0") ||
|
|
strings.Contains(header.Get("Cache-Control"), "must-revalidate, post-check=0, pre-check=0") ||
|
|
strings.Contains(header.Get("Content-Type"), "application/force-download") ||
|
|
strings.Contains(header.Get("Content-Type"), "application/octet-stream") ||
|
|
strings.Contains(header.Get("Content-Type"), "application/vnd.ms-excel") ||
|
|
strings.Contains(header.Get("Content-Type"), "application/download") ||
|
|
strings.Contains(header.Get("Content-Disposition"), "attachment") ||
|
|
strings.Contains(header.Get("Content-Transfer-Encoding"), "binary")
|
|
}
|
|
|
|
// recordsOperation mirrors the routes on which operation records are enabled.
|
|
// Matching by suffix keeps the behavior stable when router-prefix is configured.
|
|
func recordsOperation(method, path string) bool {
|
|
_, ok := operationRoutes[method+" "+routeSuffix(path)]
|
|
return ok
|
|
}
|
|
|
|
func routeSuffix(path string) string {
|
|
for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/"} {
|
|
if index := strings.Index(path, marker); index >= 0 {
|
|
return path[index:]
|
|
}
|
|
}
|
|
return path
|
|
}
|
|
|
|
var operationRoutes = func() map[string]struct{} {
|
|
values := []string{
|
|
"POST /user/admin_register", "POST /user/changePassword", "POST /user/setUserAuthority", "DELETE /user/deleteUser", "PUT /user/setUserInfo", "PUT /user/setSelfInfo", "POST /user/setUserAuthorities", "POST /user/setUserDepartments", "POST /user/setUserPositions", "POST /user/resetPassword", "PUT /user/setSelfSetting",
|
|
"GET /api/getApiGroups", "GET /api/syncApi", "POST /api/ignoreApi", "POST /api/enterSyncApi", "POST /api/createApi", "POST /api/deleteApi", "POST /api/getApiById", "POST /api/updateApi", "DELETE /api/deleteApisByIds", "POST /api/setApiRoles", "POST /casbin/updateCasbin",
|
|
"POST /authority/createAuthority", "POST /authority/deleteAuthority", "PUT /authority/updateAuthority", "POST /authority/copyAuthority", "POST /authority/setDataScope", "POST /authority/setRoleUsers",
|
|
"POST /menu/addBaseMenu", "POST /menu/addMenuAuthority", "POST /menu/deleteBaseMenu", "POST /menu/updateBaseMenu", "POST /menu/setMenuRoles",
|
|
"POST /department/createDepartment", "PUT /department/updateDepartment", "DELETE /department/deleteDepartment", "POST /department/setDepartmentUsers",
|
|
"POST /position/createPosition", "PUT /position/updatePosition", "DELETE /position/deletePosition", "POST /position/setPositionUsers",
|
|
"POST /sysDictionary/createSysDictionary", "DELETE /sysDictionary/deleteSysDictionary", "PUT /sysDictionary/updateSysDictionary", "POST /sysDictionary/importSysDictionary", "GET /sysDictionary/exportSysDictionary",
|
|
"POST /sysDictionaryDetail/createSysDictionaryDetail", "DELETE /sysDictionaryDetail/deleteSysDictionaryDetail", "PUT /sysDictionaryDetail/updateSysDictionaryDetail",
|
|
"POST /sysParams/createSysParams", "DELETE /sysParams/deleteSysParams", "DELETE /sysParams/deleteSysParamsByIds", "PUT /sysParams/updateSysParams",
|
|
"POST /securityConfig/setSecurityConfig", "POST /system/setSystemConfig", "POST /system/reloadSystem",
|
|
"POST /sysApiToken/createApiToken", "POST /sysApiToken/getApiTokenList", "POST /sysApiToken/deleteApiToken",
|
|
"DELETE /sysVersion/deleteSysVersion", "DELETE /sysVersion/deleteSysVersionByIds", "POST /sysVersion/exportVersion", "POST /sysVersion/importVersion",
|
|
"POST /sysExportTemplate/createSysExportTemplate", "DELETE /sysExportTemplate/deleteSysExportTemplate", "DELETE /sysExportTemplate/deleteSysExportTemplateByIds", "PUT /sysExportTemplate/updateSysExportTemplate", "POST /sysExportTemplate/importExcel",
|
|
"DELETE /sysError/deleteSysError", "DELETE /sysError/deleteSysErrorByIds", "PUT /sysError/updateSysError",
|
|
"DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds",
|
|
"POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask",
|
|
"POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail",
|
|
}
|
|
out := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
out[value] = struct{}{}
|
|
}
|
|
return out
|
|
}()
|