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

PHP Token 機制防止表單重複提交完整實作

     編輯:PHP 綜合知識

PHP Token 防重複提交機制完整實作指南

表單重複提交會導致資料重複寫入、扣款重複等嚴重問題。本文實作基於 Session + Token 的防重複提交機制,包含基礎版、AJAX 版、多標籤頁支援版等完整方案。

基礎實作:Session + 隱藏欄位

<?php
session_start();

/**
 * 生成 Token
 */
function set_token() {
    $_SESSION['token'] = md5(microtime(true) . mt_rand(10000, 99999));
}

/**
 * 驗證 Token(驗證後自動更新,單次使用)
 * @return bool
 */
function valid_token() {
    $submitted = isset($_REQUEST['token']) ? $_REQUEST['token'] : '';
    $stored = isset($_SESSION['token']) ? $_SESSION['token'] : '';
    
    $valid = ($submitted === $stored && $submitted !== '');
    
    // 無論驗證成敗,都重新生成 Token(單次使用機制)
    set_token();
    
    return $valid;
}

// 初始化 Token
if (!isset($_SESSION['token']) || $_SESSION['token'] === '') {
    set_token();
}

// 處理表單提交
if (isset($_POST['submit'])) {
    if (!valid_token()) {
        $msg = "Token 驗證失敗:請勿重複提交或頁面已過期";
    } else {
        // 業務邏輯處理
        $msg = "提交成功!資料:" . htmlspecialchars($_POST['content']);
    }
}
?>
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Token 防重複提交</title></head>
<body>
    <h3>表單提交測試</h3>
    $msg

"; ?> <form method="post"> <input type="hidden" name="token" value="<?php echo $_SESSION['token']; ?>"> <textarea name="content" rows="5" cols="40" placeholder="輸入內容..."></textarea><br> <input type="submit" name="submit" value="提交"> </form> </body> </html>

進階:Token 類別封裝(支援多表單、命名空間)

<?php
class FormToken {
    private $namespace = 'default';
    private $tokenKey = 'form_tokens';
    
    public function __construct($namespace = 'default') {
        $this->namespace = $namespace;
        if (!isset($_SESSION[$this->tokenKey])) {
            $_SESSION[$this->tokenKey] = [];
        }
    }
    
    // 生成新 Token
    public function generate($formName = 'default') {
        $token = bin2hex(random_bytes(32));
        $_SESSION[$this->tokenKey][$this->namespace][$formName] = [
            'token' => $token,
            'created' => time(),
            'used' => false
        ];
        return $token;
    }
    
    // 驗證 Token
    public function validate($formName = 'default', $submittedToken = '') {
        $storage = &$_SESSION[$this->tokenKey][$this->namespace][$formName];
        
        if (!isset($storage)) return false;
        if ($storage['used']) return false; // 已使用
        if (time() - $storage['created'] > 3600) return false; // 1小時過期
        if (!hash_equals($storage['token'], $submittedToken)) return false;
        
        $storage['used'] = true; // 標記已使用
        return true;
    }
    
    // 取得 Token(用於表單隱藏欄位)
    public function getToken($formName = 'default') {
        $storage = &$_SESSION[$this->tokenKey][$this->namespace][$formName];
        if (!isset($storage) || $storage['used']) {
            return $this->generate($formName);
        }
        return $storage['token'];
    }
    
    // 清理過期 Token
    public function cleanExpired($maxAge = 3600) {
        foreach ($_SESSION[$this->tokenKey][$this->namespace] as $name => &$data) {
            if (time() - $data['created'] > $maxAge) {
                unset($_SESSION[$this->tokenKey][$this->namespace][$name]);
            }
        }
    }
}

// 使用範例
$token = new FormToken('user_module');
$csrf_token = $token->getToken('register_form');
?>
<form method="post" action="register.php">
    <input type="hidden" name="csrf_token" value="<?php echo $csrf_token; ?>">
    <input type="hidden" name="form_name" value="register_form">
    <!-- 表單欄位 -->
</form>

AJAX 版本實作

<?php
// api/token.php - 取得 Token API
session_start();
header('Content-Type: application/json');

$action = $_GET['action'] ?? '';
$form = $_GET['form'] ?? 'default';

if ($action === 'get') {
    // 生成新 Token 並返回
    $token = bin2hex(random_bytes(32));
    $_SESSION['ajax_tokens'][$form] = [
        'token' => $token,
        'time' => time()
    ];
    echo json_encode(['success' => true, 'token' => $token]);
} elseif ($action === 'verify') {
    $submitted = $_POST['token'] ?? '';
    $stored = $_SESSION['ajax_tokens'][$form]['token'] ?? '';
    
    $valid = hash_equals($stored, $submitted);
    if ($valid) unset($_SESSION['ajax_tokens'][$form]); // 銷毀
    
    echo json_encode(['success' => $valid, 'message' => $valid ? '驗證通過' : 'Token 無效或已過期']);
}
?>
// 前端 JavaScript
class FormToken {
    constructor(formName) {
        this.formName = formName;
        this.token = null;
    }
    
    async fetchToken() {
        const res = await fetch(`/api/token.php?action=get&form=${this.formName}`);
        const data = await res.json();
        this.token = data.token;
        return this.token;
    }
    
    async submit(formData) {
        if (!this.token) await this.fetchToken();
        
        formData.append('token', this.token);
        formData.append('form_name', this.formName);
        
        const res = await fetch('/api/submit.php', {
            method: 'POST',
            body: formData
        });
        
        const result = await res.json();
        if (result.success) {
            this.token = null; // 成功後清除,下次重新獲取
        }
        return result;
    }
}

// 使用
const tokenManager = new FormToken('comment_form');
document.getElementById('commentForm').addEventListener('submit', async (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const result = await tokenManager.submit(formData);
    alert(result.message);
});

多標籤頁支援策略

問題:使用者開啟多個分頁,Token 機制會導致先開分頁的 Token 失效。

解決方案:

  1. Token 池機制:儲存多組有效 Token,驗證任一有效即可
  2. 時間窗口驗證:允許 N 分鐘內的所有 Token 有效
  3. 表單級別隔離:不同表單使用獨立 Token 命名空間
// Token 池實作(允許同時 5 組有效 Token)
class TokenPool {
    const MAX_TOKENS = 5;
    
    public static function generate($formName) {
        $pool = &$_SESSION['token_pool'][$formName];
        if (!is_array($pool)) $pool = [];
        
        $token = bin2hex(random_bytes(32));
        $pool[] = ['token' => $token, 'time' => time()];
        
        // 保持池大小
        if (count($pool) > self::MAX_TOKENS) {
            array_shift($pool);
        }
        
        return $token;
    }
    
    public static function validate($formName, $token) {
        $pool = $_SESSION['token_pool'][$formName] ?? [];
        foreach ($pool as $i => $item) {
            if (hash_equals($item['token'], $token)) {
                unset($pool[$i]); // 使用後移除
                $_SESSION['token_pool'][$formName] = array_values($pool);
                return true;
            }
        }
        return false;
    }
}

前端雙重保護:按鈕禁用 + Token

<form id="secureForm">
    <input type="hidden" name="token" id="csrfToken">
    <button type="submit" id="submitBtn">提交</button>
</form>

<script>
document.getElementById('secureForm').addEventListener('submit', function(e) {
    const btn = document.getElementById('submitBtn');
    btn.disabled = true;
    btn.textContent = '提交中...';
    
    // Token 會隨表單自動提交
    // 伺服器驗證失敗時需重新啟用按鈕
});

// 伺服器返回錯誤時的處理(AJAX 情況)
function onSubmitError() {
    const btn = document.getElementById('submitBtn');
    btn.disabled = false;
    btn.textContent = '提交';
    // 重新獲取 Token
    fetchNewToken();
}
</script>

完整防禦體系對照表

防禦層級機制防禦類型適用場景
客戶端按鈕提交後禁用防誤操作所有表單
客戶端JS 防抖/節流防高頻點擊按鈕、連結
伺服器Session Token 驗證防重複提交/CSRF所有 POST 請求
伺服器資料庫唯一約束防資料重複訂單、付款、註冊
伺服器冪等性鍵防重試副作用API、支付回調
資料庫唯一索引/約束最後防線關鍵業務表

最佳實踐總結

  1. 所有狀態改變操作(POST/PUT/DELETE)必須驗證 Token
  2. 使用 hash_equals() 防止時序攻擊
  3. Token 單次使用,驗證後立即銷毀/更新
  4. 設定合理過期時間(建議 30-60 分鐘)
  5. 結合資料庫唯一約束作為最後防線
  6. AJAX 請求透過 Header 傳遞 Token:X-CSRF-Token
  7. 定期清理 Session 中過期 Token 避免記憶體洩漏
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved