refactor(auth): merge cap domain plugin into auth

This commit is contained in:
ryan
2026-09-03 08:58:34 +08:00
parent 6b28adfacf
commit 30ab8810cc
23 changed files with 393 additions and 544 deletions
+1 -3
View File
@@ -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(),
)
+2 -5
View File
@@ -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")
+29 -29
View File
@@ -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",
+29 -29
View File
@@ -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",
+22 -22
View File
@@ -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: 参数错误或核销失败
@@ -1,8 +1,7 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package cap 提供人机验证中间件
package cap
package auth
// HTTP 响应错误文案
const (
@@ -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
@@ -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
}
@@ -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)
}
}
@@ -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"`
}
@@ -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
}
@@ -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 }
+40 -5
View File
@@ -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
}
@@ -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")
}
-111
View File
@@ -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 }
@@ -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)
}
}
}
-56
View File
@@ -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
}
@@ -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() {}