优化结构

This commit is contained in:
Yvan 2026-08-23 00:38:33 +08:00
parent 64459574b2
commit 4e5071ee65
22 changed files with 301 additions and 127 deletions

View File

@ -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,

6
cmd/wire_gen.go generated
View File

@ -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)

View File

@ -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/ # 支付领域

View File

@ -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 协议落地。

View File

@ -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()` 提供通信集成菜单/APItask 通过
`modules/task.Definition()` 提供定时任务迁移和默认任务;
payment 通过 `modules/payment.Definition()` 提供支付迁移及支付菜单/API。两者通过
`worker.TaskMethods` 提供依赖系统用例的任务实现,通过 `server/router.Routes` 提供
路由。静态模块贡献可由 catalog 汇总;带运行时依赖的路由和任务仍需在 cmd/Wire

View File

@ -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.

View File

@ -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",

View File

@ -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 的生命周期编排,
不等同于某个业务模块的表仓储。

View File

@ -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
}

View File

@ -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)

View File

@ -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},
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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(),
}}
}

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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))

View File

@ -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.

View File

@ -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
}

View File

@ -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)
}
}