This commit is contained in:
Yvan
2026-08-18 15:04:07 +08:00
parent 10ccc005be
commit 1ef8ecb7b2
42 changed files with 3027 additions and 296 deletions
+11 -2
View File
@@ -1,6 +1,8 @@
package middleware
import (
"context"
"kra/internal/biz"
"kra/internal/conf"
"kra/internal/server/httpx"
@@ -9,7 +11,12 @@ import (
"github.com/gin-gonic/gin"
)
func AccessControl(runtime *conf.Runtime, access *service.AccessControlService) gin.HandlerFunc {
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 {
@@ -29,7 +36,9 @@ func AccessControl(runtime *conf.Runtime, access *service.AccessControlService)
}
requestContext, err := access.ContextWithDataScope(c.Request.Context(), claims.AuthorityID, claims.ID)
if err != nil {
requestContext = c.Request.Context()
httpx.Write(c, httpx.CodeError, gin.H{}, "数据权限解析失败")
c.Abort()
return
}
if scope, ok := biz.DataScopeFromContext(requestContext); ok {
requestID, _ := c.Get("request_id")
+47 -10
View File
@@ -2,17 +2,22 @@ package middleware
import (
"bytes"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"kra/internal/biz"
"kra/internal/conf"
"kra/internal/server/httpx"
"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 {
@@ -20,20 +25,45 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
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
if c.Request.Body != nil && !multipart {
requestBody, _ = io.ReadAll(c.Request.Body)
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
bytesIn = int64(len(requestBody))
}
if bytesIn < 0 {
bytesIn = 0
}
maxBytes := 1 << 20
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes}
c.Writer = writer
c.Header("X-Kra-Version", version)
config := runtime.Admin()
requestReadFailed := c.Request.ContentLength > bodyLimit
if requestReadFailed {
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"code": httpx.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": httpx.CodeError, "msg": "请求体超过大小上限"})
} else {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": httpx.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)
@@ -46,7 +76,9 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
}
c.Set(ctxReqBodyKey, requestText)
c.Set(ctxRespBufferKey, &writer.body)
c.Next()
if !requestReadFailed {
c.Next()
}
if logger == nil {
return
}
@@ -86,6 +118,11 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
}
}
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 {
@@ -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())
}
}
+51
View File
@@ -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())
}
}
+13 -1
View File
@@ -3,6 +3,7 @@ package middleware
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -41,7 +42,18 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H
if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
requestBody = []byte("[文件]")
} else {
requestBody, _ = io.ReadAll(c.Request.Body)
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))
}
}
+11 -2
View File
@@ -1,6 +1,7 @@
package middleware
import (
"net/http"
"strings"
"time"
@@ -18,7 +19,11 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
return
}
config, err := settings.CurrentSecurity(c.Request.Context())
if err != nil || config == nil || !config.LimitEnable {
if err != nil || config == nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": httpx.CodeError, "msg": "安全服务暂不可用"})
return
}
if !config.LimitEnable {
c.Next()
return
}
@@ -28,7 +33,11 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
}
key := "KRA_SecLimit" + c.ClientIP() + c.FullPath()
count, cacheErr := settings.IncrementRateLimit(c.Request.Context(), key, time.Duration(window)*time.Second)
if cacheErr == nil && int(count) > config.LimitCount {
if cacheErr != nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": httpx.CodeError, "msg": "安全服务暂不可用"})
return
}
if int(count) > config.LimitCount {
c.JSON(200, gin.H{"code": httpx.CodeError, "msg": "请求太过频繁,请稍后再试"})
c.Abort()
return
+23 -5
View File
@@ -2,6 +2,7 @@ package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -26,12 +27,14 @@ func (rateLimitSecurityRepo) BackfillPasswordUpdatedAt(context.Context, time.Tim
return nil
}
type rateLimitCache struct{}
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 (rateLimitCache) Increment(context.Context, string, time.Duration) (int64, error) { return 2, nil }
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)
@@ -50,3 +53,18 @@ func TestSecurityRateLimitMatchesResponseContract(t *testing.T) {
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())
}
}