fix
This commit is contained in:
@@ -20,10 +20,10 @@ import (
|
||||
kratoshttp "github.com/go-kratos/kratos/v3/transport/http"
|
||||
)
|
||||
|
||||
func NewGinServer(c *conf.Server, runtime *conf.Runtime, system *service.SystemService, access *service.AccessService, authority *handler.Authority, menu *handler.Menu, api *handler.API, permission *handler.Permission, organization *handler.Organization, announcement *handler.Announcement, email *handler.Email, task *handler.Task, media *handler.Media, auditHandler *handler.Audit, export *handler.Export, version *handler.Version, dictionary *handler.Dictionary, parameter *handler.Parameter, apiToken *handler.APIToken, systemConfig *handler.SystemConfig, publicHandler *handler.Public, user *handler.User, navigation *handler.Navigation, session *handler.Session, settings *service.SettingsService, audit *service.AuditService, emails *service.EmailService, logger *slog.Logger) *kratoshttp.Server {
|
||||
func NewGinServer(c *conf.Server, runtime *conf.Runtime, system *service.SystemService, access *service.AccessService, authority *handler.Authority, menu *handler.Menu, api *handler.API, permission *handler.Permission, organization *handler.Organization, announcement *handler.Announcement, email *handler.Email, task *handler.Task, media *handler.Media, auditHandler *handler.Audit, export *handler.Export, version *handler.Version, dictionary *handler.Dictionary, parameter *handler.Parameter, apiToken *handler.APIToken, systemConfig *handler.SystemConfig, publicHandler *handler.Public, user *handler.User, navigation *handler.Navigation, session *handler.Session, settings *service.SettingsService, audit *service.AuditService, logger *slog.Logger) *kratoshttp.Server {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(servermiddleware.RequestMeta(), servermiddleware.EmailErrorAlert(emails, logger), gin.Recovery(), servermiddleware.SecurityRateLimit(system, settings), servermiddleware.OperationAudit(audit))
|
||||
engine.Use(servermiddleware.RequestMeta(), servermiddleware.Recovery(audit, logger), servermiddleware.AccessLog(runtime, logger), servermiddleware.ErrorAudit(audit), servermiddleware.SecurityRateLimit(system, settings), servermiddleware.OperationAudit(runtime, audit))
|
||||
|
||||
prefix := ""
|
||||
config := runtime.Admin()
|
||||
|
||||
@@ -83,7 +83,11 @@ func (h *Media) DeleteMany(c *gin.Context) {
|
||||
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "批量删除成功")
|
||||
}
|
||||
func (h *Media) Find(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Query("id"), 10, 64)
|
||||
id, parseErr := strconv.ParseUint(c.Query("id"), 10, 64)
|
||||
if parseErr != nil {
|
||||
httpx.Fail(c, "文件ID非法")
|
||||
return
|
||||
}
|
||||
item, err := h.service.Media(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
httpx.Fail(c, "查询失败")
|
||||
|
||||
@@ -100,7 +100,13 @@ func (h *Public) Login(c *gin.Context) {
|
||||
if err != nil {
|
||||
_, _ = h.system.CacheIncrement(c.Request.Context(), c.ClientIP(), ipTTL)
|
||||
if errors.Is(err, biz.ErrUserDisabled) {
|
||||
_ = h.audit.RecordLoginRequest(c.Request.Context(), &dto.LoginLogRequest{Username: req.Username, IP: c.ClientIP(), Status: false, ErrorMessage: "用户被禁止登录", Agent: c.Request.UserAgent()})
|
||||
var disabled *service.UserDisabledError
|
||||
errors.As(err, &disabled)
|
||||
userID := uint(0)
|
||||
if disabled != nil {
|
||||
userID = disabled.UserID
|
||||
}
|
||||
_ = h.audit.RecordLoginRequest(c.Request.Context(), &dto.LoginLogRequest{Username: req.Username, IP: c.ClientIP(), Status: false, ErrorMessage: "用户被禁止登录", Agent: c.Request.UserAgent(), UserID: userID})
|
||||
httpx.Fail(c, "用户被禁止登录")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"kra/internal/biz"
|
||||
@@ -33,13 +32,19 @@ func AccessControl(runtime *conf.Runtime, access *service.AccessService, audit *
|
||||
requestID, _ := c.Get("request_id")
|
||||
requestIDText, _ := requestID.(string)
|
||||
_ = audit.RecordDataAccessRequest(c.Request.Context(), &dto.DataAccessRecordRequest{EventType: "blocked_access", Operation: c.Request.Method, UserID: claims.ID, AuthorityID: claims.AuthorityID, RequestID: requestIDText, Method: c.Request.Method, Path: path, Detail: "Casbin policy denied the request"})
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, httpx.Response{Code: httpx.CodeError, Data: nil, Msg: "权限不足"})
|
||||
httpx.Write(c, httpx.CodeError, gin.H{}, "权限不足")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
requestContext, err := access.ContextWithDataScope(c.Request.Context(), claims.AuthorityID, claims.ID)
|
||||
if err != nil {
|
||||
httpx.Fail(c, "数据权限加载失败")
|
||||
return
|
||||
requestContext = c.Request.Context()
|
||||
}
|
||||
if scope, ok := biz.DataScopeFromContext(requestContext); ok {
|
||||
requestID, _ := c.Get("request_id")
|
||||
scope.RequestID, _ = requestID.(string)
|
||||
scope.Method, scope.Path = c.Request.Method, path
|
||||
requestContext = biz.NewDataScopeContext(requestContext, scope)
|
||||
}
|
||||
requestContext = biz.NewActorContext(requestContext, biz.Actor{UserID: claims.ID, AuthorityID: claims.AuthorityID})
|
||||
c.Request = c.Request.WithContext(requestContext)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AccessLog is the single global request/response capture point, matching
|
||||
// the reference middleware ordering and making every HTTP request observable.
|
||||
func AccessLog(runtime *conf.Runtime, logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var requestBody []byte
|
||||
multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data")
|
||||
if c.Request.Body != nil && !multipart {
|
||||
requestBody, _ = io.ReadAll(c.Request.Body)
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
|
||||
}
|
||||
maxBytes := 1 << 20
|
||||
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes}
|
||||
c.Writer = writer
|
||||
started := time.Now()
|
||||
c.Next()
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
requestText, responseText := "", ""
|
||||
config := runtime.Admin()
|
||||
logLimit := 32768
|
||||
if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
|
||||
logLimit = int(config.Zap.AccessLogMaxBytes)
|
||||
}
|
||||
if config == nil || config.Zap == nil || config.Zap.AccessReqBody {
|
||||
if multipart {
|
||||
requestText = "[文件]"
|
||||
} else {
|
||||
requestText = redactJSONLimit(requestBody, logLimit)
|
||||
}
|
||||
}
|
||||
if config == nil || config.Zap == nil || config.Zap.AccessRespData {
|
||||
responseText = redactJSONLimit(writer.body.Bytes(), logLimit)
|
||||
}
|
||||
userID, authorityID := uint(0), uint(0)
|
||||
if claims := Claims(c); claims != nil {
|
||||
userID, authorityID = claims.ID, claims.AuthorityID
|
||||
}
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
attributes := []any{
|
||||
"ip", c.ClientIP(), "method", c.Request.Method, "path", c.Request.URL.Path, "http_route", route,
|
||||
"status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
||||
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
|
||||
"bytes_in", len(requestBody), "bytes_out", c.Writer.Size(), "user_id", userID, "authority_id", authorityID,
|
||||
"ua", c.Request.UserAgent(), "query", c.Request.URL.RawQuery, "request", requestText, "response", responseText}
|
||||
if config != nil && config.Zap != nil && config.Zap.AccessReqHeaders {
|
||||
attributes = append(attributes, "headers", redactHeaders(c.Request.Header))
|
||||
}
|
||||
logger.InfoContext(c.Request.Context(), "http access", attributes...)
|
||||
}
|
||||
}
|
||||
|
||||
func redactHeaders(headers map[string][]string) map[string][]string {
|
||||
out := make(map[string][]string, len(headers))
|
||||
for key, values := range headers {
|
||||
lower := strings.ToLower(key)
|
||||
if strings.Contains(lower, "token") || lower == "authorization" || lower == "cookie" {
|
||||
out[key] = []string{"******"}
|
||||
} else {
|
||||
out[key] = values
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringValueFromContext(c *gin.Context, key string) string {
|
||||
value, _ := c.Get(key)
|
||||
return stringValue(value)
|
||||
}
|
||||
@@ -6,29 +6,34 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func OperationAudit(service *service.AuditService) gin.HandlerFunc {
|
||||
func OperationAudit(runtime *conf.Runtime, service *service.AuditService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if isBootstrapPath(c.Request.URL.Path) || c.Request.Method == "GET" || c.Request.Method == "HEAD" || c.Request.Method == "OPTIONS" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if strings.Contains(path, "sysOperationRecord") || strings.Contains(path, "sysLoginLog") || strings.Contains(path, "dataAccessLog") {
|
||||
if !recordsOperation(c.Request.Method, path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
var requestBody []byte
|
||||
if c.Request.Body != nil {
|
||||
requestBody, _ = io.ReadAll(io.LimitReader(c.Request.Body, 32769))
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
|
||||
maxBytes := 32768
|
||||
if config := runtime.Admin(); config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
|
||||
maxBytes = int(config.Zap.AccessLogMaxBytes)
|
||||
}
|
||||
writer := &captureWriter{ResponseWriter: c.Writer}
|
||||
if c.Request.Body != nil {
|
||||
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))
|
||||
}
|
||||
}
|
||||
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes}
|
||||
c.Writer = writer
|
||||
started := time.Now()
|
||||
c.Next()
|
||||
@@ -41,6 +46,49 @@ func OperationAudit(service *service.AuditService) gin.HandlerFunc {
|
||||
if status >= 400 {
|
||||
errorMessage = writer.body.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: redactJSON(requestBody), Response: redactJSON(writer.body.Bytes()), UserID: userID, RequestID: stringValue(requestID), TraceID: c.GetHeader("traceparent"), DeviceID: c.GetHeader("X-Device-Id")})
|
||||
_ = 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: redactJSONLimit(requestBody, maxBytes), Response: writer.body.String(), UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 /sysOperationRecord/deleteSysOperationRecord", "DELETE /sysOperationRecord/deleteSysOperationRecordByIds", "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
|
||||
}()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -12,16 +13,28 @@ import (
|
||||
"kra/pkg/adminauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const claimsKey = "admin_claims"
|
||||
|
||||
var refreshTokens singleflight.Group
|
||||
|
||||
type refreshedToken struct {
|
||||
token string
|
||||
expiresAt int64
|
||||
}
|
||||
|
||||
func Auth(runtime *conf.Runtime, settings *service.SettingsService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("x-token")
|
||||
if token == "" {
|
||||
token, _ = c.Cookie("x-token")
|
||||
}
|
||||
if token == "" {
|
||||
httpx.NoAuth(c, "未登录或非法访问,请登录")
|
||||
return
|
||||
}
|
||||
secret := ""
|
||||
config := runtime.Admin()
|
||||
if config != nil && config.Jwt != nil {
|
||||
@@ -29,15 +42,24 @@ func Auth(runtime *conf.Runtime, settings *service.SettingsService) gin.HandlerF
|
||||
}
|
||||
claims, err := adminauth.Parse(token, secret)
|
||||
if err != nil {
|
||||
httpx.NoAuth(c, "未登录或非法访问")
|
||||
message := "无法处理此token"
|
||||
switch {
|
||||
case errors.Is(err, adminauth.ErrTokenExpired):
|
||||
message = "登录已过期,请重新登录"
|
||||
case errors.Is(err, adminauth.ErrTokenMalformed):
|
||||
message = "这不是一个token"
|
||||
case errors.Is(err, adminauth.ErrTokenSignatureInvalid):
|
||||
message = "无效签名"
|
||||
case errors.Is(err, adminauth.ErrTokenNotValidYet):
|
||||
message = "token尚未激活"
|
||||
}
|
||||
httpx.SetTokenCookie(c, "", -1)
|
||||
httpx.NoAuth(c, message)
|
||||
return
|
||||
}
|
||||
if disabled, checkErr := settings.IsTokenDisabled(c.Request.Context(), token); checkErr != nil || disabled {
|
||||
httpx.NoAuth(c, "登录状态已失效")
|
||||
return
|
||||
}
|
||||
if active, checkErr := settings.ActiveTokenMatches(c.Request.Context(), claims.Username, token); checkErr != nil || !active {
|
||||
httpx.NoAuth(c, "登录状态已失效")
|
||||
httpx.SetTokenCookie(c, "", -1)
|
||||
httpx.NoAuth(c, "您的帐户异地登陆或令牌失效")
|
||||
return
|
||||
}
|
||||
if claims.ExpiresAt != nil && claims.BufferTime > 0 && time.Until(claims.ExpiresAt.Time) < time.Duration(claims.BufferTime)*time.Second {
|
||||
@@ -55,11 +77,21 @@ func Auth(runtime *conf.Runtime, settings *service.SettingsService) gin.HandlerF
|
||||
issuer = config.Jwt.Issuer
|
||||
}
|
||||
}
|
||||
newToken, newClaims, refreshErr := adminauth.Generate(secret, issuer, expires, buffer, claims.ID, claims.AuthorityID, claims.UUID, claims.Username, claims.NickName, claims.MustChangePwd)
|
||||
if refreshErr == nil && settings.RotateActiveToken(c.Request.Context(), claims.Username, token, newToken, expires) == nil {
|
||||
c.Header("new-token", newToken)
|
||||
c.Header("new-expires-at", strconv.FormatInt(newClaims.ExpiresAt.Unix(), 10))
|
||||
httpx.SetTokenCookie(c, newToken, int(expires.Seconds()))
|
||||
value, refreshErr, _ := refreshTokens.Do(token, func() (any, error) {
|
||||
newToken, newClaims, generateErr := adminauth.Generate(secret, issuer, expires, buffer, claims.ID, claims.AuthorityID, claims.UUID, claims.Username, claims.NickName, claims.MustChangePwd)
|
||||
if generateErr != nil {
|
||||
return nil, generateErr
|
||||
}
|
||||
if rotateErr := settings.RotateActiveToken(c.Request.Context(), claims.Username, token, newToken, expires); rotateErr != nil {
|
||||
return nil, rotateErr
|
||||
}
|
||||
return refreshedToken{token: newToken, expiresAt: newClaims.ExpiresAt.Unix()}, nil
|
||||
})
|
||||
if refreshErr == nil {
|
||||
refreshed := value.(refreshedToken)
|
||||
c.Header("new-token", refreshed.token)
|
||||
c.Header("new-expires-at", strconv.FormatInt(refreshed.expiresAt, 10))
|
||||
httpx.SetTokenCookie(c, refreshed.token, int(expires.Seconds()))
|
||||
}
|
||||
}
|
||||
c.Set(claimsKey, claims)
|
||||
@@ -85,6 +117,6 @@ func MustChangePassword() gin.HandlerFunc {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusConflict, httpx.Response{Code: httpx.CodePasswordChangeRequired, Data: gin.H{"needChangePassword": true}, Msg: "请先修改初始密码"})
|
||||
c.AbortWithStatusJSON(http.StatusConflict, httpx.Response{Code: httpx.CodePasswordChangeRequired, Data: gin.H{"needChangePassword": true}, Msg: "密码已过期,请先修改密码"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,17 @@ import (
|
||||
|
||||
type captureWriter struct {
|
||||
gin.ResponseWriter
|
||||
body bytes.Buffer
|
||||
body bytes.Buffer
|
||||
maxBytes int
|
||||
}
|
||||
|
||||
func (w *captureWriter) Write(data []byte) (int, error) {
|
||||
if w.body.Len() < 32768 {
|
||||
remaining := 32768 - w.body.Len()
|
||||
limit := w.maxBytes
|
||||
if limit <= 0 {
|
||||
limit = 32768
|
||||
}
|
||||
if w.body.Len() < limit {
|
||||
remaining := limit - w.body.Len()
|
||||
if len(data) > remaining {
|
||||
_, _ = w.body.Write(data[:remaining])
|
||||
} else {
|
||||
@@ -26,11 +31,18 @@ func (w *captureWriter) Write(data []byte) (int, error) {
|
||||
}
|
||||
|
||||
func redactJSON(raw []byte) string {
|
||||
return redactJSONLimit(raw, 32768)
|
||||
}
|
||||
|
||||
func redactJSONLimit(raw []byte, limit int) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(raw) > 32768 {
|
||||
raw = raw[:32768]
|
||||
if limit <= 0 {
|
||||
limit = 32768
|
||||
}
|
||||
if len(raw) > limit {
|
||||
raw = raw[:limit]
|
||||
}
|
||||
var value any
|
||||
if json.Unmarshal(raw, &value) != nil {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/server/httpx"
|
||||
"kra/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func EmailErrorAlert(service *service.EmailService, logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if strings.Contains(c.Request.URL.Path, "/email/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
writer := &captureWriter{ResponseWriter: c.Writer}
|
||||
c.Writer = writer
|
||||
started := time.Now()
|
||||
c.Next()
|
||||
failed := c.Writer.Status() >= 400
|
||||
if !failed {
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
failed = json.Unmarshal(writer.body.Bytes(), &result) == nil && result.Code != httpx.CodeSuccess
|
||||
}
|
||||
if !failed {
|
||||
return
|
||||
}
|
||||
username := "Unknown"
|
||||
if claims := Claims(c); claims != nil && claims.Username != "" {
|
||||
username = claims.Username
|
||||
}
|
||||
subject := fmt.Sprintf("%s %s 调用 %s 报错", username, c.ClientIP(), c.Request.URL.Path)
|
||||
body := bytes.NewBuffer(nil)
|
||||
fmt.Fprintf(body, "请求方式:%s<br>请求路径:%s<br>状态码:%d<br>耗时:%s<br>错误响应:<pre>%s</pre>", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started), redactJSON(writer.body.Bytes()))
|
||||
if err := service.Alert(c.Request.Context(), subject, body.String()); err != nil && logger != nil {
|
||||
logger.Error("send HTTP error email", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"kra/internal/server/httpx"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ErrorAudit supplies the database sink that the Error-level logging core
|
||||
// provides. Expected authentication, permission and input failures are not
|
||||
// system errors and therefore are not inserted into sys_error.
|
||||
func ErrorAudit(audit *service.AuditService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: 1 << 20}
|
||||
c.Writer = writer
|
||||
c.Next()
|
||||
if strings.Contains(c.Request.URL.Path, "/sysError/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 {
|
||||
return
|
||||
}
|
||||
var response httpx.Response
|
||||
if json.Unmarshal(writer.body.Bytes(), &response) != nil || response.Code == httpx.CodeSuccess || expectedClientFailure(response.Msg) {
|
||||
return
|
||||
}
|
||||
requestID, _ := c.Get("request_id")
|
||||
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: response.Msg, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), Status: "未解决"})
|
||||
}
|
||||
}
|
||||
|
||||
func expectedClientFailure(message string) bool {
|
||||
for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} {
|
||||
if strings.Contains(message, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/server/httpx"
|
||||
@@ -11,7 +12,8 @@ import (
|
||||
|
||||
func SecurityRateLimit(system *service.SystemService, settings *service.SettingsService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if isBootstrapPath(c.Request.URL.Path) {
|
||||
path := strings.TrimSuffix(c.Request.URL.Path, "/")
|
||||
if !strings.HasSuffix(path, "/base/login") && !strings.HasSuffix(path, "/base/captcha") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Recovery(audit *service.AuditService, logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
panicValue := recover()
|
||||
if panicValue == nil {
|
||||
return
|
||||
}
|
||||
brokenPipe := false
|
||||
if networkError, ok := panicValue.(*net.OpError); ok {
|
||||
if syscallError, ok := networkError.Err.(*os.SyscallError); ok {
|
||||
message := strings.ToLower(syscallError.Error())
|
||||
brokenPipe = strings.Contains(message, "broken pipe") || strings.Contains(message, "connection reset by peer")
|
||||
}
|
||||
}
|
||||
request, _ := httputil.DumpRequest(c.Request, false)
|
||||
info := fmt.Sprintf("error=%v request=%s stack=%s", panicValue, request, debug.Stack())
|
||||
if logger != nil {
|
||||
logger.ErrorContext(c.Request.Context(), "recovery from panic", "error", panicValue, "request", string(request), "stack", string(debug.Stack()))
|
||||
}
|
||||
requestID, _ := c.Get("request_id")
|
||||
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: info, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), Status: "未解决"})
|
||||
if brokenPipe {
|
||||
if err, ok := panicValue.(error); ok {
|
||||
_ = c.Error(err)
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,48 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var traceParentPattern = regexp.MustCompile(`^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$`)
|
||||
|
||||
func randomHex(bytes int) string {
|
||||
value := make([]byte, bytes)
|
||||
_, _ = rand.Read(value)
|
||||
return hex.EncodeToString(value)
|
||||
}
|
||||
|
||||
func RequestMeta() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := c.GetHeader("X-Request-Id")
|
||||
if requestID == "" {
|
||||
if requestID == "" || len(requestID) > 128 || strings.ContainsAny(requestID, "\r\n") {
|
||||
requestID = uuid.NewString()
|
||||
}
|
||||
traceID, parentSpanID := "", ""
|
||||
if match := traceParentPattern.FindStringSubmatch(strings.ToLower(c.GetHeader("traceparent"))); len(match) == 3 {
|
||||
traceID, parentSpanID = match[1], match[2]
|
||||
} else if candidate := c.GetHeader("X-Trace-Id"); len(candidate) <= 128 && !strings.ContainsAny(candidate, "\r\n") {
|
||||
traceID = candidate
|
||||
}
|
||||
if traceID == "" {
|
||||
traceID = randomHex(16)
|
||||
}
|
||||
spanID := randomHex(8)
|
||||
c.Header("X-Request-Id", requestID)
|
||||
c.Header("X-Trace-Id", traceID)
|
||||
if len(traceID) == 32 {
|
||||
c.Header("traceparent", "00-"+traceID+"-"+spanID+"-01")
|
||||
}
|
||||
c.Set("request_id", requestID)
|
||||
c.Set("trace_id", traceID)
|
||||
c.Set("span_id", spanID)
|
||||
c.Set("parent_span_id", parentSpanID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user