Learning Objectives
- 說明 HTTP 是無狀態的,伺服器要靠 Cookie 才能認出同一個瀏覽器
- 用 setcookie 設定 Cookie(到期時間、路徑、HttpOnly、Secure、SameSite),說明 $_COOKIE 要到下一次請求才讀得到
- 刪除 Cookie,並說明為什麼 Cookie 不能存放敏感資料或用來判斷權限
- 用 session_start 與 $_SESSION 保存登入狀態,說明 PHPSESSID 只是編號、資料存在伺服器
- 寫出登入、檢查登入、登出的流程,登入成功後用 session_regenerate_id 換編號
- 說明「headers already sent」的原因,以及 session_start、setcookie、header 為什麼要在任何輸出之前
HTTP 記不住你
你登入一個網站,點到下一頁,網站還記得你是誰,這件事其實不簡單:HTTP 是無狀態的(stateless)。 每一次請求都是獨立的,伺服器上的 PHP 執行完就結束,變數全部消失;下一次請求進來,PHP 從頭執行, 它不知道這個請求和上一個是不是同一個人送的。
要讓伺服器「記得」,需要瀏覽器配合,每次請求都帶上一點識別資料。這就是 Cookie:
第 1 次請求 瀏覽器 → 伺服器 GET /index.php
回應 伺服器 → 瀏覽器 Set-Cookie: theme=dark (請瀏覽器記住)
第 2 次請求 瀏覽器 → 伺服器 GET /index.php
Cookie: theme=dark (瀏覽器自動帶回)
PHP 把請求帶來的 Cookie 放在超全域變數 $_COOKIE。登入狀態、購物車則多半用 Session:
資料存在伺服器,Cookie 裡只放一個編號。本課兩種都會做,並用模擬的瀏覽器看 Cookie 怎麼一來一往。
setcookie:請瀏覽器記住一筆資料
setcookie('theme', 'dark', [
'expires' => time() + 60 * 60 * 24 * 30, // 到期時間(Unix 時間戳記);不寫或 0=關閉瀏覽器就消失
'path' => '/', // 哪些網址會帶這個 Cookie;'/' 是整個網站
'secure' => true, // 只在 HTTPS 連線時傳送
'httponly' => true, // 網頁裡的 JavaScript 讀不到(降低被 XSS 偷走的風險)
'samesite' => 'Lax', // 從其他網站送來的請求要不要帶:Strict、Lax、None
]);
也可以用舊的位置參數寫法 setcookie('theme', 'dark', time() + 86400, '/', '', true, true);,不過參數的順序不好記,
而且這種寫法沒有 SameSite,所以建議用選項陣列(PHP 7.3 起支援)。
setcookie 只是在回應裡加一行 Set-Cookie 標頭,有兩個重要的結果:
- 這一次請求的
$_COOKIE不會變:$_COOKIE是瀏覽器送來的內容。剛設定的 Cookie 要等瀏覽器收到回應、下一次請求帶回來,才讀得到 - 標頭必須在內容之前送出,所以
setcookie要在任何輸出(echo、HTML)之前呼叫(本課最後說明)
設定 Cookie:這次請求還讀不到
模擬瀏覽器第一次請求 /index.php?theme=dark,瀏覽器還沒有任何 Cookie。全域框架有 $_GET,但沒有 $_COOKIE。執行完兩次 setcookie,看最後的輸出:$_COOKIE 仍然是空陣列。
下一次請求:讀取與刪除 Cookie
模擬下一次請求:瀏覽器帶著 theme 和 visited 兩個 Cookie。這次 theme 的值是 neon,因為使用者自己用瀏覽器的開發者工具改了它,所以第 18 行要檢查值是不是允許的選項。
想一想:Cookie
瀏覽器第一次造訪,還沒有任何 Cookie。下面的程式會印出什麼?
<?php
setcookie('lang', 'en');
echo $_COOKIE['lang'] ?? '沒有', ' ', count($_COOKIE);
某網站登入成功後執行 setcookie('role', 'member');,之後每一頁用 $_COOKIE['role'] === 'admin' 判斷是不是管理員。這樣做的問題是什麼?
Session:資料存在伺服器,Cookie 只放編號
Session(工作階段)把資料存在伺服器上,每份資料有一個很長的隨機編號,瀏覽器的 Cookie 只保存這個編號:
瀏覽器的 Cookie 伺服器上的 Session 資料
PHPSESSID=a6b1eec23cf1… ───→ a6b1eec23cf1… : ['user_id' => 1, 'username' => 'amy']
session_start(); // 讀取 Cookie 裡的 PHPSESSID,找回這個人的資料;沒有就建立新的
$_SESSION['user_id'] = 1; // 存
echo $_SESSION['user_id'] ?? ''; // 讀(這次和之後的請求都讀得到)
unset($_SESSION['cart']); // 刪除其中一項
- 每一頁要用 Session 都先呼叫
session_start(),之後才有$_SESSION - 第一次
session_start()時,PHP 產生新的編號,並在回應加上Set-Cookie: PHPSESSID=… - 資料不會送到瀏覽器,使用者改不了;Cookie 名稱
PHPSESSID可以用session_name()查詢 - 執行結束時 PHP 自動把
$_SESSION存回伺服器(預設存成檔案)
| Cookie | Session | |
|---|---|---|
| 資料存在 | 使用者的瀏覽器 | 伺服器 |
| 使用者看得到、改得了 | 可以 | 不行(只看得到編號) |
| 適合 | 語言、主題這類偏好 | 登入狀態、購物車 |
| 何時讀得到 | 下一次請求 | 存進去之後立刻可以讀 |
登入、檢查、登出
用 Session 做登入的三個步驟:
// 登入(login.php):密碼正確才存進 Session
if ($user !== null && password_verify($_POST['password'], $user['hash'])) {
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
header('Location: member.php');
exit;
}
// 每一頁檢查(member.php)
session_start();
if (!isset($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
// 登出(logout.php)
session_start();
session_unset(); // 清空 $_SESSION
session_destroy(); // 刪除伺服器上的資料
setcookie(session_name(), '', time() - 3600, '/'); // 請瀏覽器刪除 PHPSESSID
- 密碼不能直接存,要存
password_hash()產生的雜湊,登入時用password_verify()比對(第 18 課說明)。 雜湊字串裡有$,寫在 PHP 程式裡要用單引號,否則$2y會被當成變數 session_regenerate_id(true):登入成功時換一個新的編號,並刪除舊的。如果攻擊者事先讓你的瀏覽器用了一個他知道的編號 (session fixation,固定工作階段攻擊),你登入後他就能用同一個編號冒充你;換掉編號,他知道的那個就失效了- 登出時
session_destroy()刪除伺服器上的資料,但不會清空這次請求的$_SESSION,也不會刪除瀏覽器的 Cookie,所以三件事都要做
登入成功:把使用者存進 Session
模擬用 POST 送出帳號 amy、密碼 uedu2026,瀏覽器還沒有任何 Cookie。執行 session_start() 之後,全域框架多出 $_SESSION(一開始是空陣列);密碼驗證成功後,看右邊的 Session 陣列多了兩個鍵。程式最後轉址並結束,所以沒有輸出,回應是 302。
想一想:Session
瀏覽器第一次造訪,還沒有任何 Cookie。下面的程式會印出什麼?
<?php
session_start();
$_SESSION['n'] = ($_SESSION['n'] ?? 0) + 1;
$_SESSION['n']++;
echo $_SESSION['n'], ' ', isset($_COOKIE['PHPSESSID']) ? '有' : '沒有';
下面的程式會印出什麼?
<?php
session_start();
$_SESSION['user'] = 'amy';
session_destroy();
echo $_SESSION['user'] ?? '已清空';
登入成功後呼叫 session_regenerate_id(true) 的主要目的是什麼?
headers already sent:標頭要在內容之前
HTTP 回應先送標頭(狀態碼、Set-Cookie、Location…),空一行之後才是內容(HTML)。
PHP 遇到第一個輸出時,就必須先把標頭送出去;送出之後,header()、setcookie()、session_start() 都沒辦法再加標頭了:
Warning: Cannot modify header information - headers already sent by (output started at /var/www/html/index.php:3)
訊息裡的 output started at …:3 告訴你第一個輸出在第 3 行,從那裡找原因。常見的「看不見的輸出」:
<?php前面有空白行或空格- 檔案存成「UTF-8 with BOM」,開頭多了三個看不見的位元組
- 被 include 的檔案結尾
?>後面多了換行(所以只有 PHP 的檔案,慣例是不寫結尾的?>)
解決方法:把 session_start()、setcookie()、header() 移到檔案最前面,處理完邏輯再輸出 HTML。
另一個方法是 輸出緩衝(output buffering):ob_start() 之後的輸出先存在緩衝區,標頭還沒送出,最後 ob_end_flush() 才一起送出。
很多伺服器的 php.ini 預設開啟 output_buffering = 4096(官方的 php.ini-development 與 php.ini-production 都是這個值),
輸出不多時不會出現這個警告;但換到沒有開啟的環境(例如命令列,或 PHP 本身的預設值 Off)就會出錯,所以不要依賴它。
本課的模擬環境沒有開啟輸出緩衝。
先輸出再設定標頭
模擬一次 GET 請求(網頁情境的錯誤訊息是 HTML 格式)。第 3 行先輸出了 <h1>,之後的三行都會出現警告,而且沒有作用。
用 ob_start 先暫存輸出
同樣是先 echo 再 session_start(),但第 3 行先呼叫了 ob_start():輸出先存在緩衝區,標頭還沒送出,所以這次都沒有警告。
動手做:記住使用者選的語言
模擬的請求是 /index.php?lang=ja,瀏覽器帶著上次存的 Cookie lang=en。請依下面的順序決定語言 $lang 與來源 $from:
- 網址有
lang而且是支援的語言:用它,並用setcookie記住 30 天;來源是「網址」 - 否則,Cookie 有
lang而且是支援的語言:用它;來源是「Cookie」 - 都沒有:用
zh;來源是「預設」
注意:剛用 setcookie 存的值,這次請求的 $_COOKIE 讀不到,要直接用變數。
重點回顧
- HTTP 是無狀態的:每次請求都是獨立的,伺服器靠瀏覽器帶回的 Cookie 認出同一個人
setcookie(名稱, 值, [選項])在回應加上Set-Cookie;常用選項 expires、path、secure、httponly、samesite$_COOKIE是這次請求帶來的 Cookie:剛設定的值要到下一次請求才讀得到- 刪除 Cookie:值給空字串、到期時間設在過去
- Cookie 存在使用者的瀏覽器,看得到也改得了:不放密碼、權限等敏感資料,讀取時要驗證
- Session 的資料存在伺服器,瀏覽器只有
PHPSESSID編號;每頁先session_start()再用$_SESSION - 登入成功後
session_regenerate_id(true);登出要session_unset()、session_destroy(),並刪除 PHPSESSID 這個 Cookie session_start()、setcookie()、header()要在任何輸出之前;出現 headers already sent 時,看訊息指出的第一個輸出位置,或用ob_start()