diff --git a/backend/cmd/app.go b/backend/cmd/app.go index d1cfcb1d..e35768f8 100644 --- a/backend/cmd/app.go +++ b/backend/cmd/app.go @@ -8,7 +8,6 @@ import ( "Wavelet/core/contracts" "Wavelet/plugins/domain/admin" "Wavelet/plugins/domain/auth" - "Wavelet/plugins/domain/cap" "Wavelet/plugins/domain/msg_gateway" "Wavelet/plugins/domain/risk_control" "Wavelet/plugins/domain/system" @@ -100,7 +99,7 @@ func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App { driver_inproc_cron.New(), ) - // 3. Register all 8 domain business plugins (admin first to ensure schema and base config tables exist) + // 3. Register all 7 domain business plugins (admin first to ensure schema and base config tables exist) app.Use( admin.New(), user.New(), @@ -108,7 +107,6 @@ func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App { msg_gateway.New(), risk_control.New(), upload.New(), - cap.New(), system.New(), ) diff --git a/backend/cmd/app_test.go b/backend/cmd/app_test.go index 165e87bc..a98d246b 100644 --- a/backend/cmd/app_test.go +++ b/backend/cmd/app_test.go @@ -34,9 +34,9 @@ func TestNewWaveletAppProfiles(t *testing.T) { require.NotNil(t, app) assert.Equal(t, prof, app.Profile()) - // 3 infra + 2 cache + 4 worker/cron + 8 domain + 1 http driver = 18 plugins + // 3 infra + 2 cache + 4 worker/cron + 7 domain + 1 http driver = 17 plugins plugins := app.Plugins() - assert.Len(t, plugins, 18) + assert.Len(t, plugins, 17) require.NoError(t, app.Reconcile()) @@ -94,9 +94,6 @@ func TestNewWaveletAppProfiles(t *testing.T) { _, ok = app.Plugin("upload") assert.True(t, ok, "upload plugin missing") - _, ok = app.Plugin("cap") - assert.True(t, ok, "cap plugin missing") - _, ok = app.Plugin("system") assert.True(t, ok, "system plugin missing") diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 2e512eb7..38707569 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -4397,7 +4397,7 @@ const docTemplate = `{ "name": "request", "in": "body", "schema": { - "$ref": "#/definitions/cap.challengeRequest" + "$ref": "#/definitions/auth.challengeRequest" } } ], @@ -4413,7 +4413,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/cap.ChallengeResponse" + "$ref": "#/definitions/auth.ChallengeResponse" } } } @@ -4446,7 +4446,7 @@ const docTemplate = `{ "name": "request", "in": "body", "schema": { - "$ref": "#/definitions/cap.challengeRequest" + "$ref": "#/definitions/auth.challengeRequest" } } ], @@ -4462,7 +4462,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/cap.ChallengeResponse" + "$ref": "#/definitions/auth.ChallengeResponse" } } } @@ -4498,7 +4498,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/cap.redeemRequest" + "$ref": "#/definitions/auth.redeemRequest" } } ], @@ -4514,7 +4514,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/cap.RedeemResponse" + "$ref": "#/definitions/auth.RedeemResponse" } } } @@ -6134,26 +6134,7 @@ const docTemplate = `{ } } }, - "auth.OAuthAuthorizeResponse": { - "type": "object", - "properties": { - "authorize_url": { - "type": "string" - } - } - }, - "auth.OAuthCallbackResult": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "user": { - "$ref": "#/definitions/auth.BasicUserInfo" - } - } - }, - "cap.ChallengeResponse": { + "auth.ChallengeResponse": { "type": "object", "properties": { "challenge": { @@ -6179,7 +6160,26 @@ const docTemplate = `{ } } }, - "cap.RedeemResponse": { + "auth.OAuthAuthorizeResponse": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + } + } + }, + "auth.OAuthCallbackResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/auth.BasicUserInfo" + } + } + }, + "auth.RedeemResponse": { "type": "object", "properties": { "error": { @@ -6196,7 +6196,7 @@ const docTemplate = `{ } } }, - "cap.challengeRequest": { + "auth.challengeRequest": { "type": "object", "properties": { "scope": { @@ -6204,7 +6204,7 @@ const docTemplate = `{ } } }, - "cap.redeemRequest": { + "auth.redeemRequest": { "type": "object", "required": [ "solutions", diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 894af81a..b5b17238 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -4390,7 +4390,7 @@ "name": "request", "in": "body", "schema": { - "$ref": "#/definitions/cap.challengeRequest" + "$ref": "#/definitions/auth.challengeRequest" } } ], @@ -4406,7 +4406,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/cap.ChallengeResponse" + "$ref": "#/definitions/auth.ChallengeResponse" } } } @@ -4439,7 +4439,7 @@ "name": "request", "in": "body", "schema": { - "$ref": "#/definitions/cap.challengeRequest" + "$ref": "#/definitions/auth.challengeRequest" } } ], @@ -4455,7 +4455,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/cap.ChallengeResponse" + "$ref": "#/definitions/auth.ChallengeResponse" } } } @@ -4491,7 +4491,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/cap.redeemRequest" + "$ref": "#/definitions/auth.redeemRequest" } } ], @@ -4507,7 +4507,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/cap.RedeemResponse" + "$ref": "#/definitions/auth.RedeemResponse" } } } @@ -6127,26 +6127,7 @@ } } }, - "auth.OAuthAuthorizeResponse": { - "type": "object", - "properties": { - "authorize_url": { - "type": "string" - } - } - }, - "auth.OAuthCallbackResult": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "user": { - "$ref": "#/definitions/auth.BasicUserInfo" - } - } - }, - "cap.ChallengeResponse": { + "auth.ChallengeResponse": { "type": "object", "properties": { "challenge": { @@ -6172,7 +6153,26 @@ } } }, - "cap.RedeemResponse": { + "auth.OAuthAuthorizeResponse": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + } + } + }, + "auth.OAuthCallbackResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/auth.BasicUserInfo" + } + } + }, + "auth.RedeemResponse": { "type": "object", "properties": { "error": { @@ -6189,7 +6189,7 @@ } } }, - "cap.challengeRequest": { + "auth.challengeRequest": { "type": "object", "properties": { "scope": { @@ -6197,7 +6197,7 @@ } } }, - "cap.redeemRequest": { + "auth.redeemRequest": { "type": "object", "required": [ "solutions", diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 53bf3835..14d39049 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -55,19 +55,7 @@ definitions: - code - state type: object - auth.OAuthAuthorizeResponse: - properties: - authorize_url: - type: string - type: object - auth.OAuthCallbackResult: - properties: - status: - type: string - user: - $ref: '#/definitions/auth.BasicUserInfo' - type: object - cap.ChallengeResponse: + auth.ChallengeResponse: properties: challenge: properties: @@ -84,7 +72,19 @@ definitions: token: type: string type: object - cap.RedeemResponse: + auth.OAuthAuthorizeResponse: + properties: + authorize_url: + type: string + type: object + auth.OAuthCallbackResult: + properties: + status: + type: string + user: + $ref: '#/definitions/auth.BasicUserInfo' + type: object + auth.RedeemResponse: properties: error: type: string @@ -95,12 +95,12 @@ definitions: token: type: string type: object - cap.challengeRequest: + auth.challengeRequest: properties: scope: type: string type: object - cap.redeemRequest: + auth.redeemRequest: properties: scope: type: string @@ -4031,7 +4031,7 @@ paths: in: body name: request schema: - $ref: '#/definitions/cap.challengeRequest' + $ref: '#/definitions/auth.challengeRequest' produces: - application/json responses: @@ -4042,7 +4042,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/cap.ChallengeResponse' + $ref: '#/definitions/auth.ChallengeResponse' type: object "500": description: 内部服务错误 @@ -4060,7 +4060,7 @@ paths: in: body name: request schema: - $ref: '#/definitions/cap.challengeRequest' + $ref: '#/definitions/auth.challengeRequest' produces: - application/json responses: @@ -4071,7 +4071,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/cap.ChallengeResponse' + $ref: '#/definitions/auth.ChallengeResponse' type: object "500": description: 内部服务错误 @@ -4091,7 +4091,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/cap.redeemRequest' + $ref: '#/definitions/auth.redeemRequest' produces: - application/json responses: @@ -4102,7 +4102,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/cap.RedeemResponse' + $ref: '#/definitions/auth.RedeemResponse' type: object "400": description: 参数错误或核销失败 diff --git a/backend/plugins/domain/cap/errs.go b/backend/plugins/domain/auth/cap_errs.go similarity index 95% rename from backend/plugins/domain/cap/errs.go rename to backend/plugins/domain/auth/cap_errs.go index 58fbb7d3..1f47bfb9 100644 --- a/backend/plugins/domain/cap/errs.go +++ b/backend/plugins/domain/auth/cap_errs.go @@ -1,8 +1,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -// Package cap 提供人机验证中间件 -package cap +package auth // HTTP 响应错误文案 const ( diff --git a/backend/plugins/domain/cap/handlers.go b/backend/plugins/domain/auth/cap_handlers.go similarity index 89% rename from backend/plugins/domain/cap/handlers.go rename to backend/plugins/domain/auth/cap_handlers.go index fe211ddb..d95987bd 100644 --- a/backend/plugins/domain/cap/handlers.go +++ b/backend/plugins/domain/auth/cap_handlers.go @@ -1,7 +1,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package cap +package auth import ( "Wavelet/pkg/logger" @@ -18,7 +18,7 @@ import ( // @Accept json // @Produce json // @Param request body challengeRequest false "可选范围限制参数" -// @Success 200 {object} response.Any{data=cap.ChallengeResponse} "成功返回 PoW 难题" +// @Success 200 {object} response.Any{data=auth.ChallengeResponse} "成功返回 PoW 难题" // @Failure 500 {object} response.Any "内部服务错误" // @Router /api/v1/cap/challenge [get] // @Router /api/v1/cap/challenge [post] @@ -30,7 +30,7 @@ func Challenge(c *gin.Context) { req.Scope = "login" } - mgr := GetDefaultManager() + mgr := GetDefaultCapManager() if mgr == nil { response.AbortInternal(c, errCapNotConfigured) return @@ -52,7 +52,7 @@ func Challenge(c *gin.Context) { // @Accept json // @Produce json // @Param request body redeemRequest true "难题 Token 与解答 solutions 数组" -// @Success 200 {object} response.Any{data=cap.RedeemResponse} "核销成功,返回 X-Cap-Token" +// @Success 200 {object} response.Any{data=auth.RedeemResponse} "核销成功,返回 X-Cap-Token" // @Failure 400 {object} response.Any "参数错误或核销失败" // @Failure 500 {object} response.Any "内部服务错误" // @Router /api/v1/cap/redeem [post] @@ -67,7 +67,7 @@ func Redeem(c *gin.Context) { req.Scope = "login" } - mgr := GetDefaultManager() + mgr := GetDefaultCapManager() if mgr == nil { response.AbortInternal(c, errCapNotConfigured) return diff --git a/backend/plugins/domain/cap/middleware.go b/backend/plugins/domain/auth/cap_middleware.go similarity index 70% rename from backend/plugins/domain/cap/middleware.go rename to backend/plugins/domain/auth/cap_middleware.go index aa9dc19a..1c6c7c7a 100644 --- a/backend/plugins/domain/cap/middleware.go +++ b/backend/plugins/domain/auth/cap_middleware.go @@ -1,7 +1,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package cap +package auth import ( "Wavelet/pkg/response" @@ -9,10 +9,10 @@ import ( "github.com/gin-gonic/gin" ) -// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header. -func VerifyMiddleware(mgr *Manager, scope string) gin.HandlerFunc { +// VerifyCaptchaMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header. +func VerifyCaptchaMiddleware(mgr *CaptchaManager, scope string) gin.HandlerFunc { return func(c *gin.Context) { - if !ProtectionEnabled(c.Request.Context()) { + if !CapProtectionEnabled(c.Request.Context()) { c.Next() return } diff --git a/backend/plugins/domain/cap/middleware_test.go b/backend/plugins/domain/auth/cap_middleware_test.go similarity index 65% rename from backend/plugins/domain/cap/middleware_test.go rename to backend/plugins/domain/auth/cap_middleware_test.go index 0aaf1da1..4c98a822 100644 --- a/backend/plugins/domain/cap/middleware_test.go +++ b/backend/plugins/domain/auth/cap_middleware_test.go @@ -1,7 +1,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package cap +package auth import ( "Wavelet/pkg/response" @@ -14,12 +14,12 @@ import ( func TestVerifyMiddlewareMissingTokenIsBadRequest(t *testing.T) { gin.SetMode(gin.TestMode) - restore := InstallTestRuntimeSettings(RuntimeSettings{LoginEnabled: true}) + restore := InstallCapTestRuntimeSettings(CapRuntimeSettings{LoginEnabled: true}) t.Cleanup(restore) engine := gin.New() engine.Use(response.ErrorHandlerMiddleware()) - engine.POST("/register", VerifyMiddleware(GetDefaultManager(), "register"), func(c *gin.Context) { + engine.POST("/register", VerifyCaptchaMiddleware(GetDefaultCapManager(), "register"), func(c *gin.Context) { c.Status(http.StatusOK) }) @@ -27,6 +27,6 @@ func TestVerifyMiddlewareMissingTokenIsBadRequest(t *testing.T) { rec := httptest.NewRecorder() engine.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { - t.Errorf("VerifyMiddleware() status = %d, want %d", rec.Code, http.StatusBadRequest) + t.Errorf("VerifyCaptchaMiddleware() status = %d, want %d", rec.Code, http.StatusBadRequest) } } diff --git a/backend/plugins/domain/cap/models.go b/backend/plugins/domain/auth/cap_models.go similarity index 85% rename from backend/plugins/domain/cap/models.go rename to backend/plugins/domain/auth/cap_models.go index 645a85ef..4a21d0f0 100644 --- a/backend/plugins/domain/cap/models.go +++ b/backend/plugins/domain/auth/cap_models.go @@ -1,10 +1,10 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package cap +package auth import ( - "Wavelet/plugins/domain/cap/pow" + "Wavelet/plugins/domain/auth/pow" ) // ChallengeResponse is a local type alias for the pow.ChallengeResponse struct @@ -30,8 +30,8 @@ type RedeemResponse struct { Error string `json:"error,omitempty"` } -// configRecord maps the columns selected from the system config table. -type configRecord struct { +// capConfigRecord maps the columns selected from the system config table. +type capConfigRecord struct { Key string `gorm:"column:key"` Value string `gorm:"column:value"` } diff --git a/backend/plugins/domain/auth/cap_runtime_settings.go b/backend/plugins/domain/auth/cap_runtime_settings.go new file mode 100644 index 00000000..411ce58c --- /dev/null +++ b/backend/plugins/domain/auth/cap_runtime_settings.go @@ -0,0 +1,197 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "errors" + "strconv" + "sync/atomic" + "time" + + "golang.org/x/sync/singleflight" +) + +const ( + defaultCapChallengeCount = 1 + defaultCapChallengeSize = 32 + defaultCapChallengeDifficulty = 4 + defaultCapChallengeTTL = 10 * time.Minute + defaultCapTokenTTL = 20 * time.Minute +) + +// CapRuntimeSettings is the parsed CAPTCHA runtime configuration loaded from system_configs. +type CapRuntimeSettings struct { + LoginEnabled bool + ChallengeCount int + ChallengeSize int + ChallengeDifficulty int + ChallengeTTL time.Duration + TokenTTL time.Duration +} + +// CAP 动态配置键常量 +const ( + ConfigKeyCapLoginEnabled = "cap_login_enabled" + ConfigKeyCapChallengeCount = "cap_challenge_count" + ConfigKeyCapChallengeSize = "cap_challenge_size" + ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" + ConfigKeyCapChallengeTTL = "cap_challenge_ttl" + // ConfigKeyCapTokenTTL 验证码 Token 过期时间键 + // #nosec G101 + ConfigKeyCapTokenTTL = "cap_token_ttl" +) + +var capRuntimeConfigKeys = []string{ + ConfigKeyCapLoginEnabled, + ConfigKeyCapChallengeCount, + ConfigKeyCapChallengeSize, + ConfigKeyCapChallengeDifficulty, + ConfigKeyCapChallengeTTL, + ConfigKeyCapTokenTTL, +} + +var capRuntimeConfigKeySet = func() map[string]struct{} { + set := make(map[string]struct{}, len(capRuntimeConfigKeys)) + for _, key := range capRuntimeConfigKeys { + set[key] = struct{}{} + } + return set +}() + +type capRuntimeSettingsStore struct { + snapshot atomic.Pointer[CapRuntimeSettings] + loadGroup singleflight.Group +} + +var capSettingsStore = &capRuntimeSettingsStore{} + +// IsCapRuntimeConfigKey reports whether a system config key affects CAPTCHA runtime settings. +func IsCapRuntimeConfigKey(key string) bool { + _, ok := capRuntimeConfigKeySet[key] + return ok +} + +// CurrentCapSettings returns the cached CAPTCHA runtime settings snapshot. +func CurrentCapSettings(ctx context.Context) (CapRuntimeSettings, error) { + return capSettingsStore.current(ctx) +} + +// CapProtectionEnabled reports whether CAPTCHA verification is required for protected routes. +func CapProtectionEnabled(ctx context.Context) bool { + settings, err := CurrentCapSettings(ctx) + if err != nil { + return false + } + return settings.LoginEnabled +} + +// InvalidateCapRuntimeSettings drops the in-process CAPTCHA settings snapshot. +func InvalidateCapRuntimeSettings() { + capSettingsStore.snapshot.Store(nil) +} + +// ResetCapRuntimeSettingsForTest clears the CAPTCHA runtime snapshot. +func ResetCapRuntimeSettingsForTest() { + InvalidateCapRuntimeSettings() +} + +// InstallCapTestRuntimeSettings installs a fixed snapshot for unit tests. +func InstallCapTestRuntimeSettings(settings CapRuntimeSettings) func() { + snapshot := settings + capSettingsStore.snapshot.Store(&snapshot) + return InvalidateCapRuntimeSettings +} + +func (s *capRuntimeSettingsStore) current(ctx context.Context) (CapRuntimeSettings, error) { + if snapshot := s.snapshot.Load(); snapshot != nil { + return *snapshot, nil + } + + loaded, err, _ := s.loadGroup.Do("cap-runtime-settings", func() (any, error) { + if snapshot := s.snapshot.Load(); snapshot != nil { + return *snapshot, nil + } + + settings, loadErr := loadCapRuntimeSettings(ctx) + if loadErr != nil { + return CapRuntimeSettings{}, loadErr + } + + s.snapshot.Store(&settings) + return settings, nil + }) + if err != nil { + return CapRuntimeSettings{}, err + } + + settings, ok := loaded.(CapRuntimeSettings) + if !ok { + return CapRuntimeSettings{}, errors.New("cap runtime settings loader returned unexpected type") + } + return settings, nil +} + +func loadCapRuntimeSettings(ctx context.Context) (CapRuntimeSettings, error) { + var records []capConfigRecord + db := getDB(ctx) + if db == nil { + return parseCapRuntimeSettings(nil), nil + } + if err := db.Table("w_system_configs").Where("key IN ?", capRuntimeConfigKeys).Find(&records).Error; err != nil { + return CapRuntimeSettings{}, err + } + configs := make(map[string]string, len(records)) + for _, r := range records { + configs[r.Key] = r.Value + } + return parseCapRuntimeSettings(configs), nil +} + +func parseCapRuntimeSettings(configs map[string]string) CapRuntimeSettings { + settings := CapRuntimeSettings{ + ChallengeCount: defaultCapChallengeCount, + ChallengeSize: defaultCapChallengeSize, + ChallengeDifficulty: defaultCapChallengeDifficulty, + ChallengeTTL: defaultCapChallengeTTL, + TokenTTL: defaultCapTokenTTL, + } + + if len(configs) == 0 { + return settings + } + + if val, ok := configs[ConfigKeyCapLoginEnabled]; ok { + if enabled, err := strconv.ParseBool(val); err == nil { + settings.LoginEnabled = enabled + } + } + if val, ok := configs[ConfigKeyCapChallengeCount]; ok { + if count, err := strconv.Atoi(val); err == nil && count > 0 { + settings.ChallengeCount = count + } + } + if val, ok := configs[ConfigKeyCapChallengeSize]; ok { + if size, err := strconv.Atoi(val); err == nil && size > 0 { + settings.ChallengeSize = size + } + } + if val, ok := configs[ConfigKeyCapChallengeDifficulty]; ok { + if diff, err := strconv.Atoi(val); err == nil && diff > 0 { + settings.ChallengeDifficulty = diff + } + } + if val, ok := configs[ConfigKeyCapChallengeTTL]; ok { + if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 { + settings.ChallengeTTL = time.Duration(ttlSeconds) * time.Second + } + } + if val, ok := configs[ConfigKeyCapTokenTTL]; ok { + if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 { + settings.TokenTTL = time.Duration(ttlSeconds) * time.Second + } + } + + return settings +} diff --git a/backend/plugins/domain/cap/service.go b/backend/plugins/domain/auth/cap_service.go similarity index 71% rename from backend/plugins/domain/cap/service.go rename to backend/plugins/domain/auth/cap_service.go index b062fffe..9e7e0a41 100644 --- a/backend/plugins/domain/cap/service.go +++ b/backend/plugins/domain/auth/cap_service.go @@ -1,11 +1,10 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -// Package cap provides CAPTCHA and proof-of-work (PoW) verification services. -package cap +package auth import ( - "Wavelet/plugins/domain/cap/pow" + "Wavelet/plugins/domain/auth/pow" "context" "crypto/sha256" "encoding/hex" @@ -22,23 +21,23 @@ const ( valuePartsCount = 2 // 存储值由 scope 和过期时间组成 ) -// Manager orchestrates challenge generation and solution validation. -type Manager struct { +// CaptchaManager orchestrates challenge generation and solution validation. +type CaptchaManager struct { secret []byte store pow.Store } -// NewManager creates a new CAPTCHA Manager. -func NewManager(secret []byte, store pow.Store) *Manager { - return &Manager{ +// NewCaptchaManager creates a new CAPTCHA Manager. +func NewCaptchaManager(secret []byte, store pow.Store) *CaptchaManager { + return &CaptchaManager{ secret: secret, store: store, } } // Generate creates a challenge response. -func (m *Manager) Generate(ctx context.Context, scope string) (*pow.ChallengeResponse, error) { - settings, err := CurrentSettings(ctx) +func (m *CaptchaManager) Generate(ctx context.Context, scope string) (*pow.ChallengeResponse, error) { + settings, err := CurrentCapSettings(ctx) if err != nil { return nil, err } @@ -53,7 +52,7 @@ func (m *Manager) Generate(ctx context.Context, scope string) (*pow.ChallengeRes } // Redeem verifies PoW solutions and returns a one-time redeem token. -func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) { +func (m *CaptchaManager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) { sigHex := pow.JwtSigHex(token) if sigHex == "" { return &RedeemResponse{Success: false, Error: redeemErrInvalidToken}, nil @@ -80,7 +79,7 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco return &RedeemResponse{Success: false, Error: redeemErrAlreadyRedeemed}, nil } - settings, err := CurrentSettings(ctx) + settings, err := CurrentCapSettings(ctx) if err != nil { return &RedeemResponse{Success: false, Error: redeemErrSettingsLoad}, err } @@ -106,7 +105,7 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco } // VerifyToken validates and consumes the redeem token (single-use). -func (m *Manager) VerifyToken(ctx context.Context, token, expectedScope string) (bool, error) { +func (m *CaptchaManager) VerifyToken(ctx context.Context, token, expectedScope string) (bool, error) { if token == "" { return false, nil } @@ -160,23 +159,33 @@ func sGetAndDelete(ctx context.Context, store pow.Store, key string) (string, bo } var ( - defaultManagerMu sync.RWMutex - defaultManager *Manager + defaultCapManagerMu sync.RWMutex + defaultCapManager *CaptchaManager ) -// SetSecret sets the shared secret used by the default manager. -func SetSecret(secret []byte) { - defaultManagerMu.Lock() - defer defaultManagerMu.Unlock() +// SetCapSecret sets the shared secret used by the default CAPTCHA manager. +func SetCapSecret(secret []byte) { + defaultCapManagerMu.Lock() + defer defaultCapManagerMu.Unlock() if len(secret) > 0 { store := pow.NewMemoryStore(1 * time.Minute) - defaultManager = NewManager(secret, store) + defaultCapManager = NewCaptchaManager(secret, store) } } -// GetDefaultManager yields the global singleton CAPTCHA manager. -func GetDefaultManager() *Manager { - defaultManagerMu.RLock() - defer defaultManagerMu.RUnlock() - return defaultManager +// GetDefaultCapManager yields the global singleton CAPTCHA manager. +func GetDefaultCapManager() *CaptchaManager { + defaultCapManagerMu.RLock() + defer defaultCapManagerMu.RUnlock() + return defaultCapManager } + +type captchaService struct{} + +func (captchaService) VerifyMiddleware(scope string) any { + return VerifyCaptchaMiddleware(GetDefaultCapManager(), scope) +} + +func (captchaService) ChallengeHandler() any { return Challenge } + +func (captchaService) RedeemHandler() any { return Redeem } diff --git a/backend/plugins/domain/auth/plugin.go b/backend/plugins/domain/auth/plugin.go index 95e74c1e..8a1c81bd 100644 --- a/backend/plugins/domain/auth/plugin.go +++ b/backend/plugins/domain/auth/plugin.go @@ -85,6 +85,9 @@ func (p *Plugin) Apply(ctx *core.Context) error { var cfg SessionConfig if err := ctx.Config().Bind("app", &cfg); err == nil { SetSessionConfig(cfg) + if cfg.SessionSecret != "" { + SetCapSecret([]byte(cfg.SessionSecret)) + } } core.Bind[contracts.DBService](ctx, setDBService) @@ -100,7 +103,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { // 1. Register migrations ctx.Migrations().Register("auth", authMigrations) - // 2. Initialize and provide AuthService & AuthRegistry + // 2. Initialize and provide AuthService, AuthRegistry & CaptchaService if p.authSvc == nil { p.authSvc = newAuthService() } @@ -110,6 +113,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { core.Provide[contracts.AuthService](ctx, p.authSvc) core.Provide[contracts.AuthRegistry](ctx, p.authRegistry) + core.Provide[contracts.CaptchaService](ctx, captchaService{}) // 2.1 Register Public / Auth Whitelist Endpoints publicEndpoints := []string{ @@ -144,20 +148,47 @@ func (p *Plugin) Apply(ctx *core.Context) error { } ctx.Router().GET("/api/v1/user-info", LoginRequired(), UserInfo) + // 3.1 Register CAPTCHA HTTP Routes + capGroup := ctx.Router().Group("/api/v1/cap") + { + capGroup.GET("/challenge", Challenge) + capGroup.POST("/challenge", Challenge) + capGroup.POST("/redeem", Redeem) + } + // 4. Register Settings Schemas + const ( + settingTypeInteger = "integer" + settingCategorySecurity = "security" + ) + ctx.Settings().Register(extpoints.SettingSchema{ Key: "auth.session_age", Default: 86400 * 7, Description: "Default session lifetime in seconds", - Type: "integer", - Category: "security", + Type: settingTypeInteger, + Category: settingCategorySecurity, }) ctx.Settings().Register(extpoints.SettingSchema{ Key: "auth.login_rate_limit_max_attempts", Default: 5, Description: "Max login failure attempts before temporary IP lock", - Type: "integer", - Category: "security", + Type: settingTypeInteger, + Category: settingCategorySecurity, + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "cap.login_enabled", + Default: false, + Description: "Whether to require CAPTCHA verification for user login", + Type: "boolean", + Category: settingCategorySecurity, + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "cap.challenge_count", + Default: 1, + Description: "Number of PoW puzzle challenges to solve", + Type: settingTypeInteger, + Category: settingCategorySecurity, }) // 5. Register Event Listeners for domain events @@ -171,5 +202,9 @@ func (p *Plugin) Apply(ctx *core.Context) error { return nil }) + ctx.Events().On(contracts.EventTopicConfigChanged, func(_ any) { + InvalidateCapRuntimeSettings() + }) + return nil } diff --git a/backend/plugins/domain/auth/plugin_test.go b/backend/plugins/domain/auth/plugin_test.go index dcf09f36..62c10e98 100644 --- a/backend/plugins/domain/auth/plugin_test.go +++ b/backend/plugins/domain/auth/plugin_test.go @@ -161,4 +161,25 @@ func TestAuthPluginUnit(t *testing.T) { current, err := authSvc.GetCurrentUser(userCtx) require.NoError(t, err) assert.Equal(t, user.ID, current.ID) + + // Test CaptchaService injection + capSvc, err := core.Inject[contracts.CaptchaService](ctx) + require.NoError(t, err) + assert.NotNil(t, capSvc) + assert.NotNil(t, capSvc.ChallengeHandler()) + assert.NotNil(t, capSvc.RedeemHandler()) + assert.NotNil(t, capSvc.VerifyMiddleware("login")) + + // Verify CAPTCHA routes registered + var foundChallenge, foundRedeem bool + for _, rd := range ctx.Router().Routes() { + if rd.Path == "/api/v1/cap/challenge" { + foundChallenge = true + } + if rd.Path == "/api/v1/cap/redeem" { + foundRedeem = true + } + } + assert.True(t, foundChallenge, "expected /api/v1/cap/challenge route") + assert.True(t, foundRedeem, "expected /api/v1/cap/redeem route") } diff --git a/backend/plugins/domain/cap/pow/cap.go b/backend/plugins/domain/auth/pow/cap.go similarity index 100% rename from backend/plugins/domain/cap/pow/cap.go rename to backend/plugins/domain/auth/pow/cap.go diff --git a/backend/plugins/domain/cap/pow/errs.go b/backend/plugins/domain/auth/pow/errs.go similarity index 100% rename from backend/plugins/domain/cap/pow/errs.go rename to backend/plugins/domain/auth/pow/errs.go diff --git a/backend/plugins/domain/cap/pow/pow_test.go b/backend/plugins/domain/auth/pow/pow_test.go similarity index 100% rename from backend/plugins/domain/cap/pow/pow_test.go rename to backend/plugins/domain/auth/pow/pow_test.go diff --git a/backend/plugins/domain/cap/pow/prng.go b/backend/plugins/domain/auth/pow/prng.go similarity index 100% rename from backend/plugins/domain/cap/pow/prng.go rename to backend/plugins/domain/auth/pow/prng.go diff --git a/backend/plugins/domain/cap/pow/store.go b/backend/plugins/domain/auth/pow/store.go similarity index 100% rename from backend/plugins/domain/cap/pow/store.go rename to backend/plugins/domain/auth/pow/store.go diff --git a/backend/plugins/domain/cap/plugin.go b/backend/plugins/domain/cap/plugin.go deleted file mode 100644 index 148b02ed..00000000 --- a/backend/plugins/domain/cap/plugin.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package cap provides the proof-of-work (PoW) CAPTCHA verification domain plugin for Cordis. -package cap - -import ( - "Wavelet/core" - "Wavelet/core/contracts" - "Wavelet/core/extpoints" - "reflect" -) - -// Plugin implements core.Plugin to provide CAPTCHA generation, validation, and route protection. -type Plugin struct{} - -// New creates a new cap domain plugin. -func New() *Plugin { - return &Plugin{} -} - -// Name returns the unique identifier for the cap domain plugin. -func (p *Plugin) Name() string { - return "cap" -} - -// Inject declares required dependencies for the cap domain plugin. -func (p *Plugin) Inject() []reflect.Type { - return []reflect.Type{ - reflect.TypeFor[contracts.DBService](), - } -} - -// Manifest returns the plugin metadata. -func (p *Plugin) Manifest() core.Manifest { - return core.Manifest{ - Name: "cap", - Version: "1.0.0", - Description: "Proof-of-work CAPTCHA challenge and verification domain plugin", - Author: "Wavelet Team", - } -} - -type capAppConfig struct { - SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` -} - -// DeclareConfig declares configuration bindings for the cap plugin. -func (p *Plugin) DeclareConfig() []core.ConfigBinding { - return []core.ConfigBinding{ - {Prefix: "app", Target: &capAppConfig{}}, - } -} - -// Apply registers the cap routes and settings into the Context. -func (p *Plugin) Apply(ctx *core.Context) error { - var cfg capAppConfig - if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" { - SetSecret([]byte(cfg.SessionSecret)) - } - - core.Bind[contracts.DBService](ctx, setDBService) - ctx.OnDispose(func() error { - setDBService(nil) - return nil - }) - - // Listen to system config changed events to invalidate cached settings - ctx.Events().On(contracts.EventTopicConfigChanged, func(_ any) { - InvalidateRuntimeSettings() - }) - - core.Provide[contracts.CaptchaService](ctx, captchaService{}) - - // Register HTTP Routes - capGroup := ctx.Router().Group("/api/v1/cap") - { - capGroup.GET("/challenge", Challenge) - capGroup.POST("/challenge", Challenge) - capGroup.POST("/redeem", Redeem) - } - ctx.Router().RegisterWhitelist("/api/v1/cap/challenge", "/api/v1/cap/redeem") - - // Register Settings Schemas - ctx.Settings().Register(extpoints.SettingSchema{ - Key: "cap.login_enabled", - Default: false, - Description: "Whether to require CAPTCHA verification for user login", - Type: "boolean", - Category: "security", - }) - ctx.Settings().Register(extpoints.SettingSchema{ - Key: "cap.challenge_count", - Default: 1, - Description: "Number of PoW puzzle challenges to solve", - Type: "integer", - Category: "security", - }) - - return nil -} - -type captchaService struct{} - -func (captchaService) VerifyMiddleware(scope string) any { - return VerifyMiddleware(GetDefaultManager(), scope) -} - -func (captchaService) ChallengeHandler() any { return Challenge } - -func (captchaService) RedeemHandler() any { return Redeem } diff --git a/backend/plugins/domain/cap/plugin_captcha_contract_test.go b/backend/plugins/domain/cap/plugin_captcha_contract_test.go deleted file mode 100644 index 7577a31d..00000000 --- a/backend/plugins/domain/cap/plugin_captcha_contract_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "context" - "testing" - - "Wavelet/core" - "Wavelet/core/contracts" -) - -func TestApplyProvidesCaptchaService(t *testing.T) { - ctx := core.NewContext(context.Background()) - if err := New().Apply(ctx); err != nil { - t.Fatal(err) - } - svc, err := core.Inject[contracts.CaptchaService](ctx) - if err != nil || svc == nil { - t.Fatalf("Inject CaptchaService: svc=%v err=%v", svc, err) - } - if svc.ChallengeHandler() == nil || svc.RedeemHandler() == nil { - t.Fatal("handlers must be non-nil") - } - if svc.VerifyMiddleware("login") == nil { - t.Fatal("VerifyMiddleware(login) must be non-nil") - } -} - -func TestApplyRegistersUnversionedCapRoutes(t *testing.T) { - ctx := core.NewContext(context.Background()) - if err := New().Apply(ctx); err != nil { - t.Fatal(err) - } - want := map[string]bool{ - "GET /api/v1/cap/challenge": false, - "POST /api/v1/cap/challenge": false, - "POST /api/v1/cap/redeem": false, - } - for _, rd := range ctx.Router().Routes() { - key := rd.Method + " " + rd.Path - if _, ok := want[key]; ok { - want[key] = true - } - if key == "POST /api/cap/challenge" || key == "POST /api/cap/redeem" { - t.Errorf("legacy route must not exist: %s", key) - } - } - for key, ok := range want { - if !ok { - t.Errorf("missing route %s", key) - } - } -} diff --git a/backend/plugins/domain/cap/repository.go b/backend/plugins/domain/cap/repository.go deleted file mode 100644 index ad9c26f2..00000000 --- a/backend/plugins/domain/cap/repository.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "Wavelet/core" - "Wavelet/core/contracts" - "context" - "sync" - - "gorm.io/gorm" -) - -var ( - dbMu sync.RWMutex - dbSvc contracts.DBService -) - -// setDBService caches the DBService contract used by the persistence layer. -func setDBService(s contracts.DBService) { - dbMu.Lock() - defer dbMu.Unlock() - dbSvc = s -} - -// getDB resolves a GORM handle from the request/app context, then the Bind fallback. -func getDB(ctx context.Context) *gorm.DB { - if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { - return s.DB(ctx) - } - dbMu.RLock() - s := dbSvc - dbMu.RUnlock() - if s != nil { - return s.DB(ctx) - } - return nil -} - -// loadRuntimeSettings reads the CAPTCHA owned rows from the system config table. -func loadRuntimeSettings(ctx context.Context) (RuntimeSettings, error) { - var records []configRecord - db := getDB(ctx) - if db == nil { - return parseRuntimeSettings(nil), nil - } - if err := db.Table("w_system_configs").Where("key IN ?", runtimeConfigKeys).Find(&records).Error; err != nil { - return RuntimeSettings{}, err - } - configs := make(map[string]string, len(records)) - for _, r := range records { - configs[r.Key] = r.Value - } - return parseRuntimeSettings(configs), nil -} diff --git a/backend/plugins/domain/cap/runtime_settings.go b/backend/plugins/domain/cap/runtime_settings.go deleted file mode 100644 index ebb6fd3e..00000000 --- a/backend/plugins/domain/cap/runtime_settings.go +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cap - -import ( - "context" - "errors" - "strconv" - "sync/atomic" - "time" - - "golang.org/x/sync/singleflight" -) - -const ( - defaultChallengeCount = 1 - defaultChallengeSize = 32 - defaultChallengeDifficulty = 4 - defaultChallengeTTL = 10 * time.Minute - defaultTokenTTL = 20 * time.Minute -) - -// RuntimeSettings is the parsed CAPTCHA runtime configuration loaded from system_configs. -type RuntimeSettings struct { - LoginEnabled bool - ChallengeCount int - ChallengeSize int - ChallengeDifficulty int - ChallengeTTL time.Duration - TokenTTL time.Duration -} - -// CAP 动态配置键常量 -const ( - ConfigKeyCapLoginEnabled = "cap_login_enabled" - ConfigKeyCapChallengeCount = "cap_challenge_count" - ConfigKeyCapChallengeSize = "cap_challenge_size" - ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" - ConfigKeyCapChallengeTTL = "cap_challenge_ttl" - // ConfigKeyCapTokenTTL 验证码 Token 过期时间键 - // #nosec G101 - ConfigKeyCapTokenTTL = "cap_token_ttl" -) - -var runtimeConfigKeys = []string{ - ConfigKeyCapLoginEnabled, - ConfigKeyCapChallengeCount, - ConfigKeyCapChallengeSize, - ConfigKeyCapChallengeDifficulty, - ConfigKeyCapChallengeTTL, - ConfigKeyCapTokenTTL, -} - -var runtimeConfigKeySet = func() map[string]struct{} { - set := make(map[string]struct{}, len(runtimeConfigKeys)) - for _, key := range runtimeConfigKeys { - set[key] = struct{}{} - } - return set -}() - -type runtimeSettingsStore struct { - snapshot atomic.Pointer[RuntimeSettings] - loadGroup singleflight.Group -} - -var settingsStore = &runtimeSettingsStore{} - -// IsRuntimeConfigKey reports whether a system config key affects CAPTCHA runtime settings. -func IsRuntimeConfigKey(key string) bool { - _, ok := runtimeConfigKeySet[key] - return ok -} - -// CurrentSettings returns the cached CAPTCHA runtime settings snapshot. -func CurrentSettings(ctx context.Context) (RuntimeSettings, error) { - return settingsStore.current(ctx) -} - -// ProtectionEnabled reports whether CAPTCHA verification is required for protected routes. -func ProtectionEnabled(ctx context.Context) bool { - settings, err := CurrentSettings(ctx) - if err != nil { - return false - } - return settings.LoginEnabled -} - -// InvalidateRuntimeSettings drops the in-process CAPTCHA settings snapshot. -func InvalidateRuntimeSettings() { - settingsStore.snapshot.Store(nil) -} - -// ResetRuntimeSettingsForTest clears the CAPTCHA runtime snapshot. -func ResetRuntimeSettingsForTest() { - InvalidateRuntimeSettings() -} - -// InstallTestRuntimeSettings installs a fixed snapshot for unit tests. -func InstallTestRuntimeSettings(settings RuntimeSettings) func() { - snapshot := settings - settingsStore.snapshot.Store(&snapshot) - return InvalidateRuntimeSettings -} - -func (s *runtimeSettingsStore) current(ctx context.Context) (RuntimeSettings, error) { - s.ensureInvalidationListener() - - if snapshot := s.snapshot.Load(); snapshot != nil { - return *snapshot, nil - } - - loaded, err, _ := s.loadGroup.Do("cap-runtime-settings", func() (any, error) { - if snapshot := s.snapshot.Load(); snapshot != nil { - return *snapshot, nil - } - - settings, loadErr := loadRuntimeSettings(ctx) - if loadErr != nil { - return RuntimeSettings{}, loadErr - } - - s.snapshot.Store(&settings) - return settings, nil - }) - if err != nil { - return RuntimeSettings{}, err - } - - settings, ok := loaded.(RuntimeSettings) - if !ok { - return RuntimeSettings{}, errors.New("cap runtime settings loader returned unexpected type") - } - return settings, nil -} - -func parseRuntimeSettings(configs map[string]string) RuntimeSettings { - settings := RuntimeSettings{ - ChallengeCount: defaultChallengeCount, - ChallengeSize: defaultChallengeSize, - ChallengeDifficulty: defaultChallengeDifficulty, - ChallengeTTL: defaultChallengeTTL, - TokenTTL: defaultTokenTTL, - } - - if len(configs) == 0 { - return settings - } - - if val, ok := configs[ConfigKeyCapLoginEnabled]; ok { - if enabled, err := strconv.ParseBool(val); err == nil { - settings.LoginEnabled = enabled - } - } - if val, ok := configs[ConfigKeyCapChallengeCount]; ok { - if count, err := strconv.Atoi(val); err == nil && count > 0 { - settings.ChallengeCount = count - } - } - if val, ok := configs[ConfigKeyCapChallengeSize]; ok { - if size, err := strconv.Atoi(val); err == nil && size > 0 { - settings.ChallengeSize = size - } - } - if val, ok := configs[ConfigKeyCapChallengeDifficulty]; ok { - if diff, err := strconv.Atoi(val); err == nil && diff > 0 { - settings.ChallengeDifficulty = diff - } - } - if val, ok := configs[ConfigKeyCapChallengeTTL]; ok { - if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 { - settings.ChallengeTTL = time.Duration(ttlSeconds) * time.Second - } - } - if val, ok := configs[ConfigKeyCapTokenTTL]; ok { - if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 { - settings.TokenTTL = time.Duration(ttlSeconds) * time.Second - } - } - - return settings -} - -func (s *runtimeSettingsStore) ensureInvalidationListener() {}