Repository navigation
feat(captcha): add GeeTest and Corptcha channels with per-scene switches #6882
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
0b895b1
9fd8ca7
824bf95
c94b9cf
bab9ecf
dc1bf77
9eb5655
cf5425a
ebdb23f
43478a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package middleware | ||
|
|
||
| import ( | ||
| "github.com/QuantumNous/new-api/common" | ||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| // CaptchaScene 人机验证保护的应用场景。 | ||
| // 场景开关用于控制某类业务接口是否需要人机验证;未列出的场景(如签到)始终跟随渠道开关。 | ||
| type CaptchaScene string | ||
|
|
||
| const ( | ||
| CaptchaSceneLogin CaptchaScene = "login" | ||
| CaptchaSceneRegister CaptchaScene = "register" | ||
| CaptchaSceneReset CaptchaScene = "reset" | ||
| ) | ||
|
|
||
| func captchaSceneEnabled(scene CaptchaScene) bool { | ||
| switch scene { | ||
| case CaptchaSceneLogin: | ||
| return common.CaptchaLoginEnabled | ||
| case CaptchaSceneRegister: | ||
| return common.CaptchaRegisterEnabled | ||
| case CaptchaSceneReset: | ||
| return common.CaptchaResetEnabled | ||
| default: | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| // CaptchaCheckFor 机器人保护统一入口,按业务场景启用。 | ||
| // 目前支持 Cloudflare Turnstile、极验 GeeTest 与 Corptcha 三种渠道, | ||
| // 且同一时刻仅允许启用一个,按当前配置分发到对应渠道的校验中间件。 | ||
| func CaptchaCheckFor(scene CaptchaScene) gin.HandlerFunc { | ||
| return func(c *gin.Context) { | ||
| if !captchaSceneEnabled(scene) { | ||
| c.Next() | ||
| return | ||
| } | ||
| switch { | ||
| case common.CorptchaCheckEnabled: | ||
| CorptchaCheck(scene)(c) | ||
| case common.GeeTestCheckEnabled: | ||
| GeeTestCheck()(c) | ||
| case common.TurnstileCheckEnabled: | ||
| TurnstileCheck()(c) | ||
| default: | ||
| c.Next() | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| package middleware | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "encoding/json" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '1,240p' common/json.go
rg -n -C 2 'json\.(Marshal|Unmarshal)|common\.(Marshal|Unmarshal)' middleware/corptcha-check.goRepository: QuantumNous/new-api Length of output: 1626 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '1,120p' middleware/corptcha-check.go
rg -n 'common\.' middleware/corptcha-check.goRepository: QuantumNous/new-api Length of output: 3464 Use the repository JSON wrappers. Replace 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| "io" | ||
| "net/http" | ||
| "strconv" | ||
| "time" | ||
|
|
||
| "github.com/QuantumNous/new-api/common" | ||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| type corptchaVerifyResponse struct { | ||
| Success bool `json:"success"` | ||
| SiteID string `json:"siteId"` | ||
| Purpose string `json:"purpose"` | ||
| RiskLevel string `json:"riskLevel"` | ||
| ErrorCode string `json:"errorCode"` | ||
| Message string `json:"message"` | ||
| } | ||
|
|
||
| // CorptchaCheck Corptcha 人机验证二次校验: | ||
| // 前端验证通过后返回一次性令牌 token,服务端携带站点密钥请求验证服务核验, | ||
| // 令牌只能被成功核验一次,重复使用会返回 TOKEN_NOT_FOUND。 | ||
| func CorptchaCheck(scene CaptchaScene) gin.HandlerFunc { | ||
| return func(c *gin.Context) { | ||
| if !common.CorptchaCheckEnabled { | ||
| c.Next() | ||
| return | ||
| } | ||
| token := c.Query("corptcha") | ||
| if token == "" { | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": "Corptcha 校验参数缺失,请刷新重试!", | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| payload, err := json.Marshal(map[string]string{ | ||
| "token": token, | ||
| "purpose": string(scene), | ||
| "siteKey": common.CorptchaSiteId, | ||
| }) | ||
| if err != nil { | ||
| common.SysLog(err.Error()) | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": err.Error(), | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| verifyURL := common.CorptchaApiServer + "/v1/verify" | ||
| req, err := http.NewRequest(http.MethodPost, verifyURL, bytes.NewReader(payload)) | ||
| if err != nil { | ||
| common.SysLog(err.Error()) | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": err.Error(), | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| req.Header.Set("content-type", "application/json") | ||
| req.Header.Set("authorization", "Bearer "+common.CorptchaSecret) | ||
| client := &http.Client{Timeout: 5 * time.Second} | ||
| rawRes, err := client.Do(req) | ||
| if err != nil { | ||
| common.SysLog(err.Error()) | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": err.Error(), | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| defer rawRes.Body.Close() | ||
| rawBody, err := io.ReadAll(rawRes.Body) | ||
| if err != nil { | ||
| common.SysLog(err.Error()) | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": err.Error(), | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| var res corptchaVerifyResponse | ||
| err = json.Unmarshal(rawBody, &res) | ||
| if err != nil { | ||
| common.SysLog(err.Error()) | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": err.Error(), | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| if rawRes.StatusCode < http.StatusOK || rawRes.StatusCode >= http.StatusMultipleChoices || !res.Success { | ||
| code := res.ErrorCode | ||
| if code == "" { | ||
| code = res.Message | ||
| } | ||
| common.SysLog("Corptcha verify failed: status=" + strconv.Itoa(rawRes.StatusCode) + " code=" + code + " body=" + string(rawBody)) | ||
| msg := "Corptcha 校验失败,请刷新重试!" | ||
| if code != "" { | ||
| msg = "Corptcha 校验失败(" + code + "),请刷新重试!" | ||
| } | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": msg, | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| c.Next() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| package middleware | ||
|
|
||
| import ( | ||
| "crypto/hmac" | ||
| "crypto/sha256" | ||
| "encoding/hex" | ||
| "net/http" | ||
| "net/url" | ||
| "time" | ||
|
|
||
| "github.com/QuantumNous/new-api/common" | ||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| type geeTestValidateResponse struct { | ||
| Status string `json:"status"` | ||
| Result string `json:"result"` | ||
| Reason string `json:"reason"` | ||
| } | ||
|
|
||
| // GeeTestCheck 极验行为验证(第四代)二次校验: | ||
| // 前端验证通过后携带 lot_number / captcha_output / pass_token / gen_time, | ||
| // 服务端使用 captcha_key 对 lot_number 计算 HMAC-SHA256 签名后请求极验 validate 接口校验。 | ||
| func GeeTestCheck() gin.HandlerFunc { | ||
| return func(c *gin.Context) { | ||
| if !common.GeeTestCheckEnabled { | ||
| c.Next() | ||
| return | ||
| } | ||
| lotNumber := c.Query("lot_number") | ||
| captchaOutput := c.Query("captcha_output") | ||
| passToken := c.Query("pass_token") | ||
| genTime := c.Query("gen_time") | ||
| if lotNumber == "" || captchaOutput == "" || passToken == "" || genTime == "" { | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": "GeeTest 校验参数缺失,请刷新重试!", | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| mac := hmac.New(sha256.New, []byte(common.GeeTestCaptchaKey)) | ||
| mac.Write([]byte(lotNumber)) | ||
| signToken := hex.EncodeToString(mac.Sum(nil)) | ||
|
|
||
| validateURL := common.GeeTestApiServer + "/validate?captcha_id=" + url.QueryEscape(common.GeeTestCaptchaId) | ||
| client := &http.Client{Timeout: 5 * time.Second} | ||
| rawRes, err := client.PostForm(validateURL, url.Values{ | ||
| "lot_number": {lotNumber}, | ||
| "captcha_output": {captchaOutput}, | ||
| "pass_token": {passToken}, | ||
| "gen_time": {genTime}, | ||
| "sign_token": {signToken}, | ||
| }) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| if err != nil { | ||
| common.SysLog(err.Error()) | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": err.Error(), | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| defer rawRes.Body.Close() | ||
| var res geeTestValidateResponse | ||
| err = common.DecodeJson(rawRes.Body, &res) | ||
| if err != nil { | ||
| common.SysLog(err.Error()) | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": err.Error(), | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| if res.Result != "success" { | ||
| common.SysLog("GeeTest validate failed: " + res.Reason) | ||
| c.JSON(http.StatusOK, gin.H{ | ||
| "success": false, | ||
| "message": "GeeTest 校验失败,请刷新重试!", | ||
| }) | ||
| c.Abort() | ||
| return | ||
| } | ||
| c.Next() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ package middleware | |
| import ( | ||
| "net/http" | ||
| "net/url" | ||
| "time" | ||
|
|
||
| "github.com/QuantumNous/new-api/common" | ||
| "github.com/gin-gonic/gin" | ||
|
|
@@ -24,7 +25,8 @@ func TurnstileCheck() gin.HandlerFunc { | |
| c.Abort() | ||
| return | ||
| } | ||
| rawRes, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{ | ||
| client := &http.Client{Timeout: 5 * time.Second} | ||
| rawRes, err := client.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{ | ||
|
Comment on lines
+28
to
+29
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Propagate the incoming request context to external CAPTCHA verification requests. The Turnstile and GeeTest checks use 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| "secret": {common.TurnstileSecretKey}, | ||
| "response": {response}, | ||
| "remoteip": {c.ClientIP()}, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.