PHP 语法进阶 14|Cookie 与 Session:状态是怎么被记住的
HTTP 本身没有状态
浏览器每发一个请求都是独立的,服务器不知道"这个人和上一个请求是不是同一个人"。记住登录状态靠两样东西:
- Cookie:存在客户端的小段数据,浏览器每次自动带上。
- Session:存在服务端的数据,客户端只持有一个"编号"(session id)。
理解这一层,后面所有安全配置就都顺理成章了:Cookie 里的内容用户随时能改,Session 里的内容用户改不了,但 session id 一旦泄露就等于账号被接管。
Cookie 的写法
<?php
// 老式(位置参数),PHP 7.3 起不推荐
setcookie("theme", "dark", time() + 86400, "/", "", true, true);
// 推荐:选项数组(PHP 7.3+)
setcookie("theme", "dark", [
'expires' => time() + 86400,
'path' => '/',
'domain' => '',
'secure' => true, // 只在 HTTPS 下发送
'httponly' => true, // JS 读不到,防 XSS 偷 Cookie
'samesite' => 'Lax', // 防 CSRF
]);
几个必须知道的规则:
setcookie()必须在任何输出之前调用。一旦有echo、空白、BOM,就会报headers already sent。判断方法:
<?php
if (headers_sent($file, $line)) {
throw new RuntimeException("输出已开始:{$file}:{$line}");
}
- 读取用
$_COOKIE,写入用setcookie(),两者不是一回事——setcookie只是给浏览器发一个响应头,当前请求的$_COOKIE不会立刻变化。 - 删除 Cookie 是把过期时间设为过去:
<?php
setcookie("theme", "", ['expires' => time() - 3600, 'path' => '/']);
unset($_COOKIE['theme']);
- 单条 Cookie 上限约 4KB,超出会被浏览器丢弃(表现是"偶发丢失")。别往 Cookie 里塞 JSON。
- Cookie 的
domain不要乱设。设成.example.com会让所有子域共享,一个子域被攻破,全站受影响。
Cookie 的三个安全属性,逐个说清
| 属性 | 作用 | 不设的后果 |
|---|---|---|
HttpOnly |
禁止 JS 通过document.cookie 读取 |
XSS 一旦发生,会话直接被偷走 |
Secure |
只在 HTTPS 连接中发送 | 明文网络下被中间人截获 |
SameSite |
限制跨站请求携带 | CSRF 攻击 |
SameSite 三个值:
Strict:跨站请求一律不带。最安全,但从外部链接跳进来时"登录状态看起来丢了"。Lax:GET 之类的顶层导航会带,POST 跨站不带。这是浏览器的默认值,绝大多数站点用这个。None:都带。必须同时有Secure,否则浏览器直接拒绝。
Session 的最小可用写法
<?php
session_start();
$_SESSION['user_id'] = 42;
$_SESSION['login_at'] = time();
// 读取
if (!isset($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
session_start() 做了三件事:读 session id(默认来自名为 PHPSESSID 的 Cookie)、按 id 加载服务端数据到 $_SESSION、发一个新的 session cookie。
session_start() 之前不能有任何输出,理由和 setcookie 一样。
登录成功后必须做的一件事
<?php
session_start();
if (password_verify($input, $hash)) {
session_regenerate_id(true); // 换一个新的 session id,删掉旧的
$_SESSION['user_id'] = $uid;
}
不做 session_regenerate_id() 就是"会话固定攻击(Session Fixation)":攻击者先拿到一个 session id(比如诱导你访问带 ?PHPSESSID=xxx 的链接),等你登录后,这个 id 就成了你的已登录会话,攻击者拿着同一个 id 直接进来。
true 参数表示"删除旧会话文件",一定要传。
完整退出
<?php
session_start();
$_SESSION = []; // 清空数据
if (ini_get("session.use_cookies")) {
$p = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 42000,
'path' => $p['path'],
'domain' => $p['domain'],
'secure' => $p['secure'],
'httponly' => $p['httponly'],
'samesite' => 'Lax',
]);
}
session_destroy(); // 销毁服务端数据
三步都要做。只 session_destroy() 不删 Cookie,浏览器下次还会带同一个 id(虽然服务端查不到,但会新建一个空会话)。
Session 锁:一个让人抓狂的性能问题
PHP 默认的 Session 用文件存储,同一个人(同一个 session id)的并发请求会被串行化——因为要加文件锁。
现象:页面里同时发 3 个 AJAX 请求,全都卡住,一个一个返回,总耗时是三个之和。
解法的核心是:不需要写 Session 的请求,尽快 session_write_close() 释放锁。
<?php
session_start();
$userId = $_SESSION['user_id']; // 读到需要的数据
session_write_close(); // 立刻释放锁
// 后面慢的活儿(查数据库、调外部接口)不再阻塞其他请求
$data = slowQuery($userId);
这是"PHP 页面明明很快,但并发一高就卡"的经典原因,很多人排查半天才找到。
Session 配置清单
; 基本
session.save_handler = files
session.save_path = "/var/lib/php/sessions" ; 不要用全世界可写的 /tmp
session.gc_maxlifetime = 1440 ; 24 分钟不活动就过期
session.use_strict_mode = 1 ; 拒绝未初始化的 session id
session.use_only_cookies = 1 ; 不从 URL 取 session id
session.cookie_httponly = 1
session.cookie_secure = 1 ; 全站 HTTPS 时开
session.cookie_samesite = "Lax"
session.sid_length = 48
session.sid_bits_per_character = 6
session.use_only_cookies = 1 必须开。否则 PHP 会接受 URL 里的 ?PHPSESSID=xxx,会话固定攻击就变得极其容易。
session.use_strict_mode = 1 也建议开:它拒绝"服务端不认识的 session id",从根上堵住会话固定。
session.save_path 必须独立。共享主机上用 /tmp 意味着同一台机器上别的站点可能读到你的会话文件(历史上出过真事)。
也可以用代码设置(在 session_start() 之前):
<?php
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
ini_set('session.use_strict_mode', '1');
session_start();
多台服务器怎么办
Session 文件存在本地,两台机器之间不共享,用户一会儿登录一会儿掉线。
三种方案:
- 粘性会话(sticky):负载均衡按 Cookie 把同一用户固定到同一台。简单,但机器挂了就丢。
- 共享存储:
session.save_handler = redis(需要redis扩展):
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?auth=xxx&database=2"
- 自己实现
SessionHandlerInterface,把 session 存到 Redis/数据库,完全可控:
<?php
class RedisSessionHandler implements SessionHandlerInterface
{
public function __construct(private Redis $redis, private int $ttl = 1440) {}
public function open(string $path, string $name): bool { return true; }
public function close(): bool { return true; }
public function read(string $id): string|false
{
return $this->redis->get("sess:{$id}") ?: '';
}
public function write(string $id, string $data): bool
{
return $this->redis->setex("sess:{$id}", $this->ttl, $data);
}
public function destroy(string $id): bool
{
$this->redis->del("sess:{$id}");
return true;
}
public function gc(int $max_lifetime): int|false
{
return 0; // Redis 的 TTL 已经处理了过期
}
}
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('your-password');
$handler = new RedisSessionHandler($redis, ttl: 1440);
session_set_save_handler($handler, true);
session_start();
session_set_save_handler 之后,open/close/read/write/destroy/gc 的返回类型必须严格匹配 SessionHandlerInterface,返回值写错会直接抛 TypeError。这是自定义 Session 存储最容易出错的地方:read 必须返回 string(哪怕是空串),不能返回 null。
什么放 Session,什么放 Cookie
| 数据 | 放哪 | 理由 |
|---|---|---|
| 登录用户 id | Session | 不能让客户端改 |
| 权限、角色 | Session(登录时算一次) | 每次请求都要用 |
| 购物车(未登录) | Session | 不能让客户端伪造价格 |
| 用户偏好(主题、语言) | Cookie 就行 | 被改了也无所谓 |
| "记住我"的令牌 | Cookie(配合服务端校验) | 见下 |
| 一次性验证码 | Session | 必须服务端可控 |
"记住我"的正确做法
不要把 user_id 直接写进 Cookie(能改)。标准做法是双令牌:
<?php
// 登录成功时
$selector = bin2hex(random_bytes(12)); // 用于查询
$validator = bin2hex(random_bytes(32)); // 用于校验
$hash = hash('sha256', $validator);
// 存库:selector -> (user_id, hash, expires) 只存 hash,不存明文
setcookie('remember', "{$selector}:{$validator}", [
'expires' => time() + 30 * 86400,
'httponly' => true,
'secure' => true,
'samesite' => 'Lax',
]);
校验时用 selector 查库,用 hash_equals() 比对 validator 的哈希,成功后立刻轮换新令牌(一次性使用)。
坑清单
setcookie/session_start之前有输出(含 BOM、空格),报headers already sent。- Cookie 只设了
HttpOnly没设Secure,在 HTTP 下明文传输。 - 登录后不
session_regenerate_id(true),会话固定。 session.use_only_cookies = 0,接受 URL 里的 session id。- 慢请求不
session_write_close(),同一用户的并发请求全被串行化。 - 只
session_destroy(),没删 Cookie。 - Cookie 里塞超过 4KB 的数据,被浏览器静默丢弃。
- 把用户 id / 权限直接放 Cookie 里当"状态"。
- Session 文件放
/tmp,共享主机上可被其他站点读取。 - 多机部署还用本地文件存 Session,用户反复掉线。