密码安全一直都是信息安全中很容易忽视的地方,那么在 PHP 中如何安全的保存密码与进行密码验证呢?接下来解释下使用散列函数对密码进行安全处理背后的原因,以及在 PHP 中如何有效的进行密码散列处理。

1. 介绍

1.1 为什么需要把应用程序中用户的密码进行散列化?

当设计一个需要接收用户密码的应用时,对密码进行散列是最基本的,也是必需的安全考虑。如果不对密码进行散列处理,那么一旦应用的数据库受到攻击,用户的密码将被窃取。同时,窃取者也可以使用用户账号和密码去尝试其他的应用,如果用户没有为每个应用单独设置密码,那么将面临风险。

通过对密码进行散列处理,然后再保存到数据库中,这样就使得攻击者无法直接获取原始密码,同时还可以保证你的应用可以对原始密码进行相同的散列处理,然后对比散列结果。

需要着重提醒的是,密码散列只能保护密码不会被从数据库中直接窃取,但是无法保证注入到应用中的恶意代码拦截到原始密码。

1.2 为何诸如 md5() 和 sha1() 这样的常见散列函数不适合用在密码保护场景?

MD5,SHA1 以及 SHA256 这样的散列算法是面向快速、高效进行散列处理而设计的。随着技术进步和计算机硬件水平的提升,破解者可以使用「暴力」方式来寻找散列码所对应的原始数据。

因此,现代化计算机可以快速的「反转」上述散列算法的散列值,所以很多安全专家强烈建议不要在密码散列中使用这些散列算法。

1.3 如果不建议使用常用散列函数保护密码,那么应该如何对密码进行散列处理?

当进行密码散列处理的时候,有两个必须要考虑的因素:计算量以及「盐」。散列算法的计算量越大,暴力破解所需要的时间就越长。

PHP 5.5 提供了一个 原生密码散列 API,它提供一种安全的方式来完成密码 散列验证。PHP 5.3.7 及后续版本中都提供了一个 纯 PHP 的兼容库

PHP 5.3 及后续版本中,还可以使用 crypt() 函数,它支持多种散列算法。 针对每种受支持的散列算法,PHP 都提供了对应的原生实现,所以在使用此函数的时候,你需要保证所选的散列算法是你的系统所能够支持的。

当对密码进行散列处理的时候,建议采用 Blowfish 算法,这是密码散列 API 的默认算法。相比 MD5 或者 SHA1,这个算法提供了更高的计算量,同时还有具有良好的伸缩性。

如果使用 crypt() 函数来进行密码验证,那么你需要选择一种耗时恒定的字符串比较算法来避免时序攻击(就是说,字符串比较所消耗的时间恒定,不随输入数据的多少变化而变化)。PHP 中的 ===== 操作符和 strcmp() 函数都不是耗时恒定的字符串比较,但是 password_verify() 可以帮你完成这项工作。我们鼓励你尽可能的使用 原生密码散列 API

1.4 「盐」是什么?

加解密领域中的「盐」是指在进行散列处理的过程中加入的一些数据,用来避免从已计算的散列值表(被称作「彩虹表」)中对比输出数据从而获取明文密码的风险。

简单而言,「盐」就是为了提高散列值被破解的难度而加入的少量数据。现在有很多在线服务都能够提供计算后的散列值以及其对应的原始输入的清单,并且数据量极其庞大。通过加「盐」就可以避免直接从清单中查找到对应明文的风险。

如果不提供「盐」,password_hash() 函数会随机生成「盐」。非常简单,行之有效。

1.5 应该如何保存「盐」?

当使用 password_hash() 或者 crypt() 函数时,「盐」会被作为生成的散列值的一部分返回。你可以直接把完整的返回值存储到数据库中,因为这个返回值中已经包含了足够的信息,可以直接用在 password_verify()crypt() 函数来进行密码验证。

下图展示了 crypt()password_hash() 函数返回值的结构。如你所见,算法的信息以及「盐」都已经包含在返回值中,在后续的密码验证中将会用到这些信息。

file

2. 相关函数

2.1 散列

/**
 * 创建密码的散列(hash)
 *
 * @param string $password 用户的密码
 * @param int 一个用来在散列密码时指示算法的密码算法常量
 * @param array 包含选项的关联数组,省略时,将使用随机盐值和默认 cost
 * @return string|false
 */
string password_hash(string $password, int $algo [, array $options])

返回散列后的密码,或者在失败时返回 FALSE

使用的算法、cost 和盐值作为散列的一部分返回。所以验证散列值的所有信息都已经包含在内。这使 password_verify() 函数验证的时候,不需要额外储存盐值或者算法的信息。

password_hash() 使用足够强度的单向散列算法创建密码的散列。password_hash() 兼容 crypt()。所以,crypt() 创建的密码散列也可用于 password_hash()

当前支持的算法:

  • PASSWORD_DEFAULT - 使用 bcrypt 算法(PHP 5.5.0 默认)。注意,该常量会随着 PHP 加入更新更高强度的算法而改变。所以,使用此常量生成结果的长度将在未来有变化。因此,数据库里储存结果的列可超过 60 个字符(最好是 255 个字符)。
  • PASSWORD_BCRYPT - 使用 CRYPT_BLOWFISH 算法创建散列。这会产生兼容使用 $2y$crypt()。 结果将会是 60 个字符的字符串,或者在失败时返回 FALSE
  • PASSWORD_ARGON2I - 使用 Argon2 散列算法创建散列。

2.2 校验

/**
 * 验证密码是否和指定的散列值匹配
 *
 * @param string $password 用户的密码
 * @param string $hash 一个由 password_hash() 创建的散列值
 * @return bool
 */
bool password_verify(string $password, string $hash)

如果密码和散列值匹配则返回 TRUE,否则返回 FALSE

注意 password_hash() 返回的散列包含了算法、cost 和盐值。因此,所有需要的信息都包含在内。使得验证函数不需要存储盐值等信息即可验证哈希。

时序攻击(timing attacks)对此函数不起作用。