feat: replace legacy captcha with Cloudflare Turnstile

This commit is contained in:
sagit
2026-02-08 04:51:41 +00:00
parent a74eb9f431
commit 2710cd1674
9 changed files with 181 additions and 416 deletions
@@ -1,67 +0,0 @@
package handler
import "time"
const captchaTokenTTL = 5 * time.Minute
func (h *Handler) storeCaptchaToken(token string) {
if h == nil {
return
}
token = normalizeCaptchaToken(token)
if token == "" {
return
}
h.captchaMu.Lock()
defer h.captchaMu.Unlock()
now := time.Now().UnixMilli()
h.pruneExpiredCaptchaTokensLocked(now)
h.captchaTokens[token] = now + int64(captchaTokenTTL/time.Millisecond)
}
func (h *Handler) consumeCaptchaToken(token string) bool {
if h == nil {
return false
}
token = normalizeCaptchaToken(token)
if token == "" {
return false
}
h.captchaMu.Lock()
defer h.captchaMu.Unlock()
now := time.Now().UnixMilli()
h.pruneExpiredCaptchaTokensLocked(now)
expiresAt, ok := h.captchaTokens[token]
if !ok || expiresAt <= now {
delete(h.captchaTokens, token)
return false
}
delete(h.captchaTokens, token)
return true
}
func (h *Handler) pruneExpiredCaptchaTokensLocked(now int64) {
for token, expiresAt := range h.captchaTokens {
if expiresAt <= now {
delete(h.captchaTokens, token)
}
}
}
func normalizeCaptchaToken(token string) string {
return trimToken(token)
}
func trimToken(token string) string {
for len(token) > 0 && (token[0] == ' ' || token[0] == '\t' || token[0] == '\n' || token[0] == '\r') {
token = token[1:]
}
for len(token) > 0 && (token[len(token)-1] == ' ' || token[len(token)-1] == '\t' || token[len(token)-1] == '\n' || token[len(token)-1] == '\r') {
token = token[:len(token)-1]
}
return token
}
+41 -16
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -26,9 +27,6 @@ type Handler struct {
jwtSecret string
wsServer *ws.Server
captchaMu sync.Mutex
captchaTokens map[string]int64
jobsMu sync.Mutex
jobsCancel context.CancelFunc
jobsStarted bool
@@ -65,10 +63,9 @@ type flowItem struct {
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
return &Handler{
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
captchaTokens: make(map[string]int64),
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
}
}
@@ -88,8 +85,6 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
mux.HandleFunc("/api/v1/captcha/generate", h.captchaGenerate)
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
mux.HandleFunc("/api/v1/user/package", h.userPackage)
mux.HandleFunc("/api/v1/user/updatePassword", h.updatePassword)
mux.HandleFunc("/api/v1/node/list", h.nodeList)
@@ -181,13 +176,22 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if captchaEnabled && strings.TrimSpace(req.CaptchaID) == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
if captchaEnabled && !h.consumeCaptchaToken(req.CaptchaID) {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
if captchaEnabled {
if strings.TrimSpace(req.CaptchaID) == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
if err != nil || secretCfg == nil || secretCfg.Value == "" {
response.WriteJSON(w, response.ErrDefault("验证码配置错误:未配置Secret Key"))
return
}
if !h.verifyCloudflareTurnstile(req.CaptchaID, secretCfg.Value) {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
}
user, err := h.repo.GetUserByUsername(req.Username)
@@ -961,3 +965,24 @@ func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
}
return string(plain), nil
}
func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
if token == "" || secretKey == "" {
return false
}
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{
"secret": {secretKey},
"response": {token},
})
if err != nil {
return false
}
defer resp.Body.Close()
var body struct {
Success bool `json:"success"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return false
}
return body.Success
}
+58 -58
View File
@@ -251,58 +251,6 @@ func (h *Handler) userResetFlow(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) captchaGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
token := randomToken(16)
payload := map[string]interface{}{
"id": token,
"data": map[string]interface{}{
"id": token,
},
"success": true,
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(payload)
}
func (h *Handler) captchaVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
var req map[string]interface{}
if err := decodeJSON(r.Body, &req); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
id := asString(req["captchaId"])
if id == "" {
id = asString(req["id"])
}
trackData := asString(req["data"])
if trackData == "" {
trackData = asString(req["trackData"])
}
if id == "" || trackData == "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
h.storeCaptchaToken(id)
payload := map[string]interface{}{
"success": true,
"data": map[string]interface{}{"validToken": id},
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(payload)
}
func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -589,6 +537,31 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) cleanupTunnelRuntime(tunnelID int64) {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil || tunnel.Type != 2 {
return
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return
}
serviceName := fmt.Sprintf("%d_tls", tunnelID)
chainName := fmt.Sprintf("chains_%d", tunnelID)
for _, row := range chainRows {
if row.ChainType == 1 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
} else if row.ChainType == 2 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
} else if row.ChainType == 3 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
}
}
}
func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -627,19 +600,34 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
return
}
h.cleanupTunnelRuntime(id)
now := time.Now().UnixMilli()
_, err := h.repo.DB().Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
asString(req["name"]), asInt(req["type"], 1), asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(asString(req["inIp"])), now, id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
typeVal := asInt(req["type"], 1)
tx, err := h.repo.DB().Begin()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
defer func() { _ = tx.Rollback() }()
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
runtimeState.TunnelID = id
applyTunnelPortsToRequest(req, runtimeState)
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(asString(req["inIp"])), now, id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if _, err := tx.Exec(`DELETE FROM chain_tunnel WHERE tunnel_id = ?`, id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -652,6 +640,16 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if typeVal == 2 {
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
if applyErr != nil {
h.rollbackTunnelRuntime(createdChains, createdServices, id)
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
return
}
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -664,6 +662,7 @@ func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
h.cleanupTunnelRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -721,6 +720,7 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
success := 0
fail := 0
for _, id := range ids {
h.cleanupTunnelRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
fail++
} else {