Menu

PHP password_hash() 和 password_verify():存储密码

用 password_hash($password, PASSWORD_DEFAULT) 对密码做哈希,保存这个 60 个字符的结果,登录时用 password_verify($password, $hash) 校验。本文讲解为什么每次的哈希都不同、cost、password_needs_rehash、Argon2id,以及为什么不能用 md5 和 sha1 存密码。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

用 password_hash($password, PASSWORD_DEFAULT) 存储密码,用 password_verify($password, $hash) 校验登录,它返回 true 或 false。永远不要存储密码本身,也永远不要用 md5() 或 sha1() 处理密码。

运行两次:每次的哈希都不同,但两个版本都能通过校验。本页所有的哈希都是如此,所以你的输出不会和别人的一样。

用 password_verify 实现登录

登录就是拿用户提交的密码,与创建账号时保存的哈希进行比对。下面的代码先“注册”一个用户,然后显示一个登录表单;先试试正确的密码(hunter2),再试一个错误的。

邮箱不存在和密码错误时,错误提示是一样的。如果说出具体是哪一个错了,攻击者就能知道哪些邮箱注册过账号。登录成功后,真实的网站会把用户 id 存进会话,见会话页面。

哈希里有什么

一个 bcrypt 哈希就是一个字符串,里面包含 password_verify() 所需的一切:算法、cost 和盐,后面跟着哈希值本身。password_get_info() 可以读出前面几部分。

因为盐存储在哈希中,所以数据库里只需要一列,而且两个密码相同的用户得到的哈希也不同。把这一列设为 VARCHAR(255):bcrypt 是 60 个字符,但 PASSWORD_DEFAULT 可能会换成输出更长的算法。

选择 cost

cost 决定每次哈希故意要慢到什么程度,每增加 1,工作量就翻一倍。PHP 8.3 中 bcrypt 的 cost 为 10(PHP 8.4 把默认值提高到了 12)。一次登录的哈希计算大约应花 100 到 300 毫秒;在你自己的服务器上测一下时间:

具体数字取决于机器,但每一行应该大约是上一行的两倍。cost 越高越能抵御破解,但每次登录也越慢,所以选择你的登录页面承受得起的最高 cost。

用 password_needs_rehash 升级旧哈希

当你提高 cost,或者 PHP 更改了 PASSWORD_DEFAULT 时,已有的哈希仍然可用,而 password_needs_rehash() 会告诉你哪些已经过时。只有在登录时你才拿得到明文密码,所以就在那里升级:

前缀 $2y$10$ 变成了 $2y$12$,下次登录时 password_needs_rehash() 会返回 false。

永远不要用 md5 或 sha1 存密码

md5() 和 sha1() 是速度很快的通用哈希,而且不加盐。对密码来说,快恰恰是错的:拿到泄露数据表的攻击者可以用 GPU 每秒尝试数十亿次猜测,而常见密码不加盐的哈希早就在公开的查询表里了。比较一下速度:

password_hash() 生成一个哈希的时间里,md5 能生成几十万个,所以破解者的每次猜测都便宜了同样多倍。搜索 5f4dcc3b5aa765d61d8327deb882cf99,你立刻就能找到单词“password”。哈希生成器每次都会把“password”变成同一个 md5。

如果你接手了一张存着 md5 哈希的表,没法直接转换它们。可以把它们包一层:存储 password_hash($oldMd5),用 password_verify(md5($input), $stored) 校验,然后在每个用户下次登录时换成正常的哈希。

Argon2id 和 72 字节限制

PASSWORD_ARGON2ID 每次哈希使用更多内存,这让 GPU 破解的成本高得多。PHP 构建时带有 libargon2 就可以使用它(大多数 Linux 软件包都带);用 defined('PASSWORD_ARGON2ID') 检查。

最后一行输出 bool(true):两个不同的密码匹配上了,因为 bcrypt 会忽略第 72 个字节之后的所有内容。真实的密码很少有这么长,但用日语、中文等多字节文字写的口令,大约 24 个字符就到 72 字节了。如果你允许很长的密码,就用 Argon2id,它没有这个限制。password_verify() 两种算法都支持,所以你可以像上面那样用 password_needs_rehash() 切换。

常见问题

为什么 password_hash 每次的结果都不一样?

每次调用都会生成一个新的随机盐,并把它存进哈希里,所以同一个密码每次得到的字符串都不同。这是有意为之:password_verify() 会从存储的哈希中读出盐,所以对正确的密码它仍然返回 true。

存 password_hash 的密码字段应该多长?

目前 PASSWORD_DEFAULT 生成的 bcrypt 哈希是 60 个字符,但默认算法在以后的 PHP 版本中可能会变。用 VARCHAR(255),这样更长的哈希也能放得下,不需要迁移数据库。

为什么 password_verify 总是返回 false?

常见原因有:哈希被截断(VARCHAR(50) 字段会截掉 60 个字符的哈希);第二个参数传的是重新调用 password_hash() 得到的值,而不是你存储的哈希;拿去比较的哈希是用 md5() 生成的;或者存储的值里多了空白字符。检查 strlen($hash) 是否为 60,并把明文密码作为第一个参数传入。

PHP 中可以解密 password_hash 的值吗?

不可以。哈希是单向的:没有任何函数能把它还原成密码。要校验密码,就对输入的密码做哈希再比较,这正是 password_verify() 做的事。用户忘记密码时,发送重置链接,而不是旧密码。

应该用 PASSWORD_DEFAULT、PASSWORD_BCRYPT 还是 PASSWORD_ARGON2ID?

通常选 PASSWORD_DEFAULT:它目前是 bcrypt,以后 PHP 可能会换成更强的算法,而 password_needs_rehash() 能让你逐步切换过去。PASSWORD_ARGON2ID 更能抵御 GPU 破解,只要你的 PHP 构建包含它,就可以放心使用。

Coddy 编程语言插图

和 Coddy 一起学编程

开始