用戶雙擊提交按鈕、瀏覽器刷新、後退鍵重新提交、網絡重試等場景,都會導致同一表單數據被多次寫入資料庫,造成訂單重複、積分多扣、垃圾數據等嚴重問題。
原理:表單渲染時生成唯一 Token 存入 Session 並嵌入表單隱藏域;提交時比對 Token,匹配則處理並銷毀 Token,不匹配則拒絕。
<?php
// form.php - 表單頁
session_start();
// 生成 Token
$token = bin2hex(random_bytes(32));
$_SESSION['form_token'] = $token;
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head><meta charset="UTF-8"><title>提交表單</title></head>
<body>
<form action="submit.php" method="post">
<input type="hidden" name="token" value="<?php echo $token; ?>">
<label>內容:<input type="text" name="content" required></label>
<button type="submit">提交</button>
</form>
</body>
</html>
<?php
// submit.php - 處理頁
session_start();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
// 1. 驗證 Token 存在
if (empty($_POST['token']) || empty($_SESSION['form_token'])) {
die('無效請求:Token 缺失');
}
// 2. 比對並銷毀(一次性使用)
if (!hash_equals($_SESSION['form_token'], $_POST['token'])) {
die('請勿重複提交或表單已過期');
}
// 驗證通過,銷毀 Token 防止二次提交
unset($_SESSION['form_token']);
// 3. 業務邏輯處理
$content = trim($_POST['content']);
// ... 資料庫寫入、發送郵件等操作
echo '提交成功!';
?>
為避免用戶開啟多個標籤頁導致 Token 衝突,可採用Token 陣列或命名空間 Token:
<?php
// 生成帶表單標識的 Token
$formKey = 'contact_form';
$token = bin2hex(random_bytes(32));
$_SESSION['form_tokens'][$formKey] = $token;
// 驗證時
$formKey = $_POST['form_key'] ?? 'default';
if (!isset($_SESSION['form_tokens'][$formKey]) ||
!hash_equals($_SESSION['form_tokens'][$formKey], $_POST['token'])) {
die('Token 驗證失敗');
}
unset($_SESSION['form_tokens'][$formKey]);
<script>
// 提交後禁用按鈕
document.querySelector('form').addEventListener('submit', function() {
this.querySelector('button[type="submit"]').disabled = true;
this.querySelector('button[type="submit"]').textContent = '提交中...';
});
</script>
| 誤區 | 正確做法 |
|---|---|
| 僅靠 JS 禁用按鈕 | JS 可繞過,必須服務端驗證 |
| 用 IP 或 Session ID 直接判斷 | 無法區分正常重新提交與惡意重試 |
| Token 不銷毀 | 必須一次性,驗證通過即銷毀 |
| 用 md5/uniqid 生成 Token | 使用 random_bytes() 保證熵值 |
多服務器集群下,Session 需共享(Redis、資料庫),或改用JWT 無狀態 Token:
<?php
// 簽發 Token(有效期 10 分鐘)
$payload = ['form' => 'contact', 'exp' => time() + 600];
$token = JWT::encode($payload, $secretKey);
// 驗證
try {
$data = JWT::decode($token, $secretKey, ['HS256']);
// 可選:在 Redis 記錄已使用的 jti 防重放
} catch (Exception $e) {
die('Token 無效');
}
總結:服務端 Token 機制是核心,前端禁用按鈕僅為體驗優化,分佈式場景下引入 Redis 或 JWT 即可平滑擴展。