mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
refactor(auth): merge cap domain plugin into auth
This commit is contained in:
+1
-3
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||
+5
-5
@@ -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
|
||||
+4
-4
@@ -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
|
||||
}
|
||||
+4
-4
@@ -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
|
||||
}
|
||||
+34
-25
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
Reference in New Issue
Block a user