使用者送出表單後按 F5 重整、或瀏覽器前進/後退、或雙擊提交按鈕,瀏覽器會**重新發送相同的 POST 請求**,導致資料庫重複寫入 (訂單、留言、註冊)。
| 方案 | 原理 | 優點 | 缺點 |
|---|---|---|---|
| JS 禁用按鈕 | 前端鎖定 submit | 簡單 | 繞過 JS 即失效 (curl、停用 JS) |
| PRG 模式 (Post-Redirect-Get) | 處理完 302 導向 GET 頁面 | 徹底解決重整重複 | 需額外頁面;無法防雙擊/重複點擊 |
| Token (單次令牌) | 表單隱藏欄位帶一次性 Token,Server 驗證後銷毀 | 防重整、防重複點擊、防 CSRF、無狀態可擴充 | 需 Session/快取儲存 Token |
form_token.php<?php
session_start();
// ---------- Token 工具函式 ----------
function generate_token(): string
{
$token = bin2hex(random_bytes(16)); // 32 字元十六進位
$_SESSION['form_token'] = $token;
return $token;
}
function validate_token(string $input): bool
{
if (empty($_SESSION['form_token']) || empty($input)) {
return false;
}
// 使用 hash_equals 防時序攻擊
if (hash_equals($_SESSION['form_token'], $input)) {
unset($_SESSION['form_token']); // 一次性銷毀
return true;
}
return false;
}
// ---------- 業務邏輯 ----------
$message = '';
$token = generate_token(); // 預先生成,供首次顯示使用
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (validate_token($_POST['token'] ?? '')) {
// 真正的業務處理
$data = htmlspecialchars(trim($_POST['data'] ?? ''), ENT_QUOTES, 'UTF-8');
if ($data !== '') {
// PDO 範例
try {
$pdo = new PDO('mysql:host=localhost;dbname=test;charset=utf8mb4', 'user', 'pass');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$stmt = $pdo->prepare('INSERT INTO `logs` (`content`) VALUES (?)');
$stmt->execute([$data]);
$message = '儲存成功!返回';
} catch (PDOException $e) {
$message = '資料庫錯誤:' . $e->getMessage() . '';
}
} else {
$message = '內容不可為空';
}
} else {
$message = '無效或重複的提交,請重新整理頁面';
$token = generate_token(); // 重新產生給下一次
}
}
?>
Token 防重複提交示範
= $message ?>
function generate_token(int $ttl = 3600): string
{
$token = bin2hex(random_bytes(16));
$_SESSION['form_token'] = $token;
$_SESSION['form_token_time'] = time();
$_SESSION['form_token_ttl'] = $ttl;
return $token;
}
function validate_token(string $input): bool
{
if (empty($_SESSION['form_token']) || empty($input)) return false;
if (time() - $_SESSION['form_token_time'] > $_SESSION['form_token_ttl']) {
unset($_SESSION['form_token'], $_SESSION['form_token_time'], $_SESSION['form_token_ttl']);
return false; // 逾期
}
if (hash_equals($_SESSION['form_token'], $input)) {
unset($_SESSION['form_token'], $_SESSION['form_token_time'], $_SESSION['form_token_ttl']);
return true;
}
return false;
}
將 Token 整合進表單驗證庫,支援 `$this->form_validation->valid_token()` 與 `$this->form_validation->create_token()`。
<?php
class MY_Form_validation extends CI_Form_validation {
protected $token_key = 'csrf_token';
protected $token_ttl = 7200; // 2 小時
public function __construct() {
parent::__construct();
$this->CI =& get_instance();
$this->CI->load->library('session');
}
public function create_token(bool $output = false): string
{
$token = bin2hex(random_bytes(16));
$this->CI->session->set_userdata($this->token_key, $token);
$this->CI->session->set_userdata($this->token_key . '_time', time());
$html = '';
if ($output) echo $html;
return $html;
}
public function valid_token(): bool
{
$saved = $this->CI->session->userdata($this->token_key);
$saved_time = $this->CI->session->userdata($this->token_key . '_time');
$posted = $this->CI->input->post($this->token_key);
if (empty($saved) || empty($posted)) return false;
if (time() - $saved_time > $this->token_ttl) return false;
if (hash_equals($saved, $posted)) {
$this->CI->session->unset_userdata([$this->token_key, $this->token_key . '_time']);
return true;
}
return false;
}
}
public function form()
{
$this->load->library('form_validation');
if ($this->input->post('submit') && $this->form_validation->valid_token()) {
// 處理業務
redirect('success');
}
$this->form_validation->create_token(true); // 直接輸出 hidden input
$this->load->view('my_form');
}