mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 02:06:37 +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/core/contracts"
|
||||||
"Wavelet/plugins/domain/admin"
|
"Wavelet/plugins/domain/admin"
|
||||||
"Wavelet/plugins/domain/auth"
|
"Wavelet/plugins/domain/auth"
|
||||||
"Wavelet/plugins/domain/cap"
|
|
||||||
"Wavelet/plugins/domain/msg_gateway"
|
"Wavelet/plugins/domain/msg_gateway"
|
||||||
"Wavelet/plugins/domain/risk_control"
|
"Wavelet/plugins/domain/risk_control"
|
||||||
"Wavelet/plugins/domain/system"
|
"Wavelet/plugins/domain/system"
|
||||||
@@ -100,7 +99,7 @@ func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App {
|
|||||||
driver_inproc_cron.New(),
|
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(
|
app.Use(
|
||||||
admin.New(),
|
admin.New(),
|
||||||
user.New(),
|
user.New(),
|
||||||
@@ -108,7 +107,6 @@ func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App {
|
|||||||
msg_gateway.New(),
|
msg_gateway.New(),
|
||||||
risk_control.New(),
|
risk_control.New(),
|
||||||
upload.New(),
|
upload.New(),
|
||||||
cap.New(),
|
|
||||||
system.New(),
|
system.New(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ func TestNewWaveletAppProfiles(t *testing.T) {
|
|||||||
require.NotNil(t, app)
|
require.NotNil(t, app)
|
||||||
assert.Equal(t, prof, app.Profile())
|
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()
|
plugins := app.Plugins()
|
||||||
assert.Len(t, plugins, 18)
|
assert.Len(t, plugins, 17)
|
||||||
|
|
||||||
require.NoError(t, app.Reconcile())
|
require.NoError(t, app.Reconcile())
|
||||||
|
|
||||||
@@ -94,9 +94,6 @@ func TestNewWaveletAppProfiles(t *testing.T) {
|
|||||||
_, ok = app.Plugin("upload")
|
_, ok = app.Plugin("upload")
|
||||||
assert.True(t, ok, "upload plugin missing")
|
assert.True(t, ok, "upload plugin missing")
|
||||||
|
|
||||||
_, ok = app.Plugin("cap")
|
|
||||||
assert.True(t, ok, "cap plugin missing")
|
|
||||||
|
|
||||||
_, ok = app.Plugin("system")
|
_, ok = app.Plugin("system")
|
||||||
assert.True(t, ok, "system plugin missing")
|
assert.True(t, ok, "system plugin missing")
|
||||||
|
|
||||||
|
|||||||
+29
-29
@@ -4397,7 +4397,7 @@ const docTemplate = `{
|
|||||||
"name": "request",
|
"name": "request",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/cap.challengeRequest"
|
"$ref": "#/definitions/auth.challengeRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4413,7 +4413,7 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"$ref": "#/definitions/cap.ChallengeResponse"
|
"$ref": "#/definitions/auth.ChallengeResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4446,7 +4446,7 @@ const docTemplate = `{
|
|||||||
"name": "request",
|
"name": "request",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/cap.challengeRequest"
|
"$ref": "#/definitions/auth.challengeRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4462,7 +4462,7 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"$ref": "#/definitions/cap.ChallengeResponse"
|
"$ref": "#/definitions/auth.ChallengeResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4498,7 +4498,7 @@ const docTemplate = `{
|
|||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/cap.redeemRequest"
|
"$ref": "#/definitions/auth.redeemRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4514,7 +4514,7 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"$ref": "#/definitions/cap.RedeemResponse"
|
"$ref": "#/definitions/auth.RedeemResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6134,26 +6134,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth.OAuthAuthorizeResponse": {
|
"auth.ChallengeResponse": {
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"authorize_url": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth.OAuthCallbackResult": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"$ref": "#/definitions/auth.BasicUserInfo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cap.ChallengeResponse": {
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"challenge": {
|
"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",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"error": {
|
"error": {
|
||||||
@@ -6196,7 +6196,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cap.challengeRequest": {
|
"auth.challengeRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"scope": {
|
"scope": {
|
||||||
@@ -6204,7 +6204,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cap.redeemRequest": {
|
"auth.redeemRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"solutions",
|
"solutions",
|
||||||
|
|||||||
+29
-29
@@ -4390,7 +4390,7 @@
|
|||||||
"name": "request",
|
"name": "request",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/cap.challengeRequest"
|
"$ref": "#/definitions/auth.challengeRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4406,7 +4406,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"$ref": "#/definitions/cap.ChallengeResponse"
|
"$ref": "#/definitions/auth.ChallengeResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4439,7 +4439,7 @@
|
|||||||
"name": "request",
|
"name": "request",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/cap.challengeRequest"
|
"$ref": "#/definitions/auth.challengeRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4455,7 +4455,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"$ref": "#/definitions/cap.ChallengeResponse"
|
"$ref": "#/definitions/auth.ChallengeResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4491,7 +4491,7 @@
|
|||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/cap.redeemRequest"
|
"$ref": "#/definitions/auth.redeemRequest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -4507,7 +4507,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"$ref": "#/definitions/cap.RedeemResponse"
|
"$ref": "#/definitions/auth.RedeemResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6127,26 +6127,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth.OAuthAuthorizeResponse": {
|
"auth.ChallengeResponse": {
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"authorize_url": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth.OAuthCallbackResult": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"$ref": "#/definitions/auth.BasicUserInfo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cap.ChallengeResponse": {
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"challenge": {
|
"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",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"error": {
|
"error": {
|
||||||
@@ -6189,7 +6189,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cap.challengeRequest": {
|
"auth.challengeRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"scope": {
|
"scope": {
|
||||||
@@ -6197,7 +6197,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cap.redeemRequest": {
|
"auth.redeemRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"solutions",
|
"solutions",
|
||||||
|
|||||||
+22
-22
@@ -55,19 +55,7 @@ definitions:
|
|||||||
- code
|
- code
|
||||||
- state
|
- state
|
||||||
type: object
|
type: object
|
||||||
auth.OAuthAuthorizeResponse:
|
auth.ChallengeResponse:
|
||||||
properties:
|
|
||||||
authorize_url:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
auth.OAuthCallbackResult:
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
user:
|
|
||||||
$ref: '#/definitions/auth.BasicUserInfo'
|
|
||||||
type: object
|
|
||||||
cap.ChallengeResponse:
|
|
||||||
properties:
|
properties:
|
||||||
challenge:
|
challenge:
|
||||||
properties:
|
properties:
|
||||||
@@ -84,7 +72,19 @@ definitions:
|
|||||||
token:
|
token:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
properties:
|
||||||
error:
|
error:
|
||||||
type: string
|
type: string
|
||||||
@@ -95,12 +95,12 @@ definitions:
|
|||||||
token:
|
token:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
cap.challengeRequest:
|
auth.challengeRequest:
|
||||||
properties:
|
properties:
|
||||||
scope:
|
scope:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
cap.redeemRequest:
|
auth.redeemRequest:
|
||||||
properties:
|
properties:
|
||||||
scope:
|
scope:
|
||||||
type: string
|
type: string
|
||||||
@@ -4031,7 +4031,7 @@ paths:
|
|||||||
in: body
|
in: body
|
||||||
name: request
|
name: request
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/cap.challengeRequest'
|
$ref: '#/definitions/auth.challengeRequest'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -4042,7 +4042,7 @@ paths:
|
|||||||
- $ref: '#/definitions/response.Any'
|
- $ref: '#/definitions/response.Any'
|
||||||
- properties:
|
- properties:
|
||||||
data:
|
data:
|
||||||
$ref: '#/definitions/cap.ChallengeResponse'
|
$ref: '#/definitions/auth.ChallengeResponse'
|
||||||
type: object
|
type: object
|
||||||
"500":
|
"500":
|
||||||
description: 内部服务错误
|
description: 内部服务错误
|
||||||
@@ -4060,7 +4060,7 @@ paths:
|
|||||||
in: body
|
in: body
|
||||||
name: request
|
name: request
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/cap.challengeRequest'
|
$ref: '#/definitions/auth.challengeRequest'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -4071,7 +4071,7 @@ paths:
|
|||||||
- $ref: '#/definitions/response.Any'
|
- $ref: '#/definitions/response.Any'
|
||||||
- properties:
|
- properties:
|
||||||
data:
|
data:
|
||||||
$ref: '#/definitions/cap.ChallengeResponse'
|
$ref: '#/definitions/auth.ChallengeResponse'
|
||||||
type: object
|
type: object
|
||||||
"500":
|
"500":
|
||||||
description: 内部服务错误
|
description: 内部服务错误
|
||||||
@@ -4091,7 +4091,7 @@ paths:
|
|||||||
name: request
|
name: request
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/cap.redeemRequest'
|
$ref: '#/definitions/auth.redeemRequest'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -4102,7 +4102,7 @@ paths:
|
|||||||
- $ref: '#/definitions/response.Any'
|
- $ref: '#/definitions/response.Any'
|
||||||
- properties:
|
- properties:
|
||||||
data:
|
data:
|
||||||
$ref: '#/definitions/cap.RedeemResponse'
|
$ref: '#/definitions/auth.RedeemResponse'
|
||||||
type: object
|
type: object
|
||||||
"400":
|
"400":
|
||||||
description: 参数错误或核销失败
|
description: 参数错误或核销失败
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// Copyright 2026 Arctel.net
|
// Copyright 2026 Arctel.net
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
// Package cap 提供人机验证中间件
|
package auth
|
||||||
package cap
|
|
||||||
|
|
||||||
// HTTP 响应错误文案
|
// HTTP 响应错误文案
|
||||||
const (
|
const (
|
||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 Arctel.net
|
// Copyright 2026 Arctel.net
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package cap
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/logger"
|
"Wavelet/pkg/logger"
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body challengeRequest false "可选范围限制参数"
|
// @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 "内部服务错误"
|
// @Failure 500 {object} response.Any "内部服务错误"
|
||||||
// @Router /api/v1/cap/challenge [get]
|
// @Router /api/v1/cap/challenge [get]
|
||||||
// @Router /api/v1/cap/challenge [post]
|
// @Router /api/v1/cap/challenge [post]
|
||||||
@@ -30,7 +30,7 @@ func Challenge(c *gin.Context) {
|
|||||||
req.Scope = "login"
|
req.Scope = "login"
|
||||||
}
|
}
|
||||||
|
|
||||||
mgr := GetDefaultManager()
|
mgr := GetDefaultCapManager()
|
||||||
if mgr == nil {
|
if mgr == nil {
|
||||||
response.AbortInternal(c, errCapNotConfigured)
|
response.AbortInternal(c, errCapNotConfigured)
|
||||||
return
|
return
|
||||||
@@ -52,7 +52,7 @@ func Challenge(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body redeemRequest true "难题 Token 与解答 solutions 数组"
|
// @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 400 {object} response.Any "参数错误或核销失败"
|
||||||
// @Failure 500 {object} response.Any "内部服务错误"
|
// @Failure 500 {object} response.Any "内部服务错误"
|
||||||
// @Router /api/v1/cap/redeem [post]
|
// @Router /api/v1/cap/redeem [post]
|
||||||
@@ -67,7 +67,7 @@ func Redeem(c *gin.Context) {
|
|||||||
req.Scope = "login"
|
req.Scope = "login"
|
||||||
}
|
}
|
||||||
|
|
||||||
mgr := GetDefaultManager()
|
mgr := GetDefaultCapManager()
|
||||||
if mgr == nil {
|
if mgr == nil {
|
||||||
response.AbortInternal(c, errCapNotConfigured)
|
response.AbortInternal(c, errCapNotConfigured)
|
||||||
return
|
return
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 Arctel.net
|
// Copyright 2026 Arctel.net
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package cap
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/response"
|
"Wavelet/pkg/response"
|
||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header.
|
// VerifyCaptchaMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header.
|
||||||
func VerifyMiddleware(mgr *Manager, scope string) gin.HandlerFunc {
|
func VerifyCaptchaMiddleware(mgr *CaptchaManager, scope string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if !ProtectionEnabled(c.Request.Context()) {
|
if !CapProtectionEnabled(c.Request.Context()) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 Arctel.net
|
// Copyright 2026 Arctel.net
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package cap
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/response"
|
"Wavelet/pkg/response"
|
||||||
@@ -14,12 +14,12 @@ import (
|
|||||||
|
|
||||||
func TestVerifyMiddlewareMissingTokenIsBadRequest(t *testing.T) {
|
func TestVerifyMiddlewareMissingTokenIsBadRequest(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
restore := InstallTestRuntimeSettings(RuntimeSettings{LoginEnabled: true})
|
restore := InstallCapTestRuntimeSettings(CapRuntimeSettings{LoginEnabled: true})
|
||||||
t.Cleanup(restore)
|
t.Cleanup(restore)
|
||||||
|
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
engine.Use(response.ErrorHandlerMiddleware())
|
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)
|
c.Status(http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -27,6 +27,6 @@ func TestVerifyMiddlewareMissingTokenIsBadRequest(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
engine.ServeHTTP(rec, req)
|
engine.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusBadRequest {
|
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
|
// Copyright 2026 Arctel.net
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package cap
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/plugins/domain/cap/pow"
|
"Wavelet/plugins/domain/auth/pow"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChallengeResponse is a local type alias for the pow.ChallengeResponse struct
|
// ChallengeResponse is a local type alias for the pow.ChallengeResponse struct
|
||||||
@@ -30,8 +30,8 @@ type RedeemResponse struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// configRecord maps the columns selected from the system config table.
|
// capConfigRecord maps the columns selected from the system config table.
|
||||||
type configRecord struct {
|
type capConfigRecord struct {
|
||||||
Key string `gorm:"column:key"`
|
Key string `gorm:"column:key"`
|
||||||
Value string `gorm:"column:value"`
|
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
|
// Copyright 2026 Arctel.net
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
// Package cap provides CAPTCHA and proof-of-work (PoW) verification services.
|
package auth
|
||||||
package cap
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/plugins/domain/cap/pow"
|
"Wavelet/plugins/domain/auth/pow"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -22,23 +21,23 @@ const (
|
|||||||
valuePartsCount = 2 // 存储值由 scope 和过期时间组成
|
valuePartsCount = 2 // 存储值由 scope 和过期时间组成
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager orchestrates challenge generation and solution validation.
|
// CaptchaManager orchestrates challenge generation and solution validation.
|
||||||
type Manager struct {
|
type CaptchaManager struct {
|
||||||
secret []byte
|
secret []byte
|
||||||
store pow.Store
|
store pow.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new CAPTCHA Manager.
|
// NewCaptchaManager creates a new CAPTCHA Manager.
|
||||||
func NewManager(secret []byte, store pow.Store) *Manager {
|
func NewCaptchaManager(secret []byte, store pow.Store) *CaptchaManager {
|
||||||
return &Manager{
|
return &CaptchaManager{
|
||||||
secret: secret,
|
secret: secret,
|
||||||
store: store,
|
store: store,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate creates a challenge response.
|
// Generate creates a challenge response.
|
||||||
func (m *Manager) Generate(ctx context.Context, scope string) (*pow.ChallengeResponse, error) {
|
func (m *CaptchaManager) Generate(ctx context.Context, scope string) (*pow.ChallengeResponse, error) {
|
||||||
settings, err := CurrentSettings(ctx)
|
settings, err := CurrentCapSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// 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)
|
sigHex := pow.JwtSigHex(token)
|
||||||
if sigHex == "" {
|
if sigHex == "" {
|
||||||
return &RedeemResponse{Success: false, Error: redeemErrInvalidToken}, nil
|
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
|
return &RedeemResponse{Success: false, Error: redeemErrAlreadyRedeemed}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := CurrentSettings(ctx)
|
settings, err := CurrentCapSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &RedeemResponse{Success: false, Error: redeemErrSettingsLoad}, err
|
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).
|
// 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 == "" {
|
if token == "" {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -160,23 +159,33 @@ func sGetAndDelete(ctx context.Context, store pow.Store, key string) (string, bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
defaultManagerMu sync.RWMutex
|
defaultCapManagerMu sync.RWMutex
|
||||||
defaultManager *Manager
|
defaultCapManager *CaptchaManager
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetSecret sets the shared secret used by the default manager.
|
// SetCapSecret sets the shared secret used by the default CAPTCHA manager.
|
||||||
func SetSecret(secret []byte) {
|
func SetCapSecret(secret []byte) {
|
||||||
defaultManagerMu.Lock()
|
defaultCapManagerMu.Lock()
|
||||||
defer defaultManagerMu.Unlock()
|
defer defaultCapManagerMu.Unlock()
|
||||||
if len(secret) > 0 {
|
if len(secret) > 0 {
|
||||||
store := pow.NewMemoryStore(1 * time.Minute)
|
store := pow.NewMemoryStore(1 * time.Minute)
|
||||||
defaultManager = NewManager(secret, store)
|
defaultCapManager = NewCaptchaManager(secret, store)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultManager yields the global singleton CAPTCHA manager.
|
// GetDefaultCapManager yields the global singleton CAPTCHA manager.
|
||||||
func GetDefaultManager() *Manager {
|
func GetDefaultCapManager() *CaptchaManager {
|
||||||
defaultManagerMu.RLock()
|
defaultCapManagerMu.RLock()
|
||||||
defer defaultManagerMu.RUnlock()
|
defer defaultCapManagerMu.RUnlock()
|
||||||
return defaultManager
|
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
|
var cfg SessionConfig
|
||||||
if err := ctx.Config().Bind("app", &cfg); err == nil {
|
if err := ctx.Config().Bind("app", &cfg); err == nil {
|
||||||
SetSessionConfig(cfg)
|
SetSessionConfig(cfg)
|
||||||
|
if cfg.SessionSecret != "" {
|
||||||
|
SetCapSecret([]byte(cfg.SessionSecret))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Bind[contracts.DBService](ctx, setDBService)
|
core.Bind[contracts.DBService](ctx, setDBService)
|
||||||
@@ -100,7 +103,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
|||||||
// 1. Register migrations
|
// 1. Register migrations
|
||||||
ctx.Migrations().Register("auth", authMigrations)
|
ctx.Migrations().Register("auth", authMigrations)
|
||||||
|
|
||||||
// 2. Initialize and provide AuthService & AuthRegistry
|
// 2. Initialize and provide AuthService, AuthRegistry & CaptchaService
|
||||||
if p.authSvc == nil {
|
if p.authSvc == nil {
|
||||||
p.authSvc = newAuthService()
|
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.AuthService](ctx, p.authSvc)
|
||||||
core.Provide[contracts.AuthRegistry](ctx, p.authRegistry)
|
core.Provide[contracts.AuthRegistry](ctx, p.authRegistry)
|
||||||
|
core.Provide[contracts.CaptchaService](ctx, captchaService{})
|
||||||
|
|
||||||
// 2.1 Register Public / Auth Whitelist Endpoints
|
// 2.1 Register Public / Auth Whitelist Endpoints
|
||||||
publicEndpoints := []string{
|
publicEndpoints := []string{
|
||||||
@@ -144,20 +148,47 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
|||||||
}
|
}
|
||||||
ctx.Router().GET("/api/v1/user-info", LoginRequired(), UserInfo)
|
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
|
// 4. Register Settings Schemas
|
||||||
|
const (
|
||||||
|
settingTypeInteger = "integer"
|
||||||
|
settingCategorySecurity = "security"
|
||||||
|
)
|
||||||
|
|
||||||
ctx.Settings().Register(extpoints.SettingSchema{
|
ctx.Settings().Register(extpoints.SettingSchema{
|
||||||
Key: "auth.session_age",
|
Key: "auth.session_age",
|
||||||
Default: 86400 * 7,
|
Default: 86400 * 7,
|
||||||
Description: "Default session lifetime in seconds",
|
Description: "Default session lifetime in seconds",
|
||||||
Type: "integer",
|
Type: settingTypeInteger,
|
||||||
Category: "security",
|
Category: settingCategorySecurity,
|
||||||
})
|
})
|
||||||
ctx.Settings().Register(extpoints.SettingSchema{
|
ctx.Settings().Register(extpoints.SettingSchema{
|
||||||
Key: "auth.login_rate_limit_max_attempts",
|
Key: "auth.login_rate_limit_max_attempts",
|
||||||
Default: 5,
|
Default: 5,
|
||||||
Description: "Max login failure attempts before temporary IP lock",
|
Description: "Max login failure attempts before temporary IP lock",
|
||||||
Type: "integer",
|
Type: settingTypeInteger,
|
||||||
Category: "security",
|
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
|
// 5. Register Event Listeners for domain events
|
||||||
@@ -171,5 +202,9 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ctx.Events().On(contracts.EventTopicConfigChanged, func(_ any) {
|
||||||
|
InvalidateCapRuntimeSettings()
|
||||||
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,4 +161,25 @@ func TestAuthPluginUnit(t *testing.T) {
|
|||||||
current, err := authSvc.GetCurrentUser(userCtx)
|
current, err := authSvc.GetCurrentUser(userCtx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, user.ID, current.ID)
|
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