mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 10:16:38 +08:00
feat: replace legacy captcha with Cloudflare Turnstile
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user