在web开发中的一个常见问题是实现用户身份验证和访问控制,通常是通过注册和登录的形式来完成。尽管这些系统在理论上很简单,实现一个符合应用安全标准的系统是一项艰巨的任务。
没有大量的仔细和谨慎,认证系统可以像一个纸板柠檬水摊子在五级飓风下那样脆弱。然而,对于一切可能出的错,的确有一个有效(通常简单)的方式来实现更高水平的安全和弹性。
概要
1. 2015年密码安全存放方式
2. 正确实现持久认证(“记住我”选框和长期的cookies)
3. 账户恢复(“找回秘密”)
密码:HASH, SALTS 和Policies
那年是2004年。MD5会产生碰撞的消息传遍了大街小巷,说这个哈希算法必然会死亡云云。在那5年以前,Niels Provos在USENIX99上发布了bcrypt。PBKDF2的草案也公布了4年了。
你能相信2015的现在,Web开发人员仍然在保存密码时使用类似MD5和SHA1之类的快速哈希算法吗?安全专家很多年以前就确定这是个坏主意了。
合适的密码保存系统
现在只有4种密码哈希算法是被专业密码学家和安全研究人员认为是安全可靠的。
* Argon2 (密码哈希竞赛的赢家)
* bcrypt
* scrypt
* PBKDF2 (Password-Based Key Derivation Function #2 基于口令的密钥导出函数)
对于许多不能在Production环境下安装PECL的PHP开发人员来说,scrypt不在考虑范围内。如果你可以使用的话,请一定使用它。
在bcrypt和PBKDF2中选一个的话,应该选bcrypt。再者,应该使用现有的 password_hash() 和 password_verify() 函数而不是自己在 crypt() 的基础上自己再实现一遍。
bcrypt的局限性
开发人员应该记住bcrypt有两个缺点:它会将密码砍短到72个字长,NUL字节也一样被砍掉。很多开发人员尝试通过先HASH一次密码来解决这个72字长的限制,但是这样会触发第二个问题。下面这段代码就很危险:
$stored = password_hash(hash('sha256', $_POST['password'], true), PASSWORD_DEFAULT);// ...if (password_verify(hash('sha256', $_POST['password'], true), $stored)) { // Success :D} else { // Failure :(}
HASH结果里面有一定的几率是存在有0x00的字节。这个字节出现得越早,碰撞的几率就呈指数倍的增长。例如,1]W和@1$用SHA-256处理之后都以ab00开头。
解决方法是,将SHA-256的结果再base64_encode()一遍,之后再传给bcrypt:
$stored = password_hash( base64_encode( hash('sha256', $_POST['password'], true) ), PASSWORD_DEFAULT);// ...if (password_verify( base64_encode( hash('sha256', $_POST['password'], true) ), $stored)) { // Success :D} else { // Failure :(}
上面的例子不会将72字长以外的数值丢掉,而且是完全的二进制安全。所以早出现的null字节不会导致安全问题。两个问题都解决了。
要不要加胡椒?
有时候,开发人员想在原本的基础上增加一些难度。加胡椒来增加蛮力攻击的难度这个话题(PHP知道而数据库不知道的秘钥)在程序猿论坛里面讨论得相当频繁。就拿上面的例子来讲,加胡椒就是将hash('sha256', $_POST['password'], true)替换成
hash_hmac('sha256', $_POST['password'], CONSTANT_SECRET_KEY, true)
. 但是我们并不推荐这种方式。
胡椒并没有在password_hash()加salt之后生成给你的东西增加有益的安全辅助。如果你的数据库和Web应用在同一台机器上,一个能访问你的数据库的攻击者很可能离访问你的PHP代码不远了。最后,依赖一个静态的HMAC秘钥意味着永远不能轻易的改变这个秘钥,除非重设用户密码。
一个更好的而且非常有用的解决方法是,如果你采用的是数据库和代码分离在不同的机器的话,将hash的结果加密之后再存进数据库。
用这种方式的话,就算攻击者下载了你所有的数据,他们也得先解密得出你的hashes值,才能够尝试破解密码。代码和数据库分离的情况下,这种方法就非常的安全。
加密hash的有点在于,你可以解密然后用新的密码再加密存起来,而不必担心原本的值。
然而,话虽如此,请不要建立自己的加密库。我们墙裂推荐Defuse Security's PHP 加密库。
最后,我们的团队写了一个叫做PasswordLock的库,里面实现了上面所提到的Bcrypt-SHA2-Base64方法,并且用我们推荐的加密算法将结果数据包装了起来。举个栗子:
use \ParagonIE\PasswordLock\PasswordLock;define('PASSWORD_KEY', \hex2bin('0102030405060708090a0b0c0d0e0f10'));// Even better: use a configuration file stored outside your document root// and not checked into version control$store_me = PasswordLock::hashAndEncrypt($_POST['password'], PASSWORD_KEY);if (PasswordLock::decryptAndVerify($_POST['password'], $store_me, PASSWORD_KEY)) { // Success! :D} else { // Failure :(}
密码政策
谁需要他们
密码政策(特别是那些麻烦的条件)通常是没有采用合适的密码保存方式的死赠品。通常最好的密码政策是一开始就没有政策。
建立最低的要求没有关系(例如至少12个字段长),但是限制哪些字母不可以或者要求强制最大密码长度就不是了。通常来说,密码政策不应该强制最大长度,只需要强制最小长度。
一个给用户提供密码强度反馈的好方法是使用Dropbox的zxcvbn库。
特别要赞一下那些让用户知道密码管理工具的优点的Web应用(例如KeePass或者KeePassX).
合理的密码政策例子:
1. 密码长度必须在12~4096之间
2. 密码可以包含任何的字符(包括unicode)
3. 我们墙裂推荐使用像KeePass或者KeePassX这样的密码管理工具来生成和保存密码。
4. 你的zxcvbn密码强度必须在level 3以上 (一共4个等级)
这样就够了。不要告诉限制用户的密码内容。不要拒绝长密码。但是如果用户准备干蠢事的时候一定要阻止他们(例如使用密码1234567),但是除此之外不要做过多的干预。
“记住我” - 持久认证
短期的用户认证典型采用的是sessions,长期的认证就要依赖于一个不同于session id的长效的cookie。通常用户通过一个“在这台机器上记住我”的选框体验到这个功能。实现一个“记住我”的特性而不依赖于繁琐的开发需要一点点的技巧。
天真的解决方案:把账户信息存到cookie
任何像remember_user=1337这样的解决方案都会为滥用打开方便之门。因为通常管理猿账号都有一个很小的ID号,例如remember_user=1就能够顺利的冒充顶级用户登陆到系统中去。
持久认证的tokens
另外一个普遍使用的策略,更不容易受到攻击的方式,就是为记住用户生成一个唯一的token,将这个token保存在一个cookie里,然后在数据库里将这个token和用户对应起来。仍要会有一些可能出错的地方,但是这相对于前一种解决方案来说毫无疑问是个重大的改进。
问题1:没有足够的随机产生
虽然很多开发人员明白不可预测性对于安全的token来说的重要性,但是很多人并不知道怎么去达成这个目标。下面举一个不大常见的生成唯一token的代码:
function generateInsecureToken($length = 20){ $buf = ''; for ($i = 0; $i < $length; ++$i) { $buf .= chr(mt_rand(0, 255)); } return bin2hex($buf);}
mt_rand() 函数并不适合安全需求。如果你需要生成一个随机数值,你可以用以下的方法:
* RandomLib
* random_bytes($length)
* 从/dev/urandom里读出未加工的字节
* mcrypt_create_iv($length, MCRYPT_DEV_URANDOM)
* openssl_random_pseudo_bytes($length)
正确的姿势是:
function generateToken($length = 20){ return bin2hex(random_bytes($length));}
问题2:计时泄露
即使你使用了加密安全的随机数生成方式,你的cookie看起来像这个样子:rememberme=WBWgm2oMFxsiGRGQNJ6n8gtN3gOuQ2wjN8ZRjZtU0Mn,然后你在数据库里面这样保存和查询:
CREATE TABLE `auth_tokens` ( `id` integer(11) not null UNSIGNED AUTO_INCREMENT, `token` char(33), `userid` integer(11) not null UNSIGNED, `expires` integer(11), -- or datetime PRIMARY KEY (`id`));
SELECT * FROM auth_tokens WHERE token = 'WBWgm2oMFxsiGRGQNJ6n8gtN3gOuQ2wjN8ZRjZtU0Mn';
注意了,一个深奥不平凡的攻击仍然存在。(接下来这段译者尽量不直译试图将原文的意思表达清楚)
这样第一眼看上去没有什么不妥,但是这样泄露了时间信息,怎么说?
首先谈到一种攻击,叫做远程计时攻击。这是通过反复尝试得出处理时间信息从而推断出密码的一种方式。举个例子,当我们不知道要传进来的字母是什么的时候,我们会将这个字符的变量与"abcdefghijklmnopqrstuvwxyzABCDEFGHIJ...."逐位比对,匹配后马上返回,这样导致的结果是啥呢?如果我将每一个字符传进去,得到一个时间列表,那么当传进一个我们不知道是什么的字符的时候,只需要通过之前的表就可以回推传进去的字符。因为得到一个匹配的时间跟它在上面字串的位置有关,越往后需要的时间越长。
把这个逻辑应用到密码上面来是什么一种情况呢?假如,目标密码是'test123',那么如果第一位就错的情况下,程序返回就是最快的,那么我们只要构建一系列的axxxxx, bxxxxx...挑出时间最长的那个,便可以知道第一位字母是什么,如此类推。当然,时间长短需要用统计均值来表示,否则单次的结果并不能有决定性的指向。
回过头来看看上面定长的token,是不是在理想状态下也会受到同样的攻击。不过在硬件越来越发达的现在,这种攻击的难度也是挺大的。不过既然是关心安全问题,那么置之不理留下漏洞也不是我们所追求的。译者认为这个问题有个简单的解决方案,便是将token再hash一遍存起来就可以,这样一来计时统计就已经失效了。如果涉及的是完全可控制的代码层面的时候,简单的解决方法可以是匹配后不立即返回,而是每次坚持把所有的字符都匹配完之后再返回。本着科学的精神,译者接下来要回归原文的直译了。
前摄性安全的持久用户认证
接下来要介绍的是我们在一个Web应用里为“记住我”特性采用的一个策略,这个策略不会泄露任何信息包括计时信息,也就是每次查询时长都是一致的,而且查询仍然是高效的(避免拒绝服务攻击,俗称DOS攻击)。
我们建议的策略脱离了上面简单的token登陆策略,重要的一步在于:我们没有在cookie里保存token,而是保存了selector:validator。
selector是一个用于查询数据库的唯一ID,可以预防计时攻击,因为查询时间是一致的,这比使用id字段要好,因为id字段会泄露在线用户人数。
CREATE TABLE `auth_tokens` ( `id` integer(11) not null UNSIGNED AUTO_INCREMENT, `selector` char(12), `token` char(64), `userid` integer(11) not null UNSIGNED, `expires` datetime, PRIMARY KEY (`id`));
在数据库端,validator并没有保存进去,保存的是它的sha-256哈希值,在cookie里面selector和validator保存的都是明文,用这种方式,如果auth_tokens数据被泄露了,即时大面积的冒充用户就不会产生。
这个算法概括起来是:
1. 从cookie中分离selector和validator
2. 用selector去查询auth_tokens
3. 用sha-256计算validator的哈希值
4. 用hash_equals()函数来比对数据库里的值和刚才计算的哈希值
5. 如果以上都成功了,就可以将当前的session指向记录中的用户了
在这个博客发表以后,我们的策略在GateKeeper实现了,如果你需要一个即时的解决方案,可以看一下这个库。
重要事项:如果用户更改密码,所有当前的持久认证token都应该让其失效。
找回密码
让我们直说好了:重设密码功能就是一个后门。对于很多的应用和服务来说,他们都不合适而且也不应该实现。
通常来说,找回密码系统有两种问题:
1. 他们问很糟糕的安全问题,问题的答案通常对于用户来说不是私密的.
2. 他们依赖于很不可靠的第二个认证因素(例如给用户的手机或者邮箱发送一个随机的token)
安全提问的问题很清楚,第二种方法需要访问用户的手机或者邮箱,那么这就给攻击者攻击其他应用或者服务商这些相关的账户的机会。这很糟糕。
我们推荐这样做:
1. 如果你能提供帮助的话就不要提供后门。
2. 不要使用安全提问如果用户可能将答案公布在网络上
3. (可选)允许用户关联一个GnuPG公钥到他们的账户去。当接到一个账户恢复请求的时候,就用这个公钥将token加密发送给用户,那么只有拥有用户私钥的人才能够解密得到这个token。我们在自己的项目中就使用了这种方法。
如果你的确需要实现一个找回密码的后门(很多应用不管需不需要都提供),而且你的用户并没有那么专业,不会使用GnuPB,最好的方法就是生成一个随机token(像上面提到的那样使用加密安全随机函数),然后发到用户的邮箱去。当他们完成这一步,准许他们重设密码,永远别发他们的旧密码回去(也只有你不hash的时候才有可能)。如果你保存有他们的旧密码的话,实在称不上一个负责人的Web开发人员。
注意到发送敏感信息到邮箱意味着你必须相信STARTTLS协议,一个机会型加密,对于一般观察者来说没问题,但是对于专业攻击者来说就没什么用了。对此现在并没有标准的广泛使用的可靠的方案。
结语
即便你把所有的方案都应用上了,也不能避免用户自己犯安全错误。所以需要将所有的登录认证行为记录起来。
共同学习,写下你的评论
评论加载中...
作者其他优质文章