PHP 语法进阶 20|密码、随机数与加密:三条不能越过的线
第一条线:密码只能"哈希",不能"加密"
<?php
// ✗ 这三种都别用
$bad1 = md5($password);
$bad2 = sha1($password);
$bad3 = hash('sha256', $password . "固定盐");
问题不在"哈希"本身,而在速度。MD5 一秒能算几亿次,SHA-256 也是。拿到数据库后,用 GPU 跑字典,八位数字密码几分钟就全出来了。
密码哈希函数的设计目标恰恰是"慢"。PHP 内置了正确的实现:
<?php
$hash = password_hash($password, PASSWORD_DEFAULT);
// 输出类似:$2y$10$wLgT9m... (bcrypt,cost=10)
PASSWORD_DEFAULT 目前是 bcrypt,未来 PHP 升级时可能切到 argon2。用 PASSWORD_DEFAULT 意味着"自动跟随 PHP 的推荐算法",这是最省心的选择。
想显式指定:
<?php
// bcrypt,cost 越高越慢(默认 10,每 +1 耗时翻倍)
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// Argon2id(需要 PHP 编译时带 libargon2,7.2+)
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4,
'threads' => 2,
]);
Argon2id 是当前最推荐的算法(抗 GPU、抗侧信道),bcrypt 依然安全可用。选哪个看环境是否支持。
校验
<?php
if (password_verify($input, $storedHash)) {
// 密码正确
}
password_verify 会从哈希串里读出盐和参数,不需要你额外存盐。哈希串自带全部信息,这是它比"md5 + 自己拼盐"高明的地方。
升级算法用 password_needs_rehash
<?php
if (password_verify($input, $row['password'])) {
if (password_needs_rehash($row['password'], PASSWORD_DEFAULT, ['cost' => 12])) {
$newHash = password_hash($input, PASSWORD_DEFAULT, ['cost' => 12]);
// UPDATE users SET password = ? WHERE id = ?
}
// 登录成功
}
这段代码实现了"用户下次登录时自动升级哈希强度"。调高 cost、换算法,都不用强制所有人改密码。
bcrypt 的 72 字节限制
<?php
$long = str_repeat('a', 100);
var_dump(password_verify($long, password_hash($long, PASSWORD_BCRYPT))); // true
// 第 73 个字符之后被忽略——但验证还是通过,因为两边都截断了
bcrypt 只看前 72 个字节。超长密码的尾部是无效的。标准做法是先做一次摘要:
<?php
$prepared = base64_encode(hash('sha384', $password, true)); // 固定 64 字节
$hash = password_hash($prepared, PASSWORD_DEFAULT);
// 校验时同样先 prepare
这样既不丢熵,也不会撞上限。不过对于普通业务,直接限制密码长度 ≤ 72 字节更简单。
第二条线:随机数必须用密码学安全的源
<?php
// ✗ 全部不安全
rand(1, 100);
mt_rand(1, 100);
uniqid(); // 基于微秒时间
array_rand($arr);
str_shuffle("abcdef"); // 内部用 rand
这些函数的种子可预测,输出可被推算。用于 token、验证码、密码重置链接、抽奖、优惠券码,全部不合格。
<?php
// ✓ 密码学安全
$bytes = random_bytes(32); // 32 字节随机
$token = bin2hex($bytes); // 64 位十六进制字符串
$code = random_int(100000, 999999); // 6 位数字验证码
random_bytes() 在底层调用 getrandom() / /dev/urandom,失败会抛异常(不会静默返回弱随机)。
判断标准:"如果攻击者能猜到这个值,会造成损失吗?" 会,就必须用 random_bytes / random_int。
生成 URL 安全的令牌
<?php
function urlSafeToken(int $bytes = 32): string
{
return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
}
这种格式可以直接放进 URL、Cookie、JSON 里,不用再转义。
第三条线:比较敏感值要用 hash_equals
<?php
// ✗ 危险的比较
if ($inputToken === $storedToken) {
// ...
}
普通字符串比较是逐字节短路的:第 1 位不同就立刻返回。攻击者可以通过测量响应时间,一位一位地试出正确值——这是"时序攻击(timing attack)"。
<?php
// ✓ 常数时间比较
if (hash_equals($storedToken, $inputToken)) {
// ...
}
hash_equals() 无论内容差多少,耗时都一样。所有令牌、签名、HMAC、API Key 的比较都必须用它。
注意参数顺序:hash_equals($known, $user),第一个是你自己的值。虽然函数本身对顺序不敏感(长度不同会直接返回 false),保持这个习惯更清晰。
HMAC:签名的正确做法
<?php
function makeToken(array $claims, string $secretKey): string
{
$payload = json_encode($claims, JSON_UNESCAPED_UNICODE);
$sig = hash_hmac('sha256', $payload, $secretKey, true);
return rtrim(strtr(base64_encode($payload . '.' . $sig), '+/', '-_'), '=');
}
拆解校验(用 strrpos 从右往保守找分隔符,避免 payload 里的点干扰):
<?php
function verifyToken(string $token, string $secretKey): ?array
{
$raw = base64_decode(strtr($token, '-_', '+/'), true);
if ($raw === false) return null;
$pos = strrpos($raw, '.');
if ($pos === false) return null;
$payload = substr($raw, 0, $pos);
$sig = substr($raw, $pos + 1);
// 关键:常数时间比对
if (!hash_equals(hash_hmac('sha256', $payload, $secretKey, true), $sig)) {
return null;
}
$data = json_decode($payload, true);
if (!is_array($data) || ($data['exp'] ?? 0) < time()) {
return null;
}
return $data;
}
先验签,再解析内容。顺序反了(先 json_decode 再用字段)就等于处理了未经验证的数据。
对称加密:只在必要时用
绝大多数场景用不到加密——密码用哈希,敏感字段该做的是"最小化存储"而不是"加密后存"。但确实需要可逆时:
<?php
function encryptString(string $plain, string $key): string
{
$cipher = 'aes-256-gcm';
$iv = random_bytes(12); // GCM 的 nonce,12 字节
$tag = '';
$ct = openssl_encrypt($plain, $cipher, $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($ct === false) {
throw new RuntimeException("加密失败");
}
return base64_encode($iv . $tag . $ct); // 把 iv 和 tag 一起带出去
}
function decryptString(string $encoded, string $key): string
{
$cipher = 'aes-256-gcm';
$raw = base64_decode($encoded, true);
if ($raw === false || strlen($raw) < 28) {
throw new RuntimeException("密文格式错误");
}
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$ct = substr($raw, 28);
$plain = openssl_decrypt($ct, $cipher, $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($plain === false) {
throw new RuntimeException("解密失败(可能被篡改或密钥不对)");
}
return $plain;
}
四条必须遵守的规则:
- 用 AEAD 模式(
aes-256-gcm、chacha20-poly1305)。它自带完整性校验,密文被改一个字节就会解密失败。不要用 ECB(相同明文块产生相同密文,等于泄露结构),CBC 也建议避开(需要额外的 MAC 防篡改)。 - 每次加密都用新的随机 IV/nonce。固定 IV 会让 GCM 的安全性彻底崩溃(同一个 key + 同一个 nonce 重复使用,能恢复出密钥流)。
- 密钥 32 字节,来自安全随机源,存在环境变量或密钥管理系统里。不要硬编码在代码里,不要提交进 Git,不要和密文存在同一个库里。
- IV 不需要保密(可以跟密文一起存),密钥绝对不能。
<?php
// 密钥从环境变量读,启动时校验长度
$key = base64_decode(getenv('APP_ENCRYPTION_KEY') ?: '', true);
if ($key === false || strlen($key) !== 32) {
throw new RuntimeException("APP_ENCRYPTION_KEY 必须是 base64 的 32 字节");
}
密码重置流程的正确实现
这是"随机数 + 哈希 + 一次性"的综合应用:
<?php
// 1) 生成
$rawToken = bin2hex(random_bytes(32)); // 发给用户
$hash = hash('sha256', $rawToken); // 存库
$pdo->prepare("UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?")
->execute([$hash, date("Y-m-d H:i:s", time() + 3600), $uid]);
// 2) 校验
$hash = hash('sha256', $_GET['token'] ?? '');
$st = $pdo->prepare("SELECT id FROM users WHERE reset_token = ? AND reset_expires > NOW()");
$st->execute([$hash]);
// 3) 用后立即作废
$pdo->prepare("UPDATE users SET reset_token = NULL WHERE id = ?")->execute([$id]);
要点:
- 库里只存 token 的哈希,不存明文。数据库泄露也无法直接用来重置密码。
- 一次性,用过立刻清空。
- 有时效,通常 30~60 分钟。
- 不要在邮件里同时给出用户名和密码。
常见的安全错误清单
- 用
md5/sha1/sha256直接存密码。 - 自己实现"加盐"逻辑,或所有用户共用一个固定盐。
- 用
rand()/mt_rand()生成 token。 - 用
===比较令牌,留下时序攻击空间。 - 加密用 ECB 模式,或固定 IV。
- 密钥写死在代码里 / 提交到 Git。
openssl_encrypt不传$tag,等于放弃了完整性校验。- 密码重置 token 存明文、可重复使用、无过期。
- 把"加密"当成"哈希"用(加密后还能解密出来,密码就不该能还原)。
一句话总结这三条线
- 密码:
password_hash+password_verify,用password_needs_rehash平滑升级。 - 随机:
random_bytes/random_int,别碰rand家族。 - 比较与签名:
hash_equals+hash_hmac;真需要加密就用 AEAD 模式 + 随机 IV + 密钥放环境。