cap 与系统信息

This commit is contained in:
ryan
2026-06-08 09:28:12 +08:00
parent 9d0f9f0576
commit 72d72810b2
33 changed files with 3324 additions and 98 deletions
+34
View File
@@ -0,0 +1,34 @@
package cap
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/util"
caputil "github.com/linux-do/credit/internal/util/cap"
)
// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header.
// enabledFunc is an optional callback allowing dynamic check of whether captcha protection is turned on.
func VerifyMiddleware(mgr *caputil.Manager, scope string, enabledFunc func() bool) gin.HandlerFunc {
return func(c *gin.Context) {
if enabledFunc != nil && !enabledFunc() {
c.Next()
return
}
token := c.GetHeader("X-Cap-Token")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码验证失败,缺少验证码凭证"))
return
}
valid, err := mgr.VerifyToken(c.Request.Context(), token, scope)
if err != nil || !valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("验证码校验失败或已过期,请重试"))
return
}
c.Next()
}
}
+92
View File
@@ -0,0 +1,92 @@
package cap
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/util/cap"
)
type challengeRequest struct {
Scope string `json:"scope" form:"scope"`
}
type redeemRequest struct {
Token string `json:"token" binding:"required"`
Solutions []int `json:"solutions" binding:"required"`
Scope string `json:"scope" form:"scope"`
}
// Challenge 生成 PoW 人机验证难题
// @Summary 生成人机验证难题
// @Description 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。
// @Tags cap
// @Accept json
// @Produce json
// @Param request body challengeRequest false "可选范围限制参数"
// @Success 200 {object} cap.ChallengeResponse "成功返回 PoW 难题"
// @Failure 500 {object} cap.RedeemResponse "内部服务错误"
// @Router /api/cap/challenge [post]
func Challenge(c *gin.Context) {
var req challengeRequest
_ = c.ShouldBind(&req) // 允许不传 body,默认使用 login scope
if req.Scope == "" {
req.Scope = "login"
}
mgr := cap.GetDefaultManager()
resp, err := mgr.Generate(c.Request.Context(), req.Scope)
if err != nil {
c.JSON(http.StatusInternalServerError, cap.RedeemResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, resp)
}
// Redeem 提交 PoW 解答并兑换一次性凭证 Token
// @Summary 校验人机验证解答
// @Description 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证
// @Tags cap
// @Accept json
// @Produce json
// @Param request body redeemRequest true "难题 Token 与解答 solutions 数组"
// @Success 200 {object} cap.RedeemResponse "核销成功,返回 X-Cap-Token"
// @Failure 400 {object} cap.RedeemResponse "参数错误或核销失败"
// @Failure 500 {object} cap.RedeemResponse "内部服务错误"
// @Router /api/cap/redeem [post]
func Redeem(c *gin.Context) {
var req redeemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, cap.RedeemResponse{
Success: false,
Error: "无效的参数",
})
return
}
if req.Scope == "" {
req.Scope = "login"
}
mgr := cap.GetDefaultManager()
resp, err := mgr.Redeem(c.Request.Context(), req.Token, req.Solutions, req.Scope)
if err != nil {
c.JSON(http.StatusInternalServerError, cap.RedeemResponse{
Success: false,
Error: err.Error(),
})
return
}
if !resp.Success {
c.JSON(http.StatusBadRequest, resp)
return
}
c.JSON(http.StatusOK, resp)
}
+132
View File
@@ -0,0 +1,132 @@
package cap
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/testhelper"
"github.com/linux-do/credit/internal/util"
capUtil "github.com/linux-do/credit/internal/util/cap"
)
func TestCapEndpointsAndMiddleware(t *testing.T) {
sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
gin.SetMode(gin.TestMode)
r := gin.New()
// Mount CAPTCHA API endpoints
capGroup := r.Group("/api/cap")
{
capGroup.POST("/challenge", Challenge)
capGroup.POST("/redeem", Redeem)
}
// Login endpoint with CAPTCHA middleware
r.POST("/api/v1/user/login", VerifyMiddleware(capUtil.GetDefaultManager(), "login", func() bool {
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
if err != nil {
return false
}
return enabled
}), func(c *gin.Context) {
c.JSON(http.StatusOK, util.OK("login success"))
})
// 1. Test challenge generation
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/cap/challenge", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
var challengeResp capUtil.ChallengeResponse
if err := json.Unmarshal(w.Body.Bytes(), &challengeResp); err != nil {
t.Fatalf("failed to unmarshal challenge response: %v", err)
}
if challengeResp.Token == "" {
t.Fatalf("expected token in challenge response")
}
// 2. Test login with CAPTCHA disabled (should pass)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK when CAPTCHA is disabled, got %d. Body: %s", w.Code, w.Body.String())
}
// 3. Enable CAPTCHA in DB
err := sqliteDB.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyCapLoginEnabled).Update("value", "true").Error
if err != nil {
t.Fatalf("failed to enable cap_login_enabled in DB: %v", err)
}
// Update cache
var sysCfg model.SystemConfig
sqliteDB.Where("key = ?", model.ConfigKeyCapLoginEnabled).First(&sysCfg)
_ = db.HSetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeyCapLoginEnabled, &sysCfg)
// 4. Test login with CAPTCHA enabled but no header (should be blocked)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized, got %d. Body: %s", w.Code, w.Body.String())
}
// 5. Solve the challenge
solutions := capUtil.Solve(challengeResp.Token, challengeResp.Challenge.C, challengeResp.Challenge.S, challengeResp.Challenge.D)
// 6. Redeem solutions
redeemReqPayload := redeemRequest{
Token: challengeResp.Token,
Solutions: solutions,
}
bodyBytes, _ := json.Marshal(redeemReqPayload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/api/cap/redeem", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK for redeem, got %d. Body: %s", w.Code, w.Body.String())
}
var redeemResp capUtil.RedeemResponse
if err := json.Unmarshal(w.Body.Bytes(), &redeemResp); err != nil {
t.Fatalf("failed to unmarshal redeem response: %v", err)
}
if !redeemResp.Success || redeemResp.Token == "" {
t.Fatalf("redeem failed or returned empty token: %+v", redeemResp)
}
// 7. Login with valid redeem token (should pass)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
req.Header.Set("X-Cap-Token", redeemResp.Token)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK with valid cap token, got %d. Body: %s", w.Code, w.Body.String())
}
// 8. Replay attack: Login with the same redeem token again (should be blocked as it is single-use)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
req.Header.Set("X-Cap-Token", redeemResp.Token)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized on replayed token, got %d. Body: %s", w.Code, w.Body.String())
}
}