表單重複提交會導致資料重複寫入、扣款重複等嚴重問題。本文實作基於 Session + Token 的防重複提交機制,包含基礎版、AJAX 版、多標籤頁支援版等完整方案。
<?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>
<?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>
<?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 失效。
解決方案:
// 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;
}
}
<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、支付回調 |
| 資料庫 | 唯一索引/約束 | 最後防線 | 關鍵業務表 |
hash_equals() 防止時序攻擊X-CSRF-Token