diff --git a/cmd/main.go b/cmd/main.go index b683650..133a2db 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,12 +9,16 @@ import ( "path/filepath" "strings" + "kra/internal/app" "kra/internal/conf" + "kra/internal/server/router" "kra/internal/service" "kra/internal/service/dto" "kra/internal/worker" "kra/pkg/logging" + "kra/pkg/module" "kra/pkg/mq" + platformtask "kra/pkg/task" "github.com/go-kratos/kratos/v3" "github.com/go-kratos/kratos/v3/config" @@ -41,6 +45,15 @@ func init() { flag.StringVar(&flagconf, "conf", "./configs", "config path, eg: -conf config.yaml") } +// runtimeContributions is the binary-level list of modules with constructed +// route or task dependencies. Adding another runtime module is explicit here. +func runtimeContributions(systemRoutes *router.Routes, systemTasks *worker.TaskMethods) app.RuntimeContributions { + return app.RuntimeContributions{ + Routes: []module.RouteRegistrar{systemRoutes}, + Tasks: []platformtask.Contributor{systemTasks}, + } +} + func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler, audit *service.AuditRecorder, loggerControl *logging.ReloadableLogger, _ mq.Client) *kratos.App { if audit != nil && loggerControl != nil { loggerControl.SetErrorSink(logging.ErrorSinkFunc(func(ctx context.Context, entry logging.ErrorEntry) error { diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 45f96bc..91d25e2 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -139,7 +139,8 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger tokenService := service.NewTokenService(tokenUsecase, tokenIssuer) apiToken := handler.NewAPIToken(tokenService) initializationRepo := initialize.NewRepo(dataData, catalog) - systemConfigUsecase := system2.NewSystemConfigUsecase(initializationRepo, taskRuntime) + taskReloader := worker.NewTaskReloader(taskScheduler) + systemConfigUsecase := system2.NewSystemConfigUsecase(initializationRepo, taskReloader) systemConfigService := service.NewSystemConfigService(systemConfigUsecase, runtimeSettings) systemConfig := handler.NewSystemConfig(systemConfigService, securityService) public := handler.NewPublic(authService, systemConfigService, securityService) diff --git a/go.mod b/go.mod index e895719..eb69ace 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,6 @@ require ( github.com/swaggo/swag v1.16.4 github.com/tencentyun/cos-go-sdk-v5 v0.7.60 github.com/xuri/excelize/v2 v2.9.0 - go.einride.tech/aip v0.86.3 go.mongodb.org/mongo-driver v1.17.2 go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.0 diff --git a/go.sum b/go.sum index 5eae956..42cf532 100644 --- a/go.sum +++ b/go.sum @@ -411,8 +411,6 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.einride.tech/aip v0.86.3 h1:jg80Ec4XBPYg1i7avzrl3MJol/dUwmMMLHtcmEMyxgM= -go.einride.tech/aip v0.86.3/go.mod h1:dZuN/0sXeoscfWqsW8QLcLrGZdvsCC1B2R2CZ4kHmao= go.mongodb.org/mongo-driver v1.17.2 h1:gvZyk8352qSfzyZ2UMWcpDpMSGEr1eqE4T793SqyhzM= go.mongodb.org/mongo-driver v1.17.2/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -569,8 +567,6 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc= -google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY= google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM= google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A= @@ -605,8 +601,6 @@ gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w= modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= diff --git a/internal/biz/biz.go b/internal/biz/biz.go index 24515af..36106f9 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -7,4 +7,4 @@ import ( ) // ProviderSet is biz providers. -var ProviderSet = wire.NewSet(system.NewUserUsecase, system.NewAuthenticationUsecase, system.NewSystemConfigUsecase, system.NewAuthorityUsecase, system.NewAPIUsecase, system.NewPermissionUsecase, system.NewAccessControlUsecase, system.NewMenuUsecase, system.NewDepartmentUsecase, system.NewPositionUsecase, system.NewDictionaryUsecase, system.NewParameterUsecase, system.NewTokenUsecase, system.NewSecurityUsecase, system.NewVersionUsecase, system.NewExportUsecase, system.NewAuditUsecase, system.NewAuditRecorderUsecase, system.NewLogViewerUsecase, system.NewTaskUsecaseWithRegistry, system.NewTaskApplicationUsecase, system.NewMediaUsecase, system.NewAnnouncementUsecase, system.NewEmailUsecase, system.NewPaymentUsecase, system.NewIntegrationConfigUsecase) +var ProviderSet = wire.NewSet(system.ProviderSet) diff --git a/internal/biz/system/authentication.go b/internal/biz/system/authentication.go index 7fddbe3..e0dd2dc 100644 --- a/internal/biz/system/authentication.go +++ b/internal/biz/system/authentication.go @@ -57,6 +57,9 @@ func NewAuthenticationUsecase(users *UserUsecase, security *SecurityUsecase, iss } func (uc *AuthenticationUsecase) recordLogin(ctx context.Context, attempt *LoginAttempt, status bool, message string, userID uint) { + if uc.audit == nil || attempt == nil { + return + } _ = uc.audit.RecordLogin(ctx, &LoginLog{Username: attempt.Username, IP: attempt.IP, Status: status, ErrorMessage: message, Agent: attempt.Agent, UserID: userID}) } @@ -65,7 +68,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp if err != nil || config == nil { return nil, fmt.Errorf("%w: security config unavailable", ErrLoginState) } - if config != nil && config.LockEnable { + if config.LockEnable { locked, lockErr := uc.security.LoginLocked(ctx, attempt.Username) if lockErr != nil { return nil, fmt.Errorf("%w: %v", ErrLoginState, lockErr) @@ -77,17 +80,14 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp } ipTTL := time.Hour - requireCaptcha := config == nil || config.CaptchaOpen == 0 - if config != nil { - if config.CaptchaTimeout > 0 { - ipTTL = time.Duration(config.CaptchaTimeout) * time.Second - } - failures, counterErr := uc.security.EnsureLoginIPCounter(ctx, attempt.IP, ipTTL) - if counterErr != nil { - return nil, fmt.Errorf("%w: %v", ErrLoginState, counterErr) - } - requireCaptcha = config.CaptchaOpen == 0 || failures > config.CaptchaOpen + if config.CaptchaTimeout > 0 { + ipTTL = time.Duration(config.CaptchaTimeout) * time.Second } + failures, counterErr := uc.security.EnsureLoginIPCounter(ctx, attempt.IP, ipTTL) + if counterErr != nil { + return nil, fmt.Errorf("%w: %v", ErrLoginState, counterErr) + } + requireCaptcha := config.CaptchaOpen == 0 || failures > config.CaptchaOpen if requireCaptcha && !uc.security.VerifyCaptcha(ctx, attempt.CaptchaID, attempt.Captcha, true) { _, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL) uc.recordLogin(ctx, attempt, false, "验证码错误", 0) @@ -97,7 +97,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp user, err := uc.users.Login(ctx, attempt.Username, attempt.Password) if err != nil { _, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL) - if config != nil && config.LockEnable { + if config.LockEnable { lockTTL := time.Duration(config.LockDuration) * time.Minute failures, stateErr := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL) if stateErr != nil { @@ -119,7 +119,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp } uc.security.ClearLoginState(ctx, attempt.Username) - if config != nil && config.PwdExpireEnable && config.PwdExpireDays > 0 && user.PasswordUpdatedAt != nil && time.Now().After((*user.PasswordUpdatedAt).AddDate(0, 0, config.PwdExpireDays)) { + if config.PwdExpireEnable && config.PwdExpireDays > 0 && user.PasswordUpdatedAt != nil && time.Now().After((*user.PasswordUpdatedAt).AddDate(0, 0, config.PwdExpireDays)) { user.MustChangePassword = true } issued, err := uc.issuer.IssueToken(user, user.AuthorityID, user.MustChangePassword, 0) diff --git a/internal/biz/system/authentication_test.go b/internal/biz/system/authentication_test.go index aaaa35f..d2887c6 100644 --- a/internal/biz/system/authentication_test.go +++ b/internal/biz/system/authentication_test.go @@ -140,6 +140,11 @@ func TestLoginRecordsSuccessBeforeMultipointCacheFailure(t *testing.T) { } } +func TestRecordLoginAllowsMissingAuditRecorder(t *testing.T) { + uc := &AuthenticationUsecase{} + uc.recordLogin(context.Background(), &LoginAttempt{Username: "admin"}, false, "failed", 0) +} + func TestSwitchAuthorityReissuesCurrentClaimsWithoutReloadingUser(t *testing.T) { repo := &switchAuthorityUserRepo{} issuer := &switchAuthorityIssuer{} diff --git a/internal/biz/system/pagination.go b/internal/biz/system/pagination.go deleted file mode 100644 index 9ed3054..0000000 --- a/internal/biz/system/pagination.go +++ /dev/null @@ -1,39 +0,0 @@ -package system - -import ( - "go.einride.tech/aip/filtering" - "go.einride.tech/aip/ordering" -) - -type ListOption func(*ListOptions) - -type ListOptions struct { - Filter filtering.Filter - OrderBy ordering.OrderBy - Offset int - Limit int -} - -func ListFilter(filter filtering.Filter) ListOption { - return func(o *ListOptions) { - o.Filter = filter - } -} - -func ListOrderBy(orderBy ordering.OrderBy) ListOption { - return func(o *ListOptions) { - o.OrderBy = orderBy - } -} - -func ListOffset(offset int) ListOption { - return func(o *ListOptions) { - o.Offset = offset - } -} - -func ListLimit(limit int) ListOption { - return func(o *ListOptions) { - o.Limit = limit - } -} diff --git a/internal/biz/system/provider.go b/internal/biz/system/provider.go new file mode 100644 index 0000000..08e0773 --- /dev/null +++ b/internal/biz/system/provider.go @@ -0,0 +1,34 @@ +package system + +import "github.com/google/wire" + +// ProviderSet wires the system domain usecases. The parent biz package keeps +// the aggregate entry point used by Wire while each domain owns its providers. +var ProviderSet = wire.NewSet( + NewUserUsecase, + NewAuthenticationUsecase, + NewSystemConfigUsecase, + NewAuthorityUsecase, + NewAPIUsecase, + NewPermissionUsecase, + NewAccessControlUsecase, + NewMenuUsecase, + NewDepartmentUsecase, + NewPositionUsecase, + NewDictionaryUsecase, + NewParameterUsecase, + NewTokenUsecase, + NewSecurityUsecase, + NewVersionUsecase, + NewExportUsecase, + NewAuditUsecase, + NewAuditRecorderUsecase, + NewLogViewerUsecase, + NewTaskUsecaseWithRegistry, + NewTaskApplicationUsecase, + NewMediaUsecase, + NewAnnouncementUsecase, + NewEmailUsecase, + NewPaymentUsecase, + NewIntegrationConfigUsecase, +) diff --git a/internal/biz/system/system_init.go b/internal/biz/system/system_init.go index 0d63e3a..0bdb924 100644 --- a/internal/biz/system/system_init.go +++ b/internal/biz/system/system_init.go @@ -30,12 +30,16 @@ type InitializationRepo interface { DiskMountPoints() []string } -type SystemConfigUsecase struct { - repo InitializationRepo - tasks TaskRuntime +type TaskReloader interface { + Reload(context.Context) error } -func NewSystemConfigUsecase(repo InitializationRepo, tasks TaskRuntime) *SystemConfigUsecase { +type SystemConfigUsecase struct { + repo InitializationRepo + tasks TaskReloader +} + +func NewSystemConfigUsecase(repo InitializationRepo, tasks TaskReloader) *SystemConfigUsecase { return &SystemConfigUsecase{repo: repo, tasks: tasks} } diff --git a/internal/biz/system/user.go b/internal/biz/system/user.go index 46d860e..2aa6915 100644 --- a/internal/biz/system/user.go +++ b/internal/biz/system/user.go @@ -62,7 +62,7 @@ func NewUserUsecase(repo UserRepo) *UserUsecase { return &UserUsecase{repo: repo func (uc *UserUsecase) Login(ctx context.Context, username, password string) (*User, error) { u, err := uc.repo.FindUserByUsername(ctx, username) - if err != nil || bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil { + if err != nil || u == nil || bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil { return nil, ErrInvalidCredentials } uc.fallbackDefaultRouter(ctx, u) diff --git a/internal/biz/system/user_test.go b/internal/biz/system/user_test.go index d02d678..0b1e390 100644 --- a/internal/biz/system/user_test.go +++ b/internal/biz/system/user_test.go @@ -12,6 +12,16 @@ type defaultRouterUserRepo struct { err error } +type loginUserRepo struct { + UserRepo + user *User + err error +} + +func (r *loginUserRepo) FindUserByUsername(context.Context, string) (*User, error) { + return r.user, r.err +} + func (r *defaultRouterUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) { return r.hasMenu, r.err } @@ -35,3 +45,10 @@ func TestFallbackDefaultRouterMatchesMenuLookupOutcome(t *testing.T) { t.Fatalf("empty missing default route = %q", user.Authority.DefaultRouter) } } + +func TestLoginRejectsMissingUserWithoutPanic(t *testing.T) { + user, err := NewUserUsecase(&loginUserRepo{}).Login(context.Background(), "missing", "password") + if !errors.Is(err, ErrInvalidCredentials) || user != nil { + t.Fatalf("Login() = %#v, %v; want nil, ErrInvalidCredentials", user, err) + } +} diff --git a/internal/data/README.md b/internal/data/README.md index 711c884..7290cb3 100644 --- a/internal/data/README.md +++ b/internal/data/README.md @@ -5,6 +5,8 @@ watching, and repository implementations. - `repository/`: system repositories and table persistence - `payment/`: payment configuration and payment-order persistence +- each subpackage owns its Wire `ProviderSet`; the root package only binds the + shared `Data` infrastructure and aggregates those sets - root files: shared database lifecycle, runtime clients, integration-config storage, data-scope auditing, and migration orchestration diff --git a/internal/data/data.go b/internal/data/data.go index d598698..9c965dc 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -25,14 +25,8 @@ var ProviderSet = wire.NewSet( wire.Bind(new(datasystem.Provider), new(*Data)), wire.Bind(new(datasystem.DatabaseProvider), new(*Data)), wire.Bind(new(datapayment.Provider), new(*Data)), - datasystem.NewRuntimeSettings, - datasystem.NewTokenIssuer, - datasystem.NewUserRepo, datasystem.NewAuthorityAccessRepo, datasystem.NewAPIRepo, datasystem.NewPermissionRepo, - datasystem.NewMenuRepo, datasystem.NewDepartmentRepo, datasystem.NewPositionRepo, datasystem.NewDictionaryRepo, datasystem.NewParameterRepo, datasystem.NewAPITokenRepo, - datasystem.NewSecurityRepo, - datasystem.NewVersionRepo, datasystem.NewExportRepo, datasystem.NewAuditRepo, datasystem.NewAuditRecorderRepo, datasystem.NewLogFileRepo, datasystem.NewTaskRepo, - datasystem.NewMediaRepo, datasystem.NewAnnouncementRepo, datapayment.NewPaymentRepo, datapayment.NewPaymentOrderRepo, - datasystem.NewIntegrationConfigRepo, + datasystem.ProviderSet, + datapayment.ProviderSet, ) func NewIntegrationRuntime(data *Data) *runtimeconfig.Store { diff --git a/internal/data/payment/providers.go b/internal/data/payment/providers.go new file mode 100644 index 0000000..194a1b9 --- /dev/null +++ b/internal/data/payment/providers.go @@ -0,0 +1,6 @@ +package payment + +import "github.com/google/wire" + +// ProviderSet wires payment persistence repositories. +var ProviderSet = wire.NewSet(NewPaymentRepo, NewPaymentOrderRepo) diff --git a/internal/data/repository/providers.go b/internal/data/repository/providers.go new file mode 100644 index 0000000..4bd14c6 --- /dev/null +++ b/internal/data/repository/providers.go @@ -0,0 +1,29 @@ +package system + +import "github.com/google/wire" + +// ProviderSet wires system repositories and their runtime-backed adapters. +var ProviderSet = wire.NewSet( + NewRuntimeSettings, + NewTokenIssuer, + NewUserRepo, + NewAuthorityAccessRepo, + NewAPIRepo, + NewPermissionRepo, + NewMenuRepo, + NewDepartmentRepo, + NewPositionRepo, + NewDictionaryRepo, + NewParameterRepo, + NewAPITokenRepo, + NewSecurityRepo, + NewVersionRepo, + NewExportRepo, + NewAuditRepo, + NewAuditRecorderRepo, + NewLogFileRepo, + NewTaskRepo, + NewMediaRepo, + NewAnnouncementRepo, + NewIntegrationConfigRepo, +) diff --git a/internal/server/gin.go b/internal/server/gin.go index 1eef31a..c0fc041 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -27,6 +27,9 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h } func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine { + if runtime == nil { + runtime = conf.NewRuntime(nil, nil) + } gin.SetMode(gin.ReleaseMode) engine := gin.New() if err := engine.SetTrustedProxies(nil); err != nil && logger != nil { diff --git a/internal/server/handler/audit.go b/internal/server/handler/audit.go index 8d7ab44..38a4ace 100644 --- a/internal/server/handler/audit.go +++ b/internal/server/handler/audit.go @@ -232,15 +232,24 @@ func failLogViewer(c *gin.Context, err error, logger *slog.Logger) { } func (h *Audit) DeleteError(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - if err := h.service.DeleteErrors(c.Request.Context(), []uint{uint(id)}); err != nil { + id, err := positiveUintQuery(c, "ID") + if err != nil { + Fail(c, "错误记录ID非法") + return + } + if err := h.service.DeleteErrors(c.Request.Context(), []uint{id}); err != nil { Fail(c, "删除失败:"+err.Error()) return } Write(c, CodeSuccess, gin.H{}, "删除成功") } func (h *Audit) DeleteErrors(c *gin.Context) { - if err := h.service.DeleteErrors(c.Request.Context(), IDsFromQuery(c)); err != nil { + ids, err := IDsFromQuery(c) + if err != nil { + Fail(c, "错误记录ID非法") + return + } + if err := h.service.DeleteErrors(c.Request.Context(), ids); err != nil { Fail(c, "批量删除失败:"+err.Error()) return } @@ -259,8 +268,12 @@ func (h *Audit) UpdateError(c *gin.Context) { Write(c, CodeSuccess, gin.H{}, "更新成功") } func (h *Audit) Error(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - item, err := h.service.Error(c.Request.Context(), uint(id)) + id, err := positiveUintQuery(c, "ID") + if err != nil { + Fail(c, "错误记录ID非法") + return + } + item, err := h.service.Error(c.Request.Context(), id) if err != nil { Fail(c, "查询失败:"+err.Error()) return diff --git a/internal/server/handler/export.go b/internal/server/handler/export.go index d4889ab..deabe09 100644 --- a/internal/server/handler/export.go +++ b/internal/server/handler/export.go @@ -142,7 +142,11 @@ func (h *Export) Preview(c *gin.Context) { Fail(c, "模板ID不能为空") return } - params, _ := exportParams(c.Request.URL.Query()) + params, err := exportParams(c.Request.URL.Query()) + if err != nil { + Fail(c, "解析 params 参数失败") + return + } sql, err := h.service.Preview(c.Request.Context(), templateID, params) if err != nil { Fail(c, "获取失败") diff --git a/internal/server/handler/media.go b/internal/server/handler/media.go index e3efaf3..51af87d 100644 --- a/internal/server/handler/media.go +++ b/internal/server/handler/media.go @@ -56,7 +56,11 @@ func (h *Media) Upload(c *gin.Context) { return } defer opened.Close() - category, _ := strconv.Atoi(c.DefaultPostForm("classId", "0")) + category, parseErr := strconv.Atoi(c.DefaultPostForm("classId", "0")) + if parseErr != nil || category < 0 { + Fail(c, "文件分类 ID 非法") + return + } save := c.DefaultQuery("noSave", "0") == "0" item, err := h.service.Upload(c.Request.Context(), claims.ID, header.Filename, header.Header.Get("Content-Type"), category, opened, save) if err != nil { @@ -276,12 +280,16 @@ func (h *Media) CompleteUpload(c *gin.Context) { } func (h *Media) CancelUpload(c *gin.Context) { claims := Claims(c) - id, _ := strconv.ParseUint(c.Param("uploadId"), 10, 64) if claims == nil { Fail(c, "未登录") return } - if err := h.service.CancelUpload(c.Request.Context(), claims.ID, uint(id)); err != nil { + id, err := positiveUintParam(c, "uploadId") + if err != nil { + Fail(c, "上传会话 ID 非法") + return + } + if err := h.service.CancelUpload(c.Request.Context(), claims.ID, id); err != nil { Fail(c, err.Error()) return } diff --git a/internal/server/handler/query.go b/internal/server/handler/query.go index bd744bc..3d0302b 100644 --- a/internal/server/handler/query.go +++ b/internal/server/handler/query.go @@ -1,11 +1,31 @@ package handler import ( + "errors" "strconv" + "strings" "github.com/gin-gonic/gin" ) +var errInvalidPositiveUint = errors.New("参数必须是正整数") + +func positiveUint(raw string) (uint, error) { + parsed, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 64) + if err != nil || parsed == 0 || uint64(uint(parsed)) != parsed { + return 0, errInvalidPositiveUint + } + return uint(parsed), nil +} + +func positiveUintQuery(c *gin.Context, key string) (uint, error) { + return positiveUint(c.Query(key)) +} + +func positiveUintParam(c *gin.Context, key string) (uint, error) { + return positiveUint(c.Param(key)) +} + func page(c *gin.Context) (int, int, error) { var value, size int if raw, exists := c.GetQuery("page"); exists && raw != "" { @@ -25,14 +45,18 @@ func page(c *gin.Context) (int, int, error) { return value, size, nil } -func IDsFromQuery(c *gin.Context) []uint { +func IDsFromQuery(c *gin.Context) ([]uint, error) { values := c.QueryArray("IDs[]") + if len(values) == 0 { + return nil, errInvalidPositiveUint + } ids := make([]uint, 0, len(values)) for _, value := range values { - id, _ := strconv.ParseUint(value, 10, 64) - if id > 0 { - ids = append(ids, uint(id)) + id, err := positiveUint(value) + if err != nil { + return nil, err } + ids = append(ids, id) } - return ids + return ids, nil } diff --git a/internal/server/handler/session.go b/internal/server/handler/session.go index 0e7d598..00c3e2d 100644 --- a/internal/server/handler/session.go +++ b/internal/server/handler/session.go @@ -1,6 +1,7 @@ package handler import ( + "kra/internal/server/middleware" "kra/internal/service" "github.com/gin-gonic/gin" @@ -10,9 +11,10 @@ type Session struct{ tokens *service.TokenService } func NewSession(tokens *service.TokenService) *Session { return &Session{tokens: tokens} } func (h *Session) Logout(c *gin.Context) { - token := c.GetHeader("x-token") + token := middleware.RequestToken(c, false) if token == "" { - token, _ = c.Cookie("x-token") + NoAuth(c, "未登录或非法访问") + return } if err := h.tokens.BlacklistToken(c.Request.Context(), token); err != nil { Fail(c, "jwt作废失败") diff --git a/internal/server/handler/version.go b/internal/server/handler/version.go index bca271f..fd11345 100644 --- a/internal/server/handler/version.go +++ b/internal/server/handler/version.go @@ -27,8 +27,12 @@ func versionStage(err error) (system.VersionStage, bool) { } func (h *Version) Delete(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - if err := h.service.DeleteVersions(c.Request.Context(), []uint{uint(id)}); err != nil { + id, err := positiveUintQuery(c, "ID") + if err != nil { + Fail(c, "版本ID非法") + return + } + if err := h.service.DeleteVersions(c.Request.Context(), []uint{id}); err != nil { Fail(c, "删除失败:"+err.Error()) return } @@ -38,8 +42,16 @@ func (h *Version) DeleteMany(c *gin.Context) { raw := c.QueryArray("IDs[]") ids := make([]uint, 0, len(raw)) for _, value := range raw { - id, _ := strconv.ParseUint(value, 10, 64) - ids = append(ids, uint(id)) + id, err := positiveUint(value) + if err != nil { + Fail(c, "版本ID非法") + return + } + ids = append(ids, id) + } + if len(ids) == 0 { + Fail(c, "版本ID不能为空") + return } if err := h.service.DeleteVersions(c.Request.Context(), ids); err != nil { Fail(c, "批量删除失败:"+err.Error()) @@ -48,8 +60,12 @@ func (h *Version) DeleteMany(c *gin.Context) { Write(c, CodeSuccess, gin.H{}, "批量删除成功") } func (h *Version) Find(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - item, err := h.service.Version(c.Request.Context(), uint(id)) + id, err := positiveUintQuery(c, "ID") + if err != nil { + Fail(c, "版本ID非法") + return + } + item, err := h.service.Version(c.Request.Context(), id) if err != nil { Fail(c, "查询失败:"+err.Error()) return @@ -112,8 +128,12 @@ func (h *Version) Download(c *gin.Context) { Fail(c, "版本ID不能为空") return } - id, _ := strconv.ParseUint(rawID, 10, 64) - raw, code, err := h.service.VersionData(c.Request.Context(), uint(id)) + id, err := positiveUint(rawID) + if err != nil { + Fail(c, "版本ID非法") + return + } + raw, code, err := h.service.VersionData(c.Request.Context(), id) if err != nil { Fail(c, "获取版本记录失败:"+err.Error()) return diff --git a/internal/server/middleware/access.go b/internal/server/middleware/access.go index add21a0..1e2046c 100644 --- a/internal/server/middleware/access.go +++ b/internal/server/middleware/access.go @@ -22,6 +22,11 @@ func AccessControl(runtime *conf.Runtime, access accessController) gin.HandlerFu NoAuth(c, "未登录或非法访问") return } + if access == nil { + Write(c, CodeError, gin.H{}, "权限服务不可用") + c.Abort() + return + } path := c.Request.URL.Path policyPath := path if config := runtime.Admin(); config != nil { diff --git a/internal/server/middleware/access_log.go b/internal/server/middleware/access_log.go index 04e0c39..4b9eca9 100644 --- a/internal/server/middleware/access_log.go +++ b/internal/server/middleware/access_log.go @@ -30,7 +30,10 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H var requestBody []byte multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") mediaUpload := multipart && isMediaUploadRoute(c.FullPath()) - config := runtime.Admin() + var config *conf.AdminBackend + if runtime != nil { + config = runtime.Admin() + } bodyLimit := defaultRequestBodyLimit if mediaUpload { bodyLimit = system.DefaultMaxMediaFileSize + (1 << 20) diff --git a/internal/server/middleware/audit.go b/internal/server/middleware/audit.go index 4809b56..1ad9d40 100644 --- a/internal/server/middleware/audit.go +++ b/internal/server/middleware/audit.go @@ -23,6 +23,10 @@ const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed" func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.HandlerFunc { return func(c *gin.Context) { + if runtime == nil || recorder == nil { + c.Next() + return + } path := c.Request.URL.Path if !recordsOperation(c.Request.Method, path) { c.Next() @@ -95,12 +99,11 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin. } 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] + parsed, _ := url.ParseQuery(raw) + values := make(map[string]string, len(parsed)) + for key, items := range parsed { + if len(items) > 0 { + values[key] = items[len(items)-1] } } body, _ := json.Marshal(&values) @@ -189,23 +192,6 @@ func operationPathMatches(pattern, path string) bool { return len(patternParts) == len(pathParts) } -func routeSuffix(path string) string { - bestIndex := -1 - bestPath := path - 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/", "/integration/", "/payment/"} { - if index := strings.Index(path, marker); index >= 0 { - // Router prefixes may themselves contain a registered route marker - // (for example /api/integration/...). Keep the deepest match so the - // policy and audit route remain the actual application endpoint. - if index > bestIndex { - bestIndex = index - bestPath = path[index:] - } - } - } - return bestPath -} - 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", diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go index cfd5c2c..ffac3b6 100644 --- a/internal/server/middleware/auth.go +++ b/internal/server/middleware/auth.go @@ -68,7 +68,9 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool) return true } -func requestToken(c *gin.Context, allowQueryToken bool) string { +// RequestToken extracts the authentication token accepted by HTTP handlers. +// Query-string tokens are opt-in for WebSocket handshakes only. +func RequestToken(c *gin.Context, allowQueryToken bool) string { token := strings.TrimSpace(c.GetHeader("x-token")) if token == "" { authorization := strings.TrimSpace(c.GetHeader("Authorization")) @@ -91,6 +93,10 @@ func requestToken(c *gin.Context, allowQueryToken bool) string { return token } +func requestToken(c *gin.Context, allowQueryToken bool) string { + return RequestToken(c, allowQueryToken) +} + func tokenErrorMessage(err error) string { message := "无法处理此token" switch { diff --git a/internal/server/middleware/cors.go b/internal/server/middleware/cors.go index a5000a9..3a02ae8 100644 --- a/internal/server/middleware/cors.go +++ b/internal/server/middleware/cors.go @@ -19,6 +19,10 @@ const ( // configuration reload takes effect without rebuilding the Gin engine. func CORS(runtime *conf.Runtime) gin.HandlerFunc { return func(c *gin.Context) { + if runtime == nil { + c.Next() + return + } config := runtime.Admin() if config == nil || config.Cors == nil { c.Next() @@ -28,12 +32,14 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc { origin := c.GetHeader("Origin") corsHandled := false if mode == "allow-all" { - setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true) - corsHandled = true + if origin != "" { + 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") { + } else if mode == "strict-whitelist" && !(c.Request.Method == http.MethodGet && isHealthPath(c.Request.URL.Path)) { c.AbortWithStatus(http.StatusForbidden) return } @@ -45,6 +51,11 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc { } } +func isHealthPath(path string) bool { + path = strings.TrimSuffix(path, "/") + return path == "/health" || strings.HasSuffix(path, "/health") +} + func matchingCORSRule(rules []*conf.AdminBackend_CORSRule, origin string) *conf.AdminBackend_CORSRule { for _, rule := range rules { if rule != nil && origin == rule.AllowOrigin { @@ -55,6 +66,7 @@ func matchingCORSRule(rules []*conf.AdminBackend_CORSRule, origin string) *conf. } func setCORSHeaders(c *gin.Context, origin, headers, methods, expose string, credentials bool) { + c.Header("Vary", "Origin") c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Headers", headers) c.Header("Access-Control-Allow-Methods", methods) diff --git a/internal/server/middleware/cors_test.go b/internal/server/middleware/cors_test.go index 8cd5d58..1c1fc2a 100644 --- a/internal/server/middleware/cors_test.go +++ b/internal/server/middleware/cors_test.go @@ -37,3 +37,9 @@ func TestCORSConsumesMatchedWhitelistPreflight(t *testing.T) { t.Fatalf("matched whitelist preflight status = %d, want %d", response.Code, http.StatusNoContent) } } + +func TestCORSStrictWhitelistAllowsPrefixedHealth(t *testing.T) { + if !isHealthPath("/admin/health") { + t.Fatal("prefixed health endpoint was not recognized by strict whitelist") + } +} diff --git a/internal/server/middleware/error_audit_test.go b/internal/server/middleware/error_audit_test.go index 686da5d..3c26d98 100644 --- a/internal/server/middleware/error_audit_test.go +++ b/internal/server/middleware/error_audit_test.go @@ -2,6 +2,7 @@ package middleware import ( "bytes" + "encoding/json" "errors" "log/slog" "net/http" @@ -12,6 +13,16 @@ import ( "github.com/gin-gonic/gin" ) +func TestOperationQueryBodyPreservesEncodedSeparators(t *testing.T) { + var values map[string]string + if err := json.Unmarshal(operationQueryBody("filter=one%3Dtwo%26three&repeat=first&repeat=last"), &values); err != nil { + t.Fatal(err) + } + if values["filter"] != "one=two&three" || values["repeat"] != "last" { + t.Fatalf("unexpected query body: %#v", values) + } +} + func runErrorAudit(t *testing.T, path, response string, logger *slog.Logger) { t.Helper() gin.SetMode(gin.TestMode) diff --git a/internal/server/middleware/rate_limit.go b/internal/server/middleware/rate_limit.go index 1417794..6069572 100644 --- a/internal/server/middleware/rate_limit.go +++ b/internal/server/middleware/rate_limit.go @@ -16,6 +16,10 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc { c.Next() return } + if settings == nil { + c.Next() + return + } config, err := settings.CurrentSecurity(c.Request.Context()) if err != nil || config == nil { c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": CodeError, "msg": "安全服务暂不可用"}) @@ -29,7 +33,11 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc { if window < 1 { window = 60 } - key := "KRA_SecLimit" + c.ClientIP() + c.FullPath() + route := c.FullPath() + if route == "" { + route = path + } + key := "KRA_SecLimit:" + c.ClientIP() + ":" + route 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": "安全服务暂不可用"}) diff --git a/internal/server/swagger.go b/internal/server/swagger.go index 5bb1011..3dd0fe8 100644 --- a/internal/server/swagger.go +++ b/internal/server/swagger.go @@ -57,6 +57,7 @@ func registerSwagger(engine *gin.Engine, prefix, version string, logger *slog.Lo } func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string { + routes = append([]gin.RouteInfo(nil), routes...) basePath := strings.TrimSuffix(prefix, "/") if basePath == "" { basePath = "/" @@ -159,8 +160,8 @@ func swaggerPathParameters(path string) []map[string]any { func swaggerPublicPath(path string) bool { for _, marker := range []string{ "/health", "/base/login", "/base/captcha", "/init/checkdb", "/init/initdb", - "/api/freshCasbin", "/sysExportTemplate/exportExcelByToken", "/sysExportTemplate/exportTemplateByToken", - "/sysError/createSysError", "/info/getInfoDataSource", "/info/getInfoPublic", + "/sysExportTemplate/exportExcelByToken", "/sysExportTemplate/exportTemplateByToken", + "/sysError/createSysError", "/info/getInfoPublic", } { if path == marker { return true diff --git a/internal/server/swagger_test.go b/internal/server/swagger_test.go new file mode 100644 index 0000000..61fc060 --- /dev/null +++ b/internal/server/swagger_test.go @@ -0,0 +1,30 @@ +package server + +import ( + "encoding/json" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestSwaggerMarksOnlyPublicRoutesWithoutAuth(t *testing.T) { + document := buildSwaggerDocument([]gin.RouteInfo{ + {Method: "GET", Path: "/api/freshCasbin"}, + {Method: "GET", Path: "/info/getInfoDataSource"}, + {Method: "GET", Path: "/info/getInfoPublic"}, + }, "", "test") + var payload struct { + Paths map[string]map[string]map[string]any `json:"paths"` + } + if err := json.Unmarshal([]byte(document), &payload); err != nil { + t.Fatal(err) + } + for _, path := range []string{"/api/freshCasbin", "/info/getInfoDataSource"} { + if _, ok := payload.Paths[path]["get"]["security"]; !ok { + t.Fatalf("private route %s was marked public", path) + } + } + if _, ok := payload.Paths["/info/getInfoPublic"]["get"]["security"]; ok { + t.Fatal("public route /info/getInfoPublic requires authentication") + } +} diff --git a/internal/worker/task_scheduler.go b/internal/worker/task_scheduler.go index 1e66e64..f894dfc 100644 --- a/internal/worker/task_scheduler.go +++ b/internal/worker/task_scheduler.go @@ -46,6 +46,8 @@ func NewTaskScheduler(tasks *system.TaskUsecase, authorities *system.AuthorityUs func NewTaskRuntime(scheduler *TaskScheduler) system.TaskRuntime { return scheduler } +func NewTaskReloader(scheduler *TaskScheduler) system.TaskReloader { return scheduler } + func (s *TaskScheduler) Start(ctx context.Context) error { runContext, cancel := context.WithCancel(ctx) s.ctxMu.Lock() diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 816ea4e..9ab0f45 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -3,4 +3,4 @@ package worker import "github.com/google/wire" // ProviderSet contains background task runtime providers. -var ProviderSet = wire.NewSet(NewTaskMethods, NewTaskExecutorWithRegistry, NewTaskScheduler, NewTaskRuntime) +var ProviderSet = wire.NewSet(NewTaskMethods, NewTaskExecutorWithRegistry, NewTaskScheduler, NewTaskRuntime, NewTaskReloader) diff --git a/pkg/module/module.go b/pkg/module/module.go index 432943e..2daaede 100644 --- a/pkg/module/module.go +++ b/pkg/module/module.go @@ -97,6 +97,9 @@ func (r *Runtime) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.E return } for _, registrar := range r.routes { + if registrar == nil { + continue + } registrar.RegisterRoutes(public, private, engine) } } diff --git a/pkg/module/module_test.go b/pkg/module/module_test.go index b0f98c0..5f25371 100644 --- a/pkg/module/module_test.go +++ b/pkg/module/module_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "testing" + "github.com/gin-gonic/gin" "kra/pkg/database/migration" "kra/pkg/task" ) @@ -41,3 +42,18 @@ func TestCatalogCollectsContributions(t *testing.T) { t.Fatalf("unexpected task methods: %#v", got) } } + +type routeRegistrarStub struct{ called bool } + +func (stub *routeRegistrarStub) RegisterRoutes(*gin.RouterGroup, *gin.RouterGroup, *gin.Engine) { + stub.called = true +} + +func TestRuntimeSkipsNilRouteRegistrars(t *testing.T) { + stub := &routeRegistrarStub{} + runtime := NewRuntime(nil, stub) + runtime.RegisterRoutes(nil, nil, nil) + if !stub.called { + t.Fatal("non-nil route registrar was not called") + } +}