优化结构
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
"kra/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type accessController interface {
|
||||
Authorize(context.Context, uint, string, string) (bool, error)
|
||||
ContextWithDataScope(context.Context, uint, uint) (context.Context, error)
|
||||
}
|
||||
|
||||
func AccessControl(runtime *conf.Runtime, access accessController) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "未登录或非法访问")
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
policyPath := path
|
||||
if config := runtime.Admin(); config != nil {
|
||||
policyPath = service.NormalizeRoutePath(policyPath, config.RouterPrefix)
|
||||
}
|
||||
allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method)
|
||||
if err != nil || !allowed {
|
||||
Write(c, CodeError, gin.H{}, "权限不足")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
requestContext, err := access.ContextWithDataScope(c.Request.Context(), claims.AuthorityID, claims.ID)
|
||||
if err != nil {
|
||||
Write(c, CodeError, gin.H{}, "数据权限解析失败")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
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)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const defaultRequestBodyLimit int64 = 8 << 20
|
||||
|
||||
// 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, version string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
started := time.Now()
|
||||
var requestBody []byte
|
||||
multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data")
|
||||
mediaUpload := multipart && isMediaUploadRoute(c.FullPath())
|
||||
config := runtime.Admin()
|
||||
bodyLimit := defaultRequestBodyLimit
|
||||
if mediaUpload {
|
||||
bodyLimit = biz.DefaultMaxMediaFileSize + (1 << 20)
|
||||
if config != nil && config.Media != nil && config.Media.MaxFileSize > 0 {
|
||||
bodyLimit = config.Media.MaxFileSize + (1 << 20)
|
||||
}
|
||||
}
|
||||
bytesIn := c.Request.ContentLength
|
||||
maxBytes := 1 << 20
|
||||
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes}
|
||||
c.Writer = writer
|
||||
c.Header("X-Kra-Version", version)
|
||||
requestReadFailed := c.Request.ContentLength > bodyLimit
|
||||
if requestReadFailed {
|
||||
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"code": CodeError, "msg": "请求体超过大小上限"})
|
||||
} else if c.Request.Body != nil && !mediaUpload {
|
||||
limited := http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit)
|
||||
var err error
|
||||
requestBody, err = io.ReadAll(limited)
|
||||
if err != nil {
|
||||
requestReadFailed = true
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"code": CodeError, "msg": "请求体超过大小上限"})
|
||||
} else {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": CodeError, "msg": "请求体读取失败"})
|
||||
}
|
||||
} else {
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
|
||||
bytesIn = int64(len(requestBody))
|
||||
}
|
||||
} else if c.Request.Body != nil {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit)
|
||||
}
|
||||
if bytesIn < 0 {
|
||||
bytesIn = 0
|
||||
}
|
||||
logLimit := 1024
|
||||
if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
|
||||
logLimit = int(config.Zap.AccessLogMaxBytes)
|
||||
}
|
||||
requestText := ""
|
||||
if multipart {
|
||||
requestText = "[文件]"
|
||||
} else {
|
||||
requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit)
|
||||
}
|
||||
c.Set(ctxReqBodyKey, requestText)
|
||||
c.Set(ctxRespBufferKey, &writer.body)
|
||||
if !requestReadFailed {
|
||||
c.Next()
|
||||
}
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), 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"
|
||||
}
|
||||
bytesOut := int64(c.Writer.Size())
|
||||
if bytesOut < 0 {
|
||||
bytesOut = 0
|
||||
}
|
||||
privateErrors := strings.TrimRight(c.Errors.ByType(gin.ErrorTypePrivate).String(), "\n")
|
||||
attributes := []any{
|
||||
"mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route,
|
||||
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
||||
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
|
||||
"bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID,
|
||||
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(), "req_query", c.Request.URL.RawQuery}
|
||||
if config != nil && config.Zap != nil && config.Zap.AccessReqHeaders {
|
||||
attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header))
|
||||
}
|
||||
if config != nil && config.Zap != nil && config.Zap.AccessReqBody {
|
||||
attributes = append(attributes, "req_body", requestText)
|
||||
}
|
||||
if config != nil && config.Zap != nil && config.Zap.AccessRespData {
|
||||
attributes = append(attributes, "resp_data", responseText)
|
||||
}
|
||||
if privateErrors != "" {
|
||||
attributes = append(attributes, "error_msg", privateErrors)
|
||||
}
|
||||
logger.InfoContext(c.Request.Context(), "请求完成", attributes...)
|
||||
}
|
||||
}
|
||||
|
||||
func isMediaUploadRoute(route string) bool {
|
||||
return strings.HasSuffix(route, "/fileUploadAndDownload/upload") ||
|
||||
strings.HasSuffix(route, "/mediaUpload/chunk")
|
||||
}
|
||||
|
||||
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 lower == "authorization" || lower == "cookie" || lower == "set-cookie" || lower == "x-token" {
|
||||
out[key] = "***"
|
||||
} else {
|
||||
out[key] = strings.Join(values, ",")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringValueFromContext(c *gin.Context, key string) string {
|
||||
value, _ := c.Get(key)
|
||||
return stringValue(value)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAccessLogRejectsOversizedRequestBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
called := false
|
||||
engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test"))
|
||||
engine.POST("/payload", func(c *gin.Context) { called = true })
|
||||
request := httptest.NewRequest(http.MethodPost, "/payload", strings.NewReader(strings.Repeat("a", int(defaultRequestBodyLimit+1))))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusRequestEntityTooLarge || called {
|
||||
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLogRejectsOversizedMultipartOnOrdinaryRoute(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
called := false
|
||||
engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test"))
|
||||
engine.POST("/login", func(c *gin.Context) { called = true })
|
||||
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(strings.Repeat("a", int(defaultRequestBodyLimit+1))))
|
||||
request.ContentLength = -1
|
||||
request.Header.Set("Content-Type", "multipart/form-data; boundary=test")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusRequestEntityTooLarge || called {
|
||||
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLogAllowsMediaLimitOnlyOnUploadRoute(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
called := false
|
||||
engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test"))
|
||||
engine.POST("/api/fileUploadAndDownload/upload", func(c *gin.Context) {
|
||||
called = true
|
||||
_, _ = io.Copy(io.Discard, c.Request.Body)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/fileUploadAndDownload/upload", strings.NewReader(strings.Repeat("a", int(defaultRequestBodyLimit+1))))
|
||||
request.Header.Set("Content-Type", "multipart/form-data; boundary=test")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !called {
|
||||
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type accessControllerStub struct {
|
||||
scopeErr error
|
||||
}
|
||||
|
||||
func (*accessControllerStub) Authorize(context.Context, uint, string, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *accessControllerStub) ContextWithDataScope(ctx context.Context, authorityID, userID uint) (context.Context, error) {
|
||||
if s.scopeErr != nil {
|
||||
return ctx, s.scopeErr
|
||||
}
|
||||
return biz.NewDataScopeContext(ctx, biz.DataScope{UserID: userID, AuthorityID: authorityID, Scope: 1, All: true}), nil
|
||||
}
|
||||
|
||||
func TestAccessControlFailsClosedWhenDataScopeResolutionFails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Set(claimsKey, &biz.AuthClaims{ID: 7, AuthorityID: 888})
|
||||
c.Next()
|
||||
})
|
||||
engine.Use(AccessControl(conf.NewRuntime(nil, &conf.AdminBackend{}), &accessControllerStub{scopeErr: errors.New("database unavailable")}))
|
||||
called := false
|
||||
engine.GET("/protected", func(c *gin.Context) { called = true })
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/protected", nil))
|
||||
if called {
|
||||
t.Fatal("protected handler ran after data-scope resolution failed")
|
||||
}
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "数据权限解析失败") {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed"
|
||||
|
||||
func OperationAudit(runtime *conf.Runtime, recorder *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 {
|
||||
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
|
||||
} 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()
|
||||
if err := recorder.RecordOperationRequest(c.Request.Context(), &service.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")}); 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 {
|
||||
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
|
||||
}()
|
||||
@@ -0,0 +1,82 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const claimsKey = "admin_claims"
|
||||
|
||||
var refreshTokens singleflight.Group
|
||||
|
||||
func Auth(auth *service.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("x-token")
|
||||
if token == "" {
|
||||
token, _ = c.Cookie("x-token")
|
||||
}
|
||||
if token == "" {
|
||||
NoAuth(c, "未登录或非法访问,请登录")
|
||||
return
|
||||
}
|
||||
value, err, _ := refreshTokens.Do(token, func() (any, error) {
|
||||
return auth.AuthenticateToken(c.Request.Context(), token)
|
||||
})
|
||||
if err != nil {
|
||||
message := "无法处理此token"
|
||||
switch {
|
||||
case errors.Is(err, biz.ErrTokenExpired):
|
||||
message = "登录已过期,请重新登录"
|
||||
case errors.Is(err, biz.ErrTokenMalformed):
|
||||
message = "这不是一个token"
|
||||
case errors.Is(err, biz.ErrTokenSignatureInvalid):
|
||||
message = "无效签名"
|
||||
case errors.Is(err, biz.ErrTokenNotValidYet):
|
||||
message = "token尚未激活"
|
||||
case errors.Is(err, biz.ErrTokenDisabled):
|
||||
message = "您的帐户异地登陆或令牌失效"
|
||||
}
|
||||
SetTokenCookie(c, "", -1)
|
||||
NoAuth(c, message)
|
||||
return
|
||||
}
|
||||
authentication := value.(*biz.TokenAuthentication)
|
||||
if authentication.Refreshed != nil {
|
||||
c.Header("new-token", authentication.Refreshed.Value)
|
||||
c.Header("new-expires-at", strconv.FormatInt(authentication.Refreshed.ExpiresAt.Unix(), 10))
|
||||
SetTokenCookie(c, authentication.Refreshed.Value, int(authentication.Refreshed.TTL.Seconds()))
|
||||
}
|
||||
c.Set(claimsKey, authentication.Claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func Claims(c *gin.Context) *biz.AuthClaims {
|
||||
value, _ := c.Get(claimsKey)
|
||||
claims, _ := value.(*biz.AuthClaims)
|
||||
return claims
|
||||
}
|
||||
|
||||
func MustChangePassword() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil || !claims.MustChangePwd {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
path := strings.TrimSuffix(c.Request.URL.Path, "/")
|
||||
if strings.HasSuffix(path, "/user/changePassword") || strings.HasSuffix(path, "/user/getUserInfo") || strings.HasSuffix(path, "/jwt/jsonInBlacklist") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusConflict, Response{Code: CodePasswordChangeRequired, Data: gin.H{"needChangePassword": true}, Msg: "密码已过期,请先修改密码"})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// The global access logger is the single request/response capture point.
|
||||
// OperationAudit consumes these values after the handler returns, avoiding a
|
||||
// second body read/writer wrapper.
|
||||
const (
|
||||
ctxReqBodyKey = "kra_req_body"
|
||||
ctxRespBufferKey = "kra_resp_buffer"
|
||||
)
|
||||
|
||||
type captureWriter struct {
|
||||
gin.ResponseWriter
|
||||
body bytes.Buffer
|
||||
maxBytes int
|
||||
}
|
||||
|
||||
func (w *captureWriter) Write(data []byte) (int, error) {
|
||||
limit := w.maxBytes
|
||||
if limit <= 0 {
|
||||
limit = 1024
|
||||
}
|
||||
if w.body.Len() < limit {
|
||||
remaining := limit - w.body.Len()
|
||||
if len(data) > remaining {
|
||||
_, _ = w.body.Write(data[:remaining])
|
||||
} else {
|
||||
_, _ = w.body.Write(data)
|
||||
}
|
||||
}
|
||||
return w.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
func redactJSON(raw []byte, contentType string, limit int) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 1024
|
||||
}
|
||||
text := string(raw)
|
||||
if !strings.Contains(strings.ToLower(contentType), "json") {
|
||||
if len(text) > limit {
|
||||
return "[超出记录长度]"
|
||||
}
|
||||
return text
|
||||
}
|
||||
var value any
|
||||
if json.Unmarshal(raw, &value) != nil {
|
||||
if len(text) > limit {
|
||||
return "[超出记录长度]"
|
||||
}
|
||||
return text
|
||||
}
|
||||
maskOperationBody(value)
|
||||
encoded, _ := json.Marshal(value)
|
||||
if len(encoded) > limit {
|
||||
return "[超出记录长度]"
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func stringValue(value any) string { text, _ := value.(string); return text }
|
||||
|
||||
func isBootstrapPath(path string) bool {
|
||||
return strings.HasSuffix(path, "/health") || strings.Contains(path, "/init/")
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCORSHeaders = "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id"
|
||||
defaultCORSMethods = "POST, GET, OPTIONS,DELETE,PUT"
|
||||
defaultCORSExpose = "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type, New-Token, New-Expires-At"
|
||||
)
|
||||
|
||||
// CORS applies the current administration CORS rules on every request so a
|
||||
// configuration reload takes effect without rebuilding the Gin engine.
|
||||
func CORS(runtime *conf.Runtime) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
config := runtime.Admin()
|
||||
if config == nil || config.Cors == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
mode := strings.TrimSpace(config.Cors.Mode)
|
||||
origin := c.GetHeader("Origin")
|
||||
corsHandled := false
|
||||
if mode == "allow-all" {
|
||||
setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true)
|
||||
corsHandled = true
|
||||
} else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil {
|
||||
setCORSHeaders(c, rule.AllowOrigin, rule.AllowHeaders, rule.AllowMethods, rule.ExposeHeaders, rule.AllowCredentials)
|
||||
corsHandled = true
|
||||
} else if mode == "strict-whitelist" && !(c.Request.Method == http.MethodGet && c.Request.URL.Path == "/health") {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if corsHandled && c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func matchingCORSRule(rules []*conf.AdminBackend_CORSRule, origin string) *conf.AdminBackend_CORSRule {
|
||||
for _, rule := range rules {
|
||||
if rule != nil && origin == rule.AllowOrigin {
|
||||
return rule
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setCORSHeaders(c *gin.Context, origin, headers, methods, expose string, credentials bool) {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Headers", headers)
|
||||
c.Header("Access-Control-Allow-Methods", methods)
|
||||
c.Header("Access-Control-Expose-Headers", expose)
|
||||
if credentials {
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func runCORSTest(t *testing.T, admin *conf.AdminBackend, method, origin string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
engine := gin.New()
|
||||
engine.Use(CORS(conf.NewRuntime(nil, admin)))
|
||||
engine.Any("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
request := httptest.NewRequest(method, "/test", nil)
|
||||
if origin != "" {
|
||||
request.Header.Set("Origin", origin)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestCORSDoesNotConsumeUnmatchedWhitelistPreflight(t *testing.T) {
|
||||
response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist"}}, http.MethodOptions, "https://unknown.example")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("unmatched whitelist preflight status = %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSConsumesMatchedWhitelistPreflight(t *testing.T) {
|
||||
response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist", Whitelist: []*conf.AdminBackend_CORSRule{{AllowOrigin: "https://admin.example"}}}}, http.MethodOptions, "https://admin.example")
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("matched whitelist preflight status = %d, want %d", response.Code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ErrorAudit emits a structured Error-level log for unexpected HTTP failures.
|
||||
// The logging core is the single persistence path for sys_error, matching the
|
||||
// reference behavior and avoiding duplicate rows for HTTP failures.
|
||||
func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
privateErrors := strings.TrimSpace(c.Errors.ByType(gin.ErrorTypePrivate).String())
|
||||
auditPersistFailed, _ := c.Get(ctxOperationAuditPersistFailedKey)
|
||||
// sysError writes must never audit themselves. Log-viewer failures are
|
||||
// already recorded by the handler with the underlying filesystem error;
|
||||
// emitting again from the response envelope would duplicate both the
|
||||
// classified error file and the sys_error row.
|
||||
if auditPersistFailed != true && (strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500) {
|
||||
return
|
||||
}
|
||||
var response Response
|
||||
var body []byte
|
||||
if value, ok := c.Get(ctxRespBufferKey); ok {
|
||||
if buffer, valid := value.(*bytes.Buffer); valid {
|
||||
body = buffer.Bytes()
|
||||
}
|
||||
}
|
||||
if json.Unmarshal(body, &response) != nil && privateErrors == "" {
|
||||
return
|
||||
}
|
||||
if response.Code == CodeSuccess && privateErrors == "" {
|
||||
return
|
||||
}
|
||||
if privateErrors == "" && expectedClientFailure(response.Msg) {
|
||||
return
|
||||
}
|
||||
errorMessage := response.Msg
|
||||
if privateErrors != "" {
|
||||
errorMessage = privateErrors
|
||||
}
|
||||
requestID, _ := c.Get("request_id")
|
||||
if logger != nil {
|
||||
logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", errorMessage, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func failureLogModule(path string) string {
|
||||
for _, marker := range []string{"/fileUploadAndDownload/", "/mediaUpload/", "/attachmentCategory/"} {
|
||||
if strings.Contains(path, marker) {
|
||||
return "upload"
|
||||
}
|
||||
}
|
||||
if strings.Contains(path, "/timedTask/") {
|
||||
return "timedTask"
|
||||
}
|
||||
if strings.Contains(path, "/logViewer/") {
|
||||
return "log-viewer"
|
||||
}
|
||||
return "biz"
|
||||
}
|
||||
|
||||
func expectedClientFailure(message string) bool {
|
||||
for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} {
|
||||
if strings.Contains(message, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func runErrorAudit(t *testing.T, path, response string, logger *slog.Logger) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(ErrorAudit(logger))
|
||||
engine.GET(path, func(c *gin.Context) {
|
||||
c.Set(ctxRespBufferKey, bytes.NewBufferString(response))
|
||||
c.Set("request_id", "request-1")
|
||||
c.Set("trace_id", "trace-1")
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(responseRecorder, request)
|
||||
}
|
||||
|
||||
func TestErrorAuditEmitsUnexpectedBusinessFailure(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
|
||||
runErrorAudit(t, "/test", `{"code":7,"data":{},"msg":"数据库写入失败"}`, logger)
|
||||
|
||||
if text := output.String(); !strings.Contains(text, `"mod":"biz"`) || !strings.Contains(text, `"error":"数据库写入失败"`) {
|
||||
t.Fatalf("unexpected error log: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditUsesFeatureModule(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
|
||||
runErrorAudit(t, "/timedTask/triggerTimedTask", `{"code":7,"data":{},"msg":"任务执行失败"}`, logger)
|
||||
if text := output.String(); !strings.Contains(text, `"mod":"timedTask"`) {
|
||||
t.Fatalf("unexpected feature log module: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditSkipsExpectedClientFailure(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
|
||||
runErrorAudit(t, "/test", `{"code":7,"data":{},"msg":"参数错误"}`, logger)
|
||||
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("expected no error log, got %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditAllowsNilLogger(t *testing.T) {
|
||||
runErrorAudit(t, "/test", `{"code":7,"data":{},"msg":"数据库写入失败"}`, nil)
|
||||
}
|
||||
|
||||
func TestErrorAuditSkipsLogViewerFailureAlreadyLoggedByHandler(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
|
||||
runErrorAudit(t, "/logViewer/content", `{"code":7,"data":{},"msg":"日志文件不可读取"}`, logger)
|
||||
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("log viewer failure must not be emitted twice, got %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditEmitsPrivateMiddlewareErrorOnSuccessResponse(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(ErrorAudit(logger))
|
||||
engine.GET("/test", func(c *gin.Context) {
|
||||
c.Error(errors.New("operation audit persist: database unavailable"))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{}, "msg": "成功"})
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/test", nil))
|
||||
if !strings.Contains(output.String(), "operation audit persist: database unavailable") {
|
||||
t.Fatalf("private middleware error was not logged: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditEmitsOperationAuditFailureForSysErrorRoute(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(ErrorAudit(logger))
|
||||
engine.PUT("/sysError/updateSysError", func(c *gin.Context) {
|
||||
c.Set(ctxOperationAuditPersistFailedKey, true)
|
||||
c.Error(errors.New("operation audit persist: database unavailable"))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{}, "msg": "成功"})
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/sysError/updateSysError", nil))
|
||||
if !strings.Contains(output.String(), "operation audit persist: database unavailable") {
|
||||
t.Fatalf("sysError operation audit failure was not logged: %s", output.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/server/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
CodeSuccess = httpx.CodeSuccess
|
||||
CodeError = httpx.CodeError
|
||||
CodePasswordChangeRequired = httpx.CodePasswordChangeRequired
|
||||
)
|
||||
|
||||
type Response = httpx.Response
|
||||
|
||||
func Write(c *gin.Context, code int, data any, message string) { httpx.Write(c, code, data, message) }
|
||||
func NoAuth(c *gin.Context, message string) { httpx.NoAuth(c, message) }
|
||||
func SetTokenCookie(c *gin.Context, value string, maxAge int) { httpx.SetTokenCookie(c, value, maxAge) }
|
||||
@@ -0,0 +1,45 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := strings.TrimSuffix(c.Request.URL.Path, "/")
|
||||
if !strings.HasSuffix(path, "/base/login") && !strings.HasSuffix(path, "/base/captcha") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
config, err := settings.CurrentSecurity(c.Request.Context())
|
||||
if err != nil || config == nil {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": CodeError, "msg": "安全服务暂不可用"})
|
||||
return
|
||||
}
|
||||
if !config.LimitEnable {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
window := config.LimitWindow
|
||||
if window < 1 {
|
||||
window = 60
|
||||
}
|
||||
key := "KRA_SecLimit" + c.ClientIP() + c.FullPath()
|
||||
count, cacheErr := settings.IncrementRateLimit(c.Request.Context(), key, time.Duration(window)*time.Second)
|
||||
if cacheErr != nil {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": CodeError, "msg": "安全服务暂不可用"})
|
||||
return
|
||||
}
|
||||
if int(count) > config.LimitCount {
|
||||
c.JSON(200, gin.H{"code": CodeError, "msg": "请求太过频繁,请稍后再试"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type rateLimitSecurityRepo struct{}
|
||||
|
||||
func (rateLimitSecurityRepo) SecurityConfig(context.Context) (*biz.SecurityConfig, error) {
|
||||
return &biz.SecurityConfig{LimitEnable: true, LimitWindow: 60, LimitCount: 1}, nil
|
||||
}
|
||||
func (rateLimitSecurityRepo) SaveSecurityConfig(context.Context, *biz.SecurityConfig) error {
|
||||
return nil
|
||||
}
|
||||
func (rateLimitSecurityRepo) BackfillPasswordUpdatedAt(context.Context, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type rateLimitCache struct{ err error }
|
||||
|
||||
func (rateLimitCache) Get(context.Context, string) (string, bool, error) { return "", false, nil }
|
||||
func (rateLimitCache) Set(context.Context, string, string, time.Duration) error { return nil }
|
||||
func (rateLimitCache) Delete(context.Context, string) error { return nil }
|
||||
func (c rateLimitCache) Increment(context.Context, string, time.Duration) (int64, error) {
|
||||
return 2, c.err
|
||||
}
|
||||
|
||||
func TestSecurityRateLimitMatchesResponseContract(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
settings := service.NewSecurityService(biz.NewSecurityUsecase(rateLimitSecurityRepo{}, rateLimitCache{}, nil, nil))
|
||||
engine := gin.New()
|
||||
engine.Use(SecurityRateLimit(settings))
|
||||
engine.POST("/base/login", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/base/login", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, `"code":7`) || !strings.Contains(body, `"msg":"请求太过频繁,请稍后再试"`) || strings.Contains(body, `"data"`) {
|
||||
t.Fatalf("unexpected rate-limit response: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityRateLimitFailsClosedWhenCacheIsUnavailable(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
settings := service.NewSecurityService(biz.NewSecurityUsecase(rateLimitSecurityRepo{}, rateLimitCache{err: errors.New("cache unavailable")}, nil, nil))
|
||||
engine := gin.New()
|
||||
called := false
|
||||
engine.Use(SecurityRateLimit(settings))
|
||||
engine.POST("/base/login", func(c *gin.Context) { called = true })
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/base/login", nil))
|
||||
if response.Code != http.StatusServiceUnavailable || called {
|
||||
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Recovery(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)
|
||||
if logger != nil {
|
||||
logger.ErrorContext(c.Request.Context(), "recovery from panic", "mod", "http", "error", panicValue, "request", string(request), "stack", string(debug.Stack()))
|
||||
}
|
||||
if brokenPipe {
|
||||
if err, ok := panicValue.(error); ok {
|
||||
_ = c.Error(err)
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"kra/pkg/logging"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
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 !saneHeaderID(requestID) {
|
||||
requestID = uuid.NewString()
|
||||
}
|
||||
traceID, parentSpanID := "", ""
|
||||
if upstreamTraceID, upstreamSpanID, ok := parseTraceparent(c.GetHeader("traceparent")); ok {
|
||||
traceID, parentSpanID = upstreamTraceID, upstreamSpanID
|
||||
} else if candidate := c.GetHeader("X-Trace-Id"); saneHeaderID(candidate) {
|
||||
traceID = candidate
|
||||
}
|
||||
if traceID == "" {
|
||||
traceID = randomHex(16)
|
||||
}
|
||||
spanID := randomHex(8)
|
||||
c.Header("X-Request-Id", requestID)
|
||||
c.Header("X-Trace-Id", traceID)
|
||||
if validTraceID(traceID) {
|
||||
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.Request = c.Request.WithContext(logging.WithContextFields(c.Request.Context(), &logging.ContextFields{
|
||||
RequestID: requestID, TraceID: traceID, SpanID: spanID, ParentSpanID: parentSpanID,
|
||||
DeviceID: c.GetHeader("X-Device-Id"), ClientIP: c.ClientIP(),
|
||||
HTTPMethod: c.Request.Method, HTTPPath: c.Request.URL.Path,
|
||||
}))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func parseTraceparent(value string) (traceID, parentSpanID string, ok bool) {
|
||||
parts := strings.Split(value, "-")
|
||||
if len(parts) < 4 {
|
||||
return "", "", false
|
||||
}
|
||||
version, traceID, parentSpanID, flags := parts[0], parts[1], parts[2], parts[3]
|
||||
if !lowerHex(version, 2) || version == "ff" || version == "00" && len(parts) != 4 ||
|
||||
!validTraceID(traceID) || !lowerHex(parentSpanID, 16) || allZero(parentSpanID) || !lowerHex(flags, 2) {
|
||||
return "", "", false
|
||||
}
|
||||
return traceID, parentSpanID, true
|
||||
}
|
||||
|
||||
func saneHeaderID(value string) bool {
|
||||
if value == "" || len(value) > 64 {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < len(value); index++ {
|
||||
if value[index] <= 0x20 || value[index] > 0x7e {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validTraceID(value string) bool { return lowerHex(value, 32) && !allZero(value) }
|
||||
|
||||
func lowerHex(value string, length int) bool {
|
||||
if len(value) != length {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < len(value); index++ {
|
||||
if current := value[index]; current < '0' || current > '9' {
|
||||
if current < 'a' || current > 'f' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func allZero(value string) bool {
|
||||
for index := 0; index < len(value); index++ {
|
||||
if value[index] != '0' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user