diff --git a/cmd/wire.go b/cmd/wire.go index 201a474..f834bef 100644 --- a/cmd/wire.go +++ b/cmd/wire.go @@ -16,6 +16,7 @@ import ( "kra/internal/initialize" "kra/internal/integration" "kra/internal/integration/cache" + "kra/internal/modules" "kra/internal/server" "kra/internal/server/handler" "kra/internal/server/middleware" @@ -36,7 +37,7 @@ func wireApp(*conf.Server, *conf.Runtime, *slog.Logger, *logging.ReloadableLogge handler.ProviderSet, router.ProviderSet, worker.ProviderSet, - app.Catalog, + modules.Catalog, app.TaskRegistry, runtimeContributions, app.Runtime, diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index eacb76b..0d56705 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -26,6 +26,7 @@ import ( "kra/internal/integration/mq" "kra/internal/integration/storage" "kra/internal/integration/websocket" + "kra/internal/modules" "kra/internal/server" "kra/internal/server/handler" "kra/internal/server/router" @@ -47,7 +48,7 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger if err != nil { return nil, nil, err } - catalog := app.Catalog() + catalog := modules.Catalog() dataData, cleanup, err := data.NewData(runtime, logger, reloadable, catalog) if err != nil { return nil, nil, err @@ -100,7 +101,8 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger emailUsecase := system2.NewEmailUsecase(emailRepo) emailService := service.NewEmailService(emailUsecase) handlerEmail := handler.NewEmail(emailService) - paymentRepo := payment.NewPaymentRepo(dataData) + paymentConfigReader := integration.NewPaymentConfigReader(dataData) + paymentRepo := payment.NewPaymentRepo(dataData, paymentConfigReader) paymentOrderRepo := payment.NewPaymentOrderRepo(dataData) paymentUsecase := payment2.NewPaymentUsecase(paymentRepo, paymentOrderRepo, logger) paymentService := service.NewPaymentService(paymentUsecase) diff --git a/docs/system-pkg-audit.md b/docs/system-pkg-audit.md index 65e8c54..44165f2 100644 --- a/docs/system-pkg-audit.md +++ b/docs/system-pkg-audit.md @@ -18,7 +18,8 @@ ## system 内部保留边界 -- `app`:组合根,汇总各模块的迁移、菜单、路由和任务贡献。 +- `app`:运行时组合根,汇总依赖注入后的路由和任务贡献。 +- `modules`:静态模块 catalog,汇总各模块迁移、菜单、API 和默认任务。 - `modules/system`:system 模块的 Definition,声明系统表迁移。 - `modules/integration`:integration 配置迁移和管理面贡献。 - `modules/task`:定时任务迁移和默认任务贡献。 @@ -44,8 +45,8 @@ ```text internal/ - app/ # 组合根和 catalog - modules/ # 业务模块定义及其模块级贡献 + app/ # 运行时组合根 + modules/ # 静态 catalog、业务模块定义及其模块级贡献 biz/ system/ # 系统领域 payment/ # 支付领域 diff --git a/docs/system-structure-audit.md b/docs/system-structure-audit.md index 1a174ae..927a976 100644 --- a/docs/system-structure-audit.md +++ b/docs/system-structure-audit.md @@ -9,8 +9,8 @@ ```text internal/ - app/ # 组合根、模块 catalog 和运行时组合 - modules/system/ # system 模块定义 + app/ # 运行时组合根 + modules/ # 静态 catalog 和模块定义 modules/payment/ # payment 模块定义 biz/ # DO、usecase、repo interface conf/ # 配置 proto/runtime @@ -40,12 +40,12 @@ internal/ 独立边界时不继续拆分。 - 删除只转发 `pkg/protoutil` 的 `utils/configutil`。 -## `internal/app` 为什么只保留组合代码 +## `internal/app` 为什么只保留运行时组合 -`app/catalog.go` 是有意保留的组合根,负责组装模块、任务注册与运行时。 -system 自身的迁移、管理面和默认定时任务位于 -`modules/system/definition.go`,由模块包声明后再被 catalog 汇总。这样模块 -定义不再和应用组合逻辑混在一起,也不能误并入 `biz`、`service` 或 `data`。 +`modules/catalog.go` 是静态模块注册点,负责按依赖顺序汇总各模块 +`Definition()`;`app/runtime.go` 只负责任务注册和依赖注入后的运行时路由组合。 +这样模块定义不再和应用组合逻辑混在一起,也不能误并入 `biz`、`service` 或 +`data`。 Catalog 只能自动汇总静态模块贡献;新增模块若提供运行时路由或依赖型任务,仍需 在 cmd/Wire 中显式注册,直到统一的 runtime contribution 协议落地。 diff --git a/internal/README.md b/internal/README.md index 1cbd234..a03ba63 100644 --- a/internal/README.md +++ b/internal/README.md @@ -3,8 +3,8 @@ 系统模块承载当前管理后台的完整业务边界。`internal` 顶层只保留有明确 生命周期或分层职责的包: -- `app`:组合根、模块 catalog 和任务/路由运行时组合 -- `modules`:按 system/integration/task/payment 维护 Definition 等模块贡献 +- `app`:运行时组合根,负责依赖注入后的任务/路由组合 +- `modules`:静态模块 catalog,按 system/integration/task/payment 维护 Definition - `biz/system`:用户、权限、菜单、审计、媒体和系统配置领域 - `biz/payment`:支付订单、支付流程、支付接口和支付日志 - `biz/integration`:集成配置定义、校验和连接测试边界 @@ -27,14 +27,17 @@ 定义位于 `modules/system`,JWT 实现集中在 `security`,protobuf JSON 统一使用 `pkg/protoutil`。 -`internal/app` 只保留 `catalog.go` 作为组合根:它负责组装模块 catalog、任务 -注册和运行时。system 的迁移、管理面和定时任务由 `internal/modules/system` -自己的 `Definition()` 声明,便于后续业务模块独立接入。 +`internal/modules/catalog.go` 是静态模块 catalog 的唯一注册点,负责按依赖顺序 +汇总各模块 Definition。`internal/app/runtime.go` 只负责依赖注入后的任务注册 +和路由运行时组合。这样新增模块只需在 modules catalog 注册一次,app 不再重复 +维护模块声明。 系统表统一使用 `sys_` 前缀;业务表应由新业务模块自行命名和迁移,不要混入 本目录。 -system 通过 `modules/system.Definition()` 提供系统迁移、通信集成菜单/API 和默认任务; +system 通过 `modules/system.Definition()` 提供系统迁移;integration 通过 +`modules/integration.Definition()` 提供通信集成菜单/API;task 通过 +`modules/task.Definition()` 提供定时任务迁移和默认任务; payment 通过 `modules/payment.Definition()` 提供支付迁移及支付菜单/API。两者通过 `worker.TaskMethods` 提供依赖系统用例的任务实现,通过 `server/router.Routes` 提供 路由。静态模块贡献可由 catalog 汇总;带运行时依赖的路由和任务仍需在 cmd/Wire diff --git a/internal/app/catalog.go b/internal/app/runtime.go similarity index 56% rename from internal/app/catalog.go rename to internal/app/runtime.go index 4a77b59..362490c 100644 --- a/internal/app/catalog.go +++ b/internal/app/runtime.go @@ -1,30 +1,13 @@ // Package app is the composition root for the running administration service. -// It knows which business modules are enabled and wires their contributions -// into the shared platform. Individual modules do not import this package. +// It wires runtime objects that need constructed dependencies. Static module +// declarations live in the sibling internal/modules package. package app import ( - integrationmodule "kra/internal/modules/integration" - paymentmodule "kra/internal/modules/payment" - systemmodule "kra/internal/modules/system" - taskmodule "kra/internal/modules/task" "kra/pkg/module" platformtask "kra/pkg/task" ) -// Catalog lists the business modules enabled in this binary. Static module -// contributions (migrations, admin metadata, default tasks) enter through a -// Definition; runtime routes and dependency-bearing task contributors still -// need explicit wiring below. -func Catalog() module.Catalog { - return module.Catalog{Definitions: []module.Definition{ - systemmodule.Definition(), - integrationmodule.Definition(), - taskmodule.Definition(), - paymentmodule.Definition(), - }} -} - // TaskRegistry builds the process-wide registry from dependency-free module // contributions. Dependency-bearing methods are added by their module runtime // constructors after the usecases have been created. diff --git a/internal/app/catalog_test.go b/internal/app/runtime_test.go similarity index 53% rename from internal/app/catalog_test.go rename to internal/app/runtime_test.go index 988ed06..e59dc7d 100644 --- a/internal/app/catalog_test.go +++ b/internal/app/runtime_test.go @@ -10,37 +10,6 @@ import ( platformtask "kra/pkg/task" ) -func TestCatalogMigrationIDsAreUnique(t *testing.T) { - seen := map[string]string{} - for _, definition := range Catalog().Definitions { - for _, step := range definition.Migrations { - if previous, exists := seen[step.ID]; exists { - t.Fatalf("migration %q is declared by both %s and %s", step.ID, previous, definition.Name) - } - seen[step.ID] = definition.Name - } - } -} - -func TestCatalogIncludesSystemDefinition(t *testing.T) { - catalog := Catalog() - if len(catalog.Definitions) != 4 { - t.Fatalf("definitions = %d, want 4", len(catalog.Definitions)) - } - if catalog.Definitions[0].Name != "system" || catalog.Definitions[1].Name != "integration" || catalog.Definitions[2].Name != "task" || catalog.Definitions[3].Name != "payment" { - t.Fatalf("definition order = [%q, %q, %q, %q], want [system, integration, task, payment]", catalog.Definitions[0].Name, catalog.Definitions[1].Name, catalog.Definitions[2].Name, catalog.Definitions[3].Name) - } - if got := catalog.MigrationSteps(); len(got) != 8 { - t.Fatalf("module migrations = %d, want 8", len(got)) - } - if surface := catalog.Surface(); len(surface.Menus) != 3 || len(surface.APIs) != 15 { - t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", len(surface.Menus), len(surface.APIs)) - } - if got := catalog.DefaultTimedTasks(); len(got) != 2 { - t.Fatalf("default timed tasks = %d, want 2", len(got)) - } -} - func TestTaskRegistryRegistersStaticModuleMethods(t *testing.T) { method := platformtask.Method{ Name: "test.static", diff --git a/internal/data/README.md b/internal/data/README.md index 325c1ff..61eb45a 100644 --- a/internal/data/README.md +++ b/internal/data/README.md @@ -25,7 +25,8 @@ reload locks. Do not split them into packages only to reduce file count. - `system` 只拥有 `sys_*` 系统表、系统仓储、种子和系统维护清理; - `integration` 唯一拥有 `sys_integration_configs` 表模型、配置仓储和通信默认值; - `task` 只拥有定时任务与任务日志表; -- `payment` 只拥有 `pay_orders` 等支付持久化,读取集成配置时复用 integration 的表模型。 +- `payment` 只拥有 `pay_orders` 等支付持久化,通过 `biz/integration.PaymentConfigReader` + 读取支付配置,不感知 integration 的 PO 或表结构。 配置文件迁移、数据库切换、Redis/Mongo/对象存储重载仍属于根 data 的生命周期编排, 不等同于某个业务模块的表仓储。 diff --git a/internal/data/integration/migrations.go b/internal/data/integration/migrations.go index 61d81cd..0ddb7d2 100644 --- a/internal/data/integration/migrations.go +++ b/internal/data/integration/migrations.go @@ -2,6 +2,7 @@ package integration import ( "encoding/json" + "errors" integrationbiz "kra/internal/biz/integration" "kra/pkg/database/migration" @@ -15,6 +16,7 @@ func Migrations() []migration.Step { return migration.CreateMissingTables(db, &ConfigPO{}) }}, {ID: "202608210001_communication_integration_defaults", Migrate: ensureCommunicationIntegrationConfigs}, + {ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs}, } } @@ -43,3 +45,43 @@ func ensureCommunicationIntegrationConfigs(db *gorm.DB) error { } return nil } + +func ensurePaymentIntegrationConfigs(db *gorm.DB) error { + for _, definition := range integrationbiz.IntegrationDefinitions(integrationbiz.IntegrationKindPayment) { + provider := definition.Provider + var row ConfigPO + err := db.Where("kind = ? AND provider = ?", integrationbiz.IntegrationKindPayment, provider).First(&row).Error + defaults := integrationbiz.DefaultIntegrationConfig(integrationbiz.IntegrationKindPayment, provider) + if errors.Is(err, gorm.ErrRecordNotFound) { + encoded, marshalErr := json.Marshal(defaults) + if marshalErr != nil { + return marshalErr + } + if err := db.Create(&ConfigPO{Kind: integrationbiz.IntegrationKindPayment, Provider: provider, Config: string(encoded)}).Error; err != nil { + return err + } + continue + } + if err != nil { + return err + } + values := integrationObject(json.RawMessage(row.Config)) + changed := false + for key, value := range defaults { + if _, exists := values[key]; !exists { + values[key] = value + changed = true + } + } + if changed { + encoded, marshalErr := json.Marshal(values) + if marshalErr != nil { + return marshalErr + } + if err := db.Model(&row).Update("config", string(encoded)).Error; err != nil { + return err + } + } + } + return nil +} diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 82a735a..6268c4a 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -3,19 +3,15 @@ package data import ( "testing" + bizpayment "kra/internal/biz/payment" dataintegration "kra/internal/data/integration" - integrationmodule "kra/internal/modules/integration" - paymentmodule "kra/internal/modules/payment" - systemmodule "kra/internal/modules/system" - taskmodule "kra/internal/modules/task" + "kra/internal/modules" "kra/pkg/database/migration" platformmodule "kra/pkg/module" ) func testCatalog() platformmodule.Catalog { - return platformmodule.Catalog{Definitions: []platformmodule.Definition{ - systemmodule.Definition(), integrationmodule.Definition(), taskmodule.Definition(), paymentmodule.Definition(), - }} + return modules.Catalog() } func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { @@ -48,6 +44,13 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { if len(communicationRows) != 3 { t.Fatalf("communication integration rows = %d, want 3", len(communicationRows)) } + var paymentRows int64 + if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", "payment").Count(&paymentRows).Error; err != nil { + t.Fatal(err) + } + if paymentRows != int64(len(bizpayment.SupportedPaymentProviders)) { + t.Fatalf("payment integration rows = %d, want %d", paymentRows, len(bizpayment.SupportedPaymentProviders)) + } for _, row := range communicationRows { if row.Enabled || row.Config == "" { t.Fatalf("default communication integration = %#v", row) diff --git a/internal/data/payment/migrations.go b/internal/data/payment/migrations.go index 1982499..e985a7e 100644 --- a/internal/data/payment/migrations.go +++ b/internal/data/payment/migrations.go @@ -11,6 +11,5 @@ func Migrations() []migration.Step { {ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &paymentOrderPO{}) }}, - {ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs}, } } diff --git a/internal/data/payment/payment.go b/internal/data/payment/payment.go index 6300522..ba64028 100644 --- a/internal/data/payment/payment.go +++ b/internal/data/payment/payment.go @@ -271,16 +271,22 @@ func recordPaymentTestError(ctx context.Context, data Provider, provider, tradeN } func (r *paymentRepo) testRow(ctx context.Context, provider string) (map[string]any, error) { - var row dataintegration.ConfigPO - if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", integrationbiz.IntegrationKindPayment, provider).First(&row).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { + if r == nil || r.config == nil { + return nil, errors.New("支付配置仓储未接入") + } + config, err := r.config.ReadPaymentConfig(ctx, provider) + if err != nil { + if errors.Is(err, integrationbiz.ErrPaymentConfigNotFound) { return nil, bizpayment.ErrPaymentProviderNotFound } return nil, err } + if config == nil { + return nil, errors.New("支付配置为空") + } values := map[string]any{} - if err := json.Unmarshal([]byte(row.Config), &values); err != nil { - return nil, fmt.Errorf("支付配置格式错误: %w", err) + if err := json.Unmarshal(config.Values, &values); err != nil || values == nil { + return nil, errors.New("支付配置格式错误") } return values, nil } diff --git a/internal/data/payment/testing_support_test.go b/internal/data/payment/testing_support_test.go index 32af0ef..2528579 100644 --- a/internal/data/payment/testing_support_test.go +++ b/internal/data/payment/testing_support_test.go @@ -2,11 +2,9 @@ package payment import ( "fmt" - "testing" "github.com/glebarez/sqlite" "gorm.io/gorm" - dataintegration "kra/internal/data/integration" ) type Data struct{ gormDB *reloadableDB } @@ -30,32 +28,3 @@ func openWithDriver(driver, dsn string) (*gorm.DB, error) { } return gorm.Open(sqlite.Open(dsn), &gorm.Config{}) } - -func openIntegrationConfigTestDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&dataintegration.ConfigPO{}); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if sqlDB, err := db.DB(); err == nil { - _ = sqlDB.Close() - } - }) - return db -} - -func migrateAll(db *gorm.DB) error { - if err := db.AutoMigrate(&dataintegration.ConfigPO{}); err != nil { - return err - } - for _, step := range Migrations() { - if err := step.Migrate(db); err != nil { - return err - } - } - return nil -} diff --git a/internal/data/system/runtime.go b/internal/data/system/runtime.go index bc15762..a802acb 100644 --- a/internal/data/system/runtime.go +++ b/internal/data/system/runtime.go @@ -112,7 +112,7 @@ func (i *tokenIssuer) ReissueToken(source *system.AuthClaims, authorityID uint) AuthorityID: authorityID, BufferTime: int64(source.BufferTime / time.Second), UserType: source.UserType, MustChangePwd: source.MustChangePwd, PasswordVersion: source.PasswordVersion, RegisteredClaims: jwt.RegisteredClaims{ - Audience: jwt.ClaimStrings(append([]string(nil), source.Audience...)), Issuer: source.Issuer, + Audience: jwt.ClaimStrings{security.TokenAudience}, Issuer: settings.Issuer, IssuedAt: jwt.NewNumericDate(source.IssuedAt), NotBefore: jwt.NewNumericDate(source.NotBefore), ExpiresAt: jwt.NewNumericDate(source.ExpiresAt), }, } @@ -124,7 +124,8 @@ func (i *tokenIssuer) ReissueToken(source *system.AuthClaims, authorityID uint) } func (i *tokenIssuer) ParseToken(token string) (*system.AuthClaims, error) { - claims, err := security.Parse(token, i.settings.JWTSettings().SigningKey) + settings := i.settings.JWTSettings() + claims, err := security.ParseWithIssuer(token, settings.SigningKey, settings.Issuer) if err != nil { switch { case errors.Is(err, security.ErrTokenExpired): @@ -144,5 +145,12 @@ func (i *tokenIssuer) ParseToken(token string) (*system.AuthClaims, error) { if claims.IssuedAt != nil { issuedAt = claims.IssuedAt.Time } - return &system.AuthClaims{UUID: claims.UUID, ID: claims.ID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, UserType: claims.UserType, BufferTime: time.Duration(claims.BufferTime) * time.Second, MustChangePwd: claims.MustChangePwd, PasswordVersion: claims.PasswordVersion, Issuer: claims.Issuer, Audience: audience, IssuedAt: issuedAt, NotBefore: claims.NotBefore.Time, ExpiresAt: claims.ExpiresAt.Time}, nil + notBefore, expiresAt := time.Time{}, time.Time{} + if claims.NotBefore != nil { + notBefore = claims.NotBefore.Time + } + if claims.ExpiresAt != nil { + expiresAt = claims.ExpiresAt.Time + } + return &system.AuthClaims{UUID: claims.UUID, ID: claims.ID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, UserType: claims.UserType, BufferTime: time.Duration(claims.BufferTime) * time.Second, MustChangePwd: claims.MustChangePwd, PasswordVersion: claims.PasswordVersion, Issuer: claims.Issuer, Audience: audience, IssuedAt: issuedAt, NotBefore: notBefore, ExpiresAt: expiresAt}, nil } diff --git a/internal/integration/storage/s3_storage.go b/internal/integration/storage/s3_storage.go index 9608487..b85ddac 100644 --- a/internal/integration/storage/s3_storage.go +++ b/internal/integration/storage/s3_storage.go @@ -102,7 +102,7 @@ func (s *s3Storage) DeletePrefix(ctx context.Context, prefix string) error { if err != nil { return err } - items := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{Prefix: boundedPrefix(s.key(prefix), prefix), Recursive: true}) + items := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{Prefix: boundedPrefix(s.key(prefix+"/"), prefix+"/"), Recursive: true}) for item := range items { if item.Err != nil { return item.Err diff --git a/internal/modules/catalog.go b/internal/modules/catalog.go new file mode 100644 index 0000000..73681da --- /dev/null +++ b/internal/modules/catalog.go @@ -0,0 +1,24 @@ +// Package modules owns the static declarations of built-in business modules. +// It is intentionally separate from app: modules describe what they provide, +// while app composes runtime objects that need constructed dependencies. +package modules + +import ( + integrationmodule "kra/internal/modules/integration" + paymentmodule "kra/internal/modules/payment" + systemmodule "kra/internal/modules/system" + taskmodule "kra/internal/modules/task" + "kra/pkg/module" +) + +// Catalog returns the modules enabled in this binary in dependency order. +// Keep this as the single static registration point for migrations, admin +// metadata and dependency-free timed tasks. +func Catalog() module.Catalog { + return module.Catalog{Definitions: []module.Definition{ + systemmodule.Definition(), + integrationmodule.Definition(), + taskmodule.Definition(), + paymentmodule.Definition(), + }} +} diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go new file mode 100644 index 0000000..9d89b67 --- /dev/null +++ b/internal/modules/catalog_test.go @@ -0,0 +1,37 @@ +package modules + +import "testing" + +func TestCatalogMigrationIDsAreUnique(t *testing.T) { + seen := map[string]string{} + for _, definition := range Catalog().Definitions { + for _, step := range definition.Migrations { + if previous, exists := seen[step.ID]; exists { + t.Fatalf("migration %q is declared by both %s and %s", step.ID, previous, definition.Name) + } + seen[step.ID] = definition.Name + } + } +} + +func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { + catalog := Catalog() + if len(catalog.Definitions) != 4 { + t.Fatalf("definitions = %d, want 4", len(catalog.Definitions)) + } + want := []string{"system", "integration", "task", "payment"} + for index, name := range want { + if got := catalog.Definitions[index].Name; got != name { + t.Fatalf("definition[%d] = %q, want %q", index, got, name) + } + } + if got := catalog.MigrationSteps(); len(got) != 8 { + t.Fatalf("module migrations = %d, want 8", len(got)) + } + if surface := catalog.Surface(); len(surface.Menus) != 3 || len(surface.APIs) != 15 { + t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", len(surface.Menus), len(surface.APIs)) + } + if got := catalog.DefaultTimedTasks(); len(got) != 2 { + t.Fatalf("default timed tasks = %d, want 2", len(got)) + } +} diff --git a/internal/modules/integration/definition_test.go b/internal/modules/integration/definition_test.go index 935dc91..3567a64 100644 --- a/internal/modules/integration/definition_test.go +++ b/internal/modules/integration/definition_test.go @@ -4,7 +4,7 @@ import "testing" func TestDefinitionOwnsCommunicationSurface(t *testing.T) { definition := Definition() - if definition.Name != "integration" || len(definition.Surface.Menus) != 1 || len(definition.Surface.APIs) != 5 { + if definition.Name != "integration" || len(definition.Migrations) != 3 || len(definition.Surface.Menus) != 1 || len(definition.Surface.APIs) != 5 { t.Fatalf("integration surface = %#v", definition.Surface) } } diff --git a/internal/modules/payment/definition_test.go b/internal/modules/payment/definition_test.go index 1075f2d..93fd5b2 100644 --- a/internal/modules/payment/definition_test.go +++ b/internal/modules/payment/definition_test.go @@ -7,8 +7,8 @@ func TestDefinitionOwnsPaymentContributions(t *testing.T) { if definition.Name != "payment" { t.Fatalf("name = %q, want payment", definition.Name) } - if len(definition.Migrations) != 2 { - t.Fatalf("migrations = %d, want 2", len(definition.Migrations)) + if len(definition.Migrations) != 1 { + t.Fatalf("migrations = %d, want 1", len(definition.Migrations)) } if len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 10 { t.Fatalf("surface = %d menus/%d APIs, want 2/10", len(definition.Surface.Menus), len(definition.Surface.APIs)) diff --git a/internal/security/README.md b/internal/security/README.md new file mode 100644 index 0000000..e7afafc --- /dev/null +++ b/internal/security/README.md @@ -0,0 +1,9 @@ +# Internal Security + +This package owns the application's administrator JWT policy. It is kept +inside `internal` because its claims, audience, issuer and password-version +fields are KRA-specific security protocol, not a reusable JWT utility. + +Generic stateless helpers may live in `pkg`, but admin token signing and +verification stay here so other services cannot accidentally depend on this +application's security contract. diff --git a/internal/security/token.go b/internal/security/token.go index ce235d0..88be804 100644 --- a/internal/security/token.go +++ b/internal/security/token.go @@ -7,7 +7,11 @@ import ( "github.com/golang-jwt/jwt/v5" ) +const TokenAudience = "KRA" + var ( + ErrEmptySigningKey = errors.New("empty JWT signing key") + ErrInvalidClaims = errors.New("invalid token claims") ErrTokenExpired = errors.New("token expired") ErrTokenMalformed = errors.New("token malformed") ErrTokenSignatureInvalid = errors.New("token signature invalid") @@ -29,29 +33,54 @@ type Claims struct { } func Generate(secret, issuer string, expires, buffer time.Duration, userID, authorityID uint, uuid, username, nickname string, mustChange bool, passwordVersion int64) (string, *Claims, error) { - if secret == "" { - return "", nil, errors.New("empty JWT signing key") + if err := validateSigningOptions(secret, expires, buffer); err != nil { + return "", nil, err } now := time.Now() - claims := &Claims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, BufferTime: int64(buffer / time.Second), UserType: "admin", MustChangePwd: mustChange, PasswordVersion: passwordVersion, RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"KRA"}, Issuer: issuer, IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-1000)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}} + claims := &Claims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, BufferTime: int64(buffer / time.Second), UserType: "admin", MustChangePwd: mustChange, PasswordVersion: passwordVersion, RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{TokenAudience}, Issuer: issuer, IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-time.Second)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}} token, err := Sign(secret, claims) return token, claims, err } func Sign(secret string, claims *Claims) (string, error) { if secret == "" { - return "", errors.New("empty JWT signing key") + return "", ErrEmptySigningKey + } + if claims == nil { + return "", ErrInvalidClaims } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) } func Parse(tokenString, secret string) (*Claims, error) { + return parse(tokenString, secret, "") +} + +// ParseWithIssuer validates a KRA admin token and its configured issuer. +func ParseWithIssuer(tokenString, secret, issuer string) (*Claims, error) { + return parse(tokenString, secret, issuer) +} + +func parse(tokenString, secret, issuer string) (*Claims, error) { + if secret == "" { + return nil, ErrEmptySigningKey + } + options := []jwt.ParserOption{ + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + jwt.WithAudience(TokenAudience), + jwt.WithExpirationRequired(), + jwt.WithNotBeforeRequired(), + jwt.WithLeeway(time.Second), + } + if issuer != "" { + options = append(options, jwt.WithIssuer(issuer)) + } token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { if token.Method != jwt.SigningMethodHS256 { return nil, errors.New("unexpected signing method") } return []byte(secret), nil - }) + }, options...) if err != nil { switch { case errors.Is(err, jwt.ErrTokenExpired): @@ -71,7 +100,20 @@ func Parse(tokenString, secret string) (*Claims, error) { } claims, ok := token.Claims.(*Claims) if !ok { - return nil, errors.New("token claims are invalid") + return nil, ErrInvalidClaims } return claims, nil } + +func validateSigningOptions(secret string, expires, buffer time.Duration) error { + if secret == "" { + return ErrEmptySigningKey + } + if expires <= 0 { + return errors.New("JWT expiration must be positive") + } + if buffer < 0 { + return errors.New("JWT buffer must not be negative") + } + return nil +} diff --git a/internal/security/token_test.go b/internal/security/token_test.go new file mode 100644 index 0000000..55359f1 --- /dev/null +++ b/internal/security/token_test.go @@ -0,0 +1,75 @@ +package security + +import ( + "errors" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestGenerateAndParseWithIssuerRoundTrip(t *testing.T) { + token, want, err := Generate("secret", "kra-admin", time.Hour, time.Minute, 7, 8, "uuid", "alice", "Alice", true, 9) + if err != nil { + t.Fatal(err) + } + got, err := ParseWithIssuer(token, "secret", "kra-admin") + if err != nil { + t.Fatal(err) + } + if got.ID != want.ID || got.Username != want.Username || got.Audience[0] != TokenAudience { + t.Fatalf("claims = %#v, want %#v", got, want) + } +} + +func TestParseRejectsWrongIssuerAndAudience(t *testing.T) { + token, _, err := Generate("secret", "kra-admin", time.Hour, time.Minute, 1, 1, "uuid", "user", "", false, 0) + if err != nil { + t.Fatal(err) + } + if _, err = ParseWithIssuer(token, "secret", "other"); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("wrong issuer error = %v, want ErrTokenInvalid", err) + } + claims := &Claims{RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"other"}, Issuer: "kra-admin", IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Second)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))}} + token, err = Sign("secret", claims) + if err != nil { + t.Fatal(err) + } + if _, err = ParseWithIssuer(token, "secret", "kra-admin"); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("wrong audience error = %v, want ErrTokenInvalid", err) + } +} + +func TestParseRejectsMissingRequiredTimeClaims(t *testing.T) { + claims := &Claims{RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{TokenAudience}, Issuer: "kra-admin"}} + token, err := Sign("secret", claims) + if err != nil { + t.Fatal(err) + } + if _, err = ParseWithIssuer(token, "secret", "kra-admin"); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("missing time claims error = %v, want ErrTokenInvalid", err) + } +} + +func TestSigningOptionsRejectInvalidValues(t *testing.T) { + if _, _, err := Generate("", "kra", time.Hour, 0, 1, 1, "", "", "", false, 0); !errors.Is(err, ErrEmptySigningKey) { + t.Fatalf("empty key error = %v", err) + } + if _, _, err := Generate("secret", "kra", 0, 0, 1, 1, "", "", "", false, 0); err == nil { + t.Fatal("zero expiration was accepted") + } + if _, err := Sign("secret", nil); !errors.Is(err, ErrInvalidClaims) { + t.Fatalf("nil claims error = %v", err) + } +} + +func TestParseRejectsAlgorithmConfusion(t *testing.T) { + claims := &Claims{RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{TokenAudience}, Issuer: "kra-admin", IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Second)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))}} + token, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte("secret")) + if err != nil { + t.Fatal(err) + } + if _, err = ParseWithIssuer(token, "secret", "kra-admin"); !errors.Is(err, ErrTokenSignatureInvalid) { + t.Fatalf("algorithm confusion error = %v, want ErrTokenSignatureInvalid", err) + } +}