1. 为什么不能用 md5 / sha1
很多早期系统把用户密码直接丢给 md5() 或 sha1() 存库,看起来"已经加密了",实际上这是最危险的做法之一。
核心问题有三个:
- 速度太快:MD5/SHA1 是为校验数据完整性设计的,单次计算在微秒级。现代 GPU 每秒可以计算数十亿次 MD5,配合字典和规则,弱密码几乎瞬间被还原。
- 已被攻破:MD5 和 SHA1 都存在实际可行的碰撞攻击,早已不适合任何安全场景。
- 无盐值:相同密码得到相同哈希,攻击者可以用彩虹表批量反查,一次泄露就能"一锅端"。
下面这段代码是典型的反面教材,请务必避免:
php
// ❌ 千万不要这样写
$hash = md5($password);
$hash = sha1($password . 'static_salt'); // 固定盐同样不安全
即使加上固定盐,也只是提高了攻击者的一次性成本,无法抵御针对整库的批量破解。
2. password_hash 与 password_verify
PHP 5.5 起内置了 password_hash() 和 password_verify(),它们封装了加盐、算法选择和参数编码,是官方推荐的标准做法。
php
<?php
// 生成哈希:算法 + 成本参数
$hash = password_hash($password, PASSWORD_DEFAULT);
// 存入数据库(VARCHAR(255) 足够)
$stmt = $pdo->prepare('INSERT INTO users (username, password) VALUES (?, ?)');
$stmt->execute([$username, $hash]);
验证时不要自己比较字符串,而是用 password_verify():
php
<?php
$stmt = $pdo->prepare('SELECT id, password FROM users WHERE username = ?');
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) {
// 登录成功
} else {
// 统一返回"用户名或密码错误",避免泄露账号是否存在
}
几个关键点:
password_hash()会自动生成随机盐,并把它编码进结果字符串,无需自己管理盐。- 哈希结果长度约 60~100 字符,字段建议
VARCHAR(255)。 - 使用
password_verify()而不是==,它内部使用恒定时间比较,能抵御时序攻击。 - 不要对密码做
trim()、htmlspecialchars()等预处理,否则用户输入会被悄悄改变。
整个注册与登录流程可以概括为下图:
3. bcrypt 与 argon2 的选择
PHP 目前支持三种算法常量:
| 常量 | 算法 | 说明 |
|---|---|---|
PASSWORD_BCRYPT |
bcrypt | 成熟稳定,兼容性最好,最大输入 72 字节 |
PASSWORD_ARGON2I |
Argon2i | 抗侧信道,需编译时启用 |
PASSWORD_ARGON2ID |
Argon2id | 推荐,兼顾抗侧信道与抗 GPU |
PASSWORD_DEFAULT |
当前默认 | 目前指向 bcrypt,未来可能变化 |
选择建议:
- 通用场景 :直接用
PASSWORD_DEFAULT,让 PHP 跟随官方推荐演进。 - 新项目且环境支持 :优先
PASSWORD_ARGON2ID,它对 GPU 和 ASIC 破解的抵抗力更强。 - 需要跨语言/跨系统兼容:bcrypt 生态最广,几乎所有语言都有成熟实现。
注意 bcrypt 的 72 字节限制:超长密码会被截断。如果允许用户设置超长密码,可以先做一次 SHA-256 再交给 bcrypt,但更简单的做法是限制密码长度上限。
php
<?php
// 明确指定 Argon2id(需 PHP 7.2+ 且编译支持)
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4,
'threads' => 2,
]);
4. 成本参数调整
成本参数决定了单次哈希的计算量,是"安全"与"性能"之间的旋钮。原则是:让一次哈希耗时约 100~300 毫秒,既能拖慢暴力破解,又不至于让登录接口成为 DoS 入口。
bcrypt 的成本参数是 cost,取值 4~31,每加 1 计算量翻倍:
php
<?php
// 默认 cost = 12(PHP 8.4 起),可根据服务器性能调整
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
Argon2 则通过 memory_cost、time_cost、threads 三个参数共同决定强度,其中内存成本对 GPU 破解的抑制效果最明显。
调整前先做一次基准测试:
php
<?php
$start = microtime(true);
password_hash('benchmark', PASSWORD_DEFAULT);
$elapsed = (microtime(true) - $start) * 1000;
echo "单次哈希耗时:{$elapsed} ms\n";
如果耗时远低于 100ms,可以适当提高成本;如果超过 500ms,登录接口在高并发下会明显变慢,需要权衡。
5. 密码重新哈希策略
算法和成本参数会随时间演进,旧哈希需要平滑升级。password_needs_rehash() 就是为此设计的:
php
<?php
if ($user && password_verify($password, $user['password'])) {
// 登录成功后检查是否需要升级
if (password_needs_rehash($user['password'], PASSWORD_DEFAULT, ['cost' => 12])) {
$newHash = password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);
$stmt = $pdo->prepare('UPDATE users SET password = ? WHERE id = ?');
$stmt->execute([$newHash, $user['id']]);
}
// 登录成功
}
这套"登录时惰性升级"的策略有几个好处:
- 不需要强制用户改密码,体验无感。
- 只有用户真正登录时才付出升级成本,避免全表批量重算。
- 随着
PASSWORD_DEFAULT演进,存量数据会逐步迁移到新算法。
配套的工程实践:
- 迁移前备份:批量升级前先备份用户表,防止脚本异常导致数据损坏。
- 灰度推进:先在小流量环境验证升级逻辑,再全量上线。
- 监控耗时:升级期间关注登录接口 P99 延迟,必要时临时降低成本参数。
- 不要降级:一旦升级到更高成本,不要为了性能回退,否则等于自毁防线。
惰性重新哈希的判定流程如下:
6. 小结
密码存储的核心只有一句话:用专门的密码哈希算法,而不是通用摘要算法。
- 拒绝 MD5/SHA1,它们太快、无盐、已被攻破。
- 用
password_hash()生成、password_verify()校验,不要自己拼盐。 - 新项目优先 Argon2id,通用场景用
PASSWORD_DEFAULT。 - 成本参数以单次 100~300ms 为基准,结合服务器性能调整。
- 用
password_needs_rehash()在登录时惰性升级,平滑迁移存量哈希。