首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 綜合知識

PHP 防止表單重複提交完整方案:前端攔截、後端 Token、Redis 分布式鎖三層防禦體系

     編輯:PHP 綜合知識

前言

表單重複提交是 Web 開發經典問題:用戶雙擊提交按鈕、網絡重發、瀏覽器刷新導致 POST 重發、惡意腳本高頻提交。單一防禦手段均有繞過風險。本文構建**前端攔截 (即時體驗) + 後端 Token (核心業務邏輯) + Redis 分布式鎖 (高併發/集群兜底)** 三層防禦體系,附完整代碼、原理分析、適用場景對比。

威脅模型與防禦層級

攻擊向量特徵防禦層級核心手段
用戶雙擊/急躁點擊毫秒級間隔,同一會話前端 (L1)按鈕禁用、Loading 狀態、防抖
瀏覽器刷新/後退重發 POST同一請求數據重複到達服務端後端 Token (L2)一次性 Token (生成->驗證->銷毀)
惡意腳本/爬蟲高頻提交高併發、多 IP/會話、繞過前端後端限流 + 鎖 (L2+L3)IP/用戶維度限流、Redis 分布式鎖
分布式集群下同一用戶請求打到不同節點單機鎖失效Redis 分布式鎖 (L3)SETNX + Lua 腳本原子操作

第一層:前端攔截 (用戶體驗優先,非安全邊界)

<!-- 表單模板 -->
<form id="bizForm" action="/submit" method="post">
    <input type="hidden" name="form_token" value="<?= $formToken ?>">
    <!-- 業務字段 -->
    <button type="submit" id="submitBtn" class="btn-primary">提交</button>
    <span id="loadingText" style="display:none;">處理中...</span>
</form>

<script>
// 方案 A:原生 JS 防抖 + 禁用 (無依賴)
document.getElementById('bizForm').addEventListener('submit', function(e) {
    const btn = document.getElementById('submitBtn');
    const loading = document.getElementById('loadingText');
    
    // 1. 防抖:若已在提交狀態,直接攔截
    if (btn.disabled) {
        e.preventDefault();
        return false;
    }
    
    // 2. 進入提交狀態
    btn.disabled = true;
    btn.style.opacity = '0.6';
    btn.textContent = '';
    loading.style.display = 'inline';
    
    // 3. 可選:表單驗證 (若失敗需恢復按鈕)
    // if (!validateForm()) { resetBtn(); e.preventDefault(); return; }
    
    // 4. 正常提交:表單跳轉或 AJAX
    // 若 AJAX 提交,需在 complete/error 中恢復按鈕
});

// 方案 B:jQuery 版 (項目有 jQuery 時)
$('#bizForm').on('submit', function() {
    const $btn = $('#submitBtn').prop('disabled', true);
    $('#loadingText').show();
    // AJAX 示例
    // $.post('/api/submit', $(this).serialize(), function() {})
    //   .always(() => { $btn.prop('disabled', false); $('#loadingText').hide(); });
    return true; // 允許默認提交
});
</script>

局限: 用戶可禁用 JS、開發者工具繞過、**無法防禦瀏覽器原生刷新重發 POST**。**必須配合後端方案。**

第二層:後端一次性 Token (核心業務防重,單機/集群通用)

原理:生成唯一 Token → 存入 Session/Redis → 表單攜帶 → 提交驗證並銷毀 → 重複提交 Token 無效

<?php
/**
 * 表單 Token 管理器 (支援 Session/Redis 存儲)
 */
class FormTokenManager {
    private $storage; // 'session' | 'redis'
    private $redis;
    private $ttl = 3600; // Token 有效期 1 小時
    private $tokenPrefix = 'form_token:';
    
    public function __construct(string $storage = 'session', \Redis $redis = null) {
        $this->storage = $storage;
        if ($storage === 'redis') {
            if (!$redis) throw new InvalidArgumentException('Redis 模式需注入 Redis 實例');
            $this->redis = $redis;
        }
        if (session_status() === PHP_SESSION_NONE) session_start();
    }
    
    /** 生成新 Token 並存儲,返回 Token 字符串 */
    public function generate(string $formKey = 'default'): string {
        $token = bin2hex(random_bytes(32)); // 256 位隨機 Token
        $key = $this->tokenPrefix . $formKey . ':' . $token;
        $data = ['used' => false, 'created' => time()];
        
        if ($this->storage === 'redis') {
            $this->redis->setex($key, $this->ttl, json_encode($data));
        } else {
            $_SESSION['form_tokens'][$key] = $data;
            $this->gcSessionTokens(); // 清理過期
        }
        return $token;
    }
    
    /** 驗證並消費 Token:成功返回 true 並標記已用,失敗返回 false */
    public function validateAndConsume(string $token, string $formKey = 'default'): bool {
        $key = $this->tokenPrefix . $formKey . ':' . $token;
        
        if ($this->storage === 'redis') {
            // Lua 腳本原子檢查並刪除 (防併發竞態)
            $lua = "
                local key = KEYS[1]
                local data = redis.call('GET', key)
                if not data then return 0 end
                local obj = cjson.decode(data)
                if obj.used then return 0 end
                obj.used = true
                redis.call('DEL', key) -- 驗證即銷毀,最強防重
                return 1
            ";
            $result = $this->redis->eval($lua, 1, $key);
            return $result === 1;
        } else {
            // Session 模式:檢查並標記 (單進程安全,多進程需 Session 共享存儲)
            if (empty($_SESSION['form_tokens'][$key]) || $_SESSION['form_tokens'][$key]['used']) {
                return false;
            }
            $_SESSION['form_tokens'][$key]['used'] = true;
            unset($_SESSION['form_tokens'][$key]); // 即時銷毀
            return true;
        }
    }
    
    private function gcSessionTokens(): void {
        $now = time();
        foreach ($_SESSION['form_tokens'] ?? [] as $k => $v) {
            if ($now - $v['created'] > $this->ttl) unset($_SESSION['form_tokens'][$k]);
        }
    }
}

// === 控制器使用示例 ===
// 1. 顯示表單頁:生成 Token 注入模板
$tokenManager = new FormTokenManager('redis', $redis); // 或 'session'
$formToken = $tokenManager->generate('order_create');
// 渲染模板,將 $formToken 填入 

// 2. 處理提交:驗證 Token
$submittedToken = $_POST['form_token'] ?? '';
if (!$tokenManager->validateAndConsume($submittedToken, 'order_create')) {
    // Token 無效/已用/過期 -> 重複提交或偽造
    jsonResponse(['code' => 400, 'msg' => '請勿重複提交,或表單已過期請刷新頁面']);
    exit;
}

// 3. Token 通過 -> 執行核心業務邏輯 (下單、扣款、寫入 DB)
// ... 
// 4. 成功後:重定向到成功頁 (PRG 模式),徹底防止瀏覽器刷新重發
header('Location: /order/success?id=' . $orderId);
exit;
?>

關鍵點深度解析

  • Token 隨機性: random_bytes(32) 產生 256 位熵,極難猜測/碰撞。
  • 一次性銷毀: 驗證通過**立即刪除** Token (Redis 用 Lua 原子 DEL,Session 用 unset),**徹底杜絕同一 Token 二次通過**。
  • 表單鍵隔離: $formKey (如 'order_create', 'user_register') 區分不同業務表單,互不干擾。
  • Redis vs Session: 集群環境**必須用 Redis** (或共享 Session 存儲),單機 Session 文件/內存不共享會導致 Token 驗證失效/繞過。
  • PRG 模式 (Post-Redirect-Get): 業務成功後**必須 302 重定向到 GET 頁面**,徹底解決瀏覽器刷新/後退重發 POST 問題,是 Web 開發鐵律。

第三層:Redis 分布式鎖 (高併發/極限防重兜底)

適用場景:秒殺、搶優惠券、高價值操作,**極端併發下業務邏輯執行時間較長**,Token 驗證通過但業務未完成時,同一用戶其他請求可能已進入業務代碼。需在**業務關鍵段**加鎖。

<?php
/**
 * Redis 分布式鎖 (基於 SET NX EX + Lua 釋放)
 */
class RedisDistributedLock {
    private $redis;
    private $defaultTtl = 10; // 鎖超時時間 (秒),必須 > 業務最大執行時間
    
    public function __construct(\Redis $redis) { $this->redis = $redis; }
    
    /** 獲取鎖
     * @param string $key       鎖鍵,如 "biz_lock:order_create:user_123"
     * @param int    $ttl       鎖過期時間
     * @param int    $retryTimes 重試次數
     * @param int    $retryDelay 重試間隔 (微秒)
     * @return bool|string 成功返回鎖值 (用於釋放),失敗返回 false
     */
    public function acquire(string $key, int $ttl = 0, int $retryTimes = 3, int $retryDelay = 50000): bool|string {
        $ttl = $ttl ?: $this->defaultTtl;
        $lockVal = bin2hex(random_bytes(16)); // 唯一鎖值,防止誤刷他人鎖
        $fullKey = "lock:$key";
        
        for ($i = 0; $i <= $retryTimes; $i++) {
            // SET key value NX EX ttl (原子設置)
            $acquired = $this->redis->set($fullKey, $lockVal, ['NX', 'EX' => $ttl]);
            if ($acquired) return $lockVal; // 返回鎖值
            if ($i < $retryTimes) usleep($retryDelay);
        }
        return false;
    }
    
    /** 釋放鎖 (Lua 腳本:僅刪除屬於自己的鎖) */
    public function release(string $key, string $lockVal): bool {
        $fullKey = "lock:$key";
        $lua = "
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            else
                return 0
            end
        ";
        return $this->redis->eval($lua, 1, $fullKey, $lockVal) === 1;
    }
}

// === 業務關鍵段使用示例 (如創建訂單核心邏輯) ===
$lock = new RedisDistributedLock($redis);
$userId = $currentUser['id'];
$lockKey = "order_create:user_$userId"; // 同一用戶串行

$lockVal = $lock->acquire($lockKey, 15, 5, 100000); // 最多重試 5 次,共 ~0.5s
if (!$lockVal) {
    jsonResponse(['code' => 429, 'msg' => '系統繁忙,請稍後再試 (併發限制)']);
    exit;
}

try {
    // 核心業務:檢查庫存、扣減、創建訂單、寫入 DB (事務)
    // 此處同一用戶同一時間只能有一個請求執行
    $orderId = createOrderInTransaction($userId, $items);
    
    // 成功:釋放鎖,返回結果
    $lock->release($lockKey, $lockVal);
    jsonResponse(['code' => 200, 'msg' => '下單成功', 'order_id' => $orderId]);
} catch (Exception $e) {
    // 失敗:釋放鎖,拋出/記錄錯誤
    $lock->release($lockKey, $lockVal);
    throw $e; // 或統一錯誤處理
}
?>

三層防禦協同部署架構圖

用戶提交
   │
   ▼
┌─────────────────────────────────────┐
│ L1 前端攔截 (JS 禁用按鈕/Loading)   │ ──攔截 90%+ 普通用戶誤操作
│  - 即時反饋、零服務端開銷           │
└──────────────┬──────────────────────┘
               │ (繞過/無 JS/刷新重發)
               ▼
┌─────────────────────────────────────┐
│ L2 後端 Token 驗證 (一次性消費)      │ ──攔截 99%+ 重複提交/刷新重發/偽造
│  - Redis/Session 存儲               │
│  - 驗證即銷毀 (Lua/unset)           │
│  - 業務鍵隔離                       │
└──────────────┬──────────────────────┘
               │ (極端併發/業務執行長/集群競態)
               ▼
┌─────────────────────────────────────┐
│ L3 Redis 分布式鎖 (關鍵段串行化)     │ ──兜底 0.01% 極端併發競態
│  - SET NX EX 原子獲取               │
│  - Lua 安全釋放                     │
│  - TTL 防死鎖                       │
└──────────────┬──────────────────────┘
               │
               ▼
         核心業務邏輯 (DB 事務)

方案選型建議

業務場景推薦方案組合核心理由
普通表單 (登錄、註冊、聯係我們、評論)L1 + L2 (Session Token)低併發,單機/集群 Session 共享即可,開發最簡單
重要業務 (下單、支付、提現、改密)L1 + L2 (Redis Token) + PRG集群安全,Token 即時銷毀,PRG 徹底防刷新重發
高併發搶購 (秒殺、搶券、限量拼團)L1 + L2 (Redis Token) + L3 (Redis Lock) + Lua 腳本原子業務極限併發下業務邏輯必須串行化,鎖保護關鍵段,配合庫存原子扣減
API 開放平台 (防重放攻擊)L2 (Redis Token, 短 TTL) + 簽名驗證 + Nonce/Time 窗口無前端,Token 作為請求唯一標識,結合簽名防篡改

總結

防止表單重複提交**沒有銀彈,只有分層防禦**。L1 前端攔截解決用戶體驗;L2 後端一次性 Token (Redis + 驗證即銷毀 + PRG)是**核心業務防線**,解決 99%+ 場景;L3 Redis 分布式鎖作為**極限併發兜底**,保護關鍵業務段串行執行。根據業務價值、併發級別、架構模式選型組合,**切記:Token 必須驗證即銷毀,業務成功必須 PRG 重定向,鎖必須帶 TTL 並用 Lua 安全釋放**,才能構建經得起生產環境考驗的防重體系。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved