在 PHP 中實現「記住我」(Remember Me)功能,絕對不能直接將帳號或密碼存入 Cookie。目前業界公認最安全的「正規寫法」是採用雙重權限驗證(Selector & Validator)機制。
這種方法將原本的一個憑證拆成兩部分,即便資料庫外洩,攻擊者也無法輕易仿造 Cookie;即便 Cookie 被竊取,也能透過「一次性驗證碼」機制偵測到異常。
1. 資料表準備
id: 流水號。selector: 長度約 12 字元的隨機字串(作為資料庫查詢索引,唯一值)。validator_hash: 驗證碼的雜湊值(類似密碼雜湊)。user_id: 對應的使用者 ID。expires_at: 到期時間。
2. 登入成功時:產生 Cookie
- 產生隨機數:產生兩個強隨機字串(建議使用
random_bytes())。$selector: 查詢標籤(例如 12 位元)。$validator: 實際驗證金鑰(例如 32 位元)。
- 存入資料庫:將
$selector與 雜湊後 的$validator_hash(使用password_hash())存入表中。 - 發送 Cookie:將
selector:validator組合成字串存入使用者瀏覽器。
3. 自動登入時:驗證流程
當使用者帶著 Cookie 重新訪問網站,且 Session 已過期時:
- 解析 Cookie:將內容拆回
$selector與$validator。 - 查詢資料庫:根據
$selector找出一筆未過期的紀錄。 - 比對驗證碼:使用
password_verify($validator, $row['validator_hash'])檢查是否匹配。 - 重新核發(推薦):為了防範 Cookie 側錄(Session Hijacking),驗證成功後應刪除舊紀錄並核發一組全新的 Selector/Validator 給使用者。
為什麼這比存 ID/密碼安全?
- 資料庫外洩防護:資料庫只存 Validator 的雜湊值。駭客即便拿到資料表,也無法反推回原始 Cookie 內容。
- 防止計時攻擊:透過 Selector 快速定位,再使用時序安全的函數比對 Validator。
- 降低風險範圍:這個 Token 應僅限於「自動登入」。當使用者要執行修改密碼、結帳等敏感操作時,應強制要求重新輸入完整密碼。
這是一個基於 Selector & Validator 機制的實作範例。這個寫法能確保即便資料庫被脫庫(Dump),攻擊者也無法偽造 Cookie。
1. 資料庫結構 (MySQL)
2. 登入成功時:產生並存儲 Token
當用戶勾選「記住我」並登入成功後,執行此段程式碼:
3. 使用者回訪時:驗證 Token
當 $_SESSION['user_id'] 不存在,但有 $_COOKIE['remember_me'] 時觸發:
當 $_SESSION['user_id'] 不存在,但有 $_COOKIE['remember_me'] 時觸發:
if (empty($_SESSION['user_id']) && !empty($_COOKIE['remember_me'])) {
$parts = explode(':', $_COOKIE['remember_me']);
if (count($parts) === 2) {
[$selector, $validator] = $parts;
// 1. 根據 Selector 查找
$stmt = $pdo->prepare("SELECT * FROM user_tokens WHERE selector = ? AND expires_at > NOW()");
$stmt->execute([$selector]);
$tokenRecord = $stmt->fetch();
// 2. 比對 Validator 雜湊值
if ($tokenRecord && password_verify($validator, $tokenRecord['validator_hash'])) {
// 驗證成功!登入使用者
$_SESSION['user_id'] = $tokenRecord['user_id'];
// 3. 重要:輪替 Token (防止側錄攻擊)
// 刪除舊的,並重新執行上面的「產生並存儲 Token」步驟發送新 Cookie
$stmt = $pdo->prepare("DELETE FROM user_tokens WHERE id = ?");
$stmt->execute([$tokenRecord['id']]);
// (此處呼叫步驟 2 的邏輯產生新 Token...)
}
}
}4. 登出時:清除 Token
登出不只是刪除 Session,也要記得清掉資料庫的紀錄。
// 刪除資料庫紀錄
if (!empty($_COOKIE['remember_me'])) {
$parts = explode(':', $_COOKIE['remember_me']);
$selector = $parts[0];
$stmt = $pdo->prepare("DELETE FROM user_tokens WHERE selector = ?");
$stmt->execute([$selector]);
}
// 刪除 Cookie
setcookie('remember_me', '', time() - 3600, '/');
if (!empty($_COOKIE['remember_me'])) {
$parts = explode(':', $_COOKIE['remember_me']);
$selector = $parts[0];
$stmt = $pdo->prepare("DELETE FROM user_tokens WHERE selector = ?");
$stmt->execute([$selector]);
}
// 刪除 Cookie
setcookie('remember_me', '', time() - 3600, '/');