PHP 密码哈希与 password_hash 最佳实践

0 阅读5分钟

1. 为什么不能用 md5 / sha1

很多早期系统把用户密码直接丢给 md5() 或 sha1() 存库,看起来"已经加密了",实际上这是最危险的做法之一。

核心问题有三个:

  • 速度太快:MD5/SHA1 是为校验数据完整性设计的,单次计算在微秒级。现代 GPU 每秒可以计算数十亿次 MD5,配合字典和规则,弱密码几乎瞬间被还原。
  • 已被攻破:MD5 和 SHA1 都存在实际可行的碰撞攻击,早已不适合任何安全场景。
  • 无盐值:相同密码得到相同哈希,攻击者可以用彩虹表批量反查,一次泄露就能"一锅端"。

下面这段代码是典型的反面教材,请务必避免:

// ❌ 千万不要这样写
$hash = md5($password);
$hash = sha1($password . 'static_salt'); // 固定盐同样不安全

即使加上固定盐,也只是提高了攻击者的一次性成本,无法抵御针对整库的批量破解。

2. password_hash 与 password_verify

PHP 5.5 起内置了 password_hash() 和 password_verify(),它们封装了加盐、算法选择和参数编码,是官方推荐的标准做法。

<?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
$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() 等预处理,否则用户输入会被悄悄改变。

整个注册与登录流程可以概括为下图:

flowchart TD
  A[用户提交密码] --> B[password_hash 生成哈希]
  B --> C[哈希串存入数据库]
  C --> D[用户登录提交密码]
  D --> E[按用户名查询哈希]
  E --> F{password_verify 校验}
  F -->|通过| G[登录成功]
  F -->|失败| H[统一提示用户名或密码错误]

3. bcrypt 与 argon2 的选择

PHP 目前支持三种算法常量:

常量算法说明
PASSWORD_BCRYPTbcrypt成熟稳定,兼容性最好,最大输入 72 字节
PASSWORD_ARGON2IArgon2i抗侧信道,需编译时启用
PASSWORD_ARGON2IDArgon2id推荐,兼顾抗侧信道与抗 GPU
PASSWORD_DEFAULT当前默认目前指向 bcrypt,未来可能变化

选择建议:

  • 通用场景:直接用 PASSWORD_DEFAULT,让 PHP 跟随官方推荐演进。
  • 新项目且环境支持:优先 PASSWORD_ARGON2ID,它对 GPU 和 ASIC 破解的抵抗力更强。
  • 需要跨语言/跨系统兼容:bcrypt 生态最广,几乎所有语言都有成熟实现。

注意 bcrypt 的 72 字节限制:超长密码会被截断。如果允许用户设置超长密码,可以先做一次 SHA-256 再交给 bcrypt,但更简单的做法是限制密码长度上限。

<?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
// 默认 cost = 12(PHP 8.4 起),可根据服务器性能调整
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

Argon2 则通过 memory_cost、time_cost、threads 三个参数共同决定强度,其中内存成本对 GPU 破解的抑制效果最明显。

调整前先做一次基准测试:

<?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
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 延迟,必要时临时降低成本参数。
  • 不要降级:一旦升级到更高成本,不要为了性能回退,否则等于自毁防线。

惰性重新哈希的判定流程如下:

flowchart TD
  A[用户登录] --> B{password_verify 通过}
  B -->|否| C[返回登录失败]
  B -->|是| D{password_needs_rehash 判断}
  D -->|否| E[直接登录成功]
  D -->|是| F[password_hash 生成新哈希]
  F --> G[UPDATE users 更新密码字段]
  G --> E

6. 小结

密码存储的核心只有一句话:用专门的密码哈希算法,而不是通用摘要算法。

  • 拒绝 MD5/SHA1,它们太快、无盐、已被攻破。
  • 用 password_hash() 生成、password_verify() 校验,不要自己拼盐。
  • 新项目优先 Argon2id,通用场景用 PASSWORD_DEFAULT。
  • 成本参数以单次 100~300ms 为基准,结合服务器性能调整。
  • 用 password_needs_rehash() 在登录时惰性升级,平滑迁移存量哈希。