luokuning / blogs

翻译,随笔,以及懒得整理……
81 stars 2 forks source link

如何安全的存储用户密码 #9

Open luokuning opened 7 years ago

luokuning commented 7 years ago

在我走上编程这条路之前,有个问题困扰了我很久: 为什么每次忘记某个账号的登录密码并且点击“忘记密码”试图找回原密码的时候,都永远只会让我重新填写一个新密码,而不是告诉我原来的密码是什么?很多时候我只想知道之前的密码,并不想再想出一个新的密码,为什么就是不告诉我?

直到正式开始学习编程一段时间后,突然想起这个问题才发现,原来这个问题我还是没搞明白。为了解决当初跟我有同样困惑的同学,今天我们就来仔细讨论一下这个问题。

直接存储明文密码

假如某个用户在你的网站上注册了一个账号 lk@gmail.com,密码为 abc654321

可能有些同学会觉得存储密码最直接的方式可以采用类似这样的表结构 :

username password
lk@gmail.com abc654321

这种方法简单直接,而且完全可以满足用户想找回原密码的需求。但是,如果某个黑客获得了你数据库的访问权限,那么他就能知道每个用户对应的密码明文。很多用户在多个网站上注册的账号用的都是同样的邮箱和密码,这样相当于黑客轻易就知道了这个用户多个网站的的信息。所以基本上没有谁会采用这种方式存储密码。

存储密码的哈希值

哈希算法是个好东西,由于它不可逆的特性,我们可以存储用户密码的哈希值,类似这样:

username password
lk@gmail.com 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8

登录验证的时候比较密码的哈希值: return sha1(password) == user[sha1_password](更好的建议是使用 sha256)。

可以看到这个方法比直接存储明文密码要更安全,黑客无法直接知道原始密码是什么,因为无法通过哈希值反向计算出原始密码。但是,想要知道原始密码并不需要可逆的运算,黑客可以不断的计算某个值的哈希值并且与目的哈希值比较,如果相同,那么表示这个值就是原始密码。

可能你会觉得这么多密码怎么可能计算得过来,但是要知道很多人的密码都是很简短的一些有规律的数字和英文单词组成的,而且很多哈希函数比如 sha1 计算速度相当快,加之 cpu 的速度越来越快,现在普通的电脑可以一秒完成上亿次计算。(前几年可能还有人用彩虹表这种攻击方式,就是事先计算好一大堆数据的散列,然后循环比较,但是现在的 cpu 计算速度已经比过去翻了很多番,已经很少有人用这种方式了)。

而且需要注意的一点是,这个方法是同时在破解你存储的所有用户密码,所以当你数据库中存储的用户越多,黑客就越容易匹配到冲突的哈希值。

2012 年的时候 LinkedIn 就被泄露了一大批用户密码的 sha1 值,其中大多数密码都可以被轻易破解。

所以只存储用户密码的简单哈希值还是不够安全。

存储密码的加盐(固定)哈希

存储的密码值类似这样:

username password
lk@gmail.com sha1("salt123456789" + password_1)
john@163.com sha1("salt123456789" + password_2)

注意上面的 sha1("salt123456789" + password_1) 是表示算法,表中 password 实际存储的是算法的值。

由于在计算密码哈希值的时候使用的是加盐算法(这个“盐”是固定的,很长的一段无序字符串), 所以黑客已经没办法使用彩虹表进行攻击了。

但是假如黑客攻破了你的数据库,那么很可能他也能知道你加的盐是什么(永远不要相信你存储的数据是安全的),所以这时候其实跟普通哈希无异。而且就算黑客无法知道盐是什么,他还是能用破解普通哈希值一样的方法来破解加盐哈希,只不过要多猜一个“盐”值。

可以看出固定加盐哈希还是不够安全。

存储密码的随机加盐哈希

由固定加盐哈希衍生出来的一种更为安全的办法就是,为每一个密码都分配一个单独的盐,所以存储的结构类似这样:

username salt password
lk@gmail.com 2dc7fcc... sha1("2dc7fcc..." + password_1)
john@163.com afadb2f... sha1("afadb2f..." + password_2)

随机加盐哈希看起来跟固定加盐哈希差别不大,算法本质上是一样的,只不过是所有的密码不再共享一个固定的盐,而是每个密码都有一个对应的随机盐。这样的好处是黑客破解密码哈希值的时候没办法同时破解所有用户的密码,因为每个密码对应的盐都不一样,每个密码的破解过程都是独立的。也就是说就算你的库里存了很多用户密码,黑客也不可能通过某个盐循环计算命中多个用户(回顾上面说的普通加盐哈希的破解过程)。

但是归根结底这还是计算量的问题,现在的 cpu 计算速度已经相当快了,而且利用集群和云计算的能力,破解密码会越来越容易。

bcrypt 算法

普通哈希函数最初的设计目的并不是用来存储密码的,要真正安全地存储密码还是要用到专为这件事设计的哈希算法。这些哈希函数不仅具有唯一、哈希单向不可逆等特性,它们还被设计的“很慢”。

这些函数中一个典型的例子就是 bcrypt 函数,这个函数计算的伪代码类似这样:

hash = bcrypt(plain_password, gensalt(log_rounds))

log_rounds 参数: 工作因子(work factor),表示的是这个算法的计算量或者快慢程度。在 log_rounds 等于 12 的情况下,这个算法计算一个密码的哈希值可能需要大概 100ms,可能你会觉得很快,但是要知道这是计算一次 sha1 耗时的 10000 倍。也就是说假如黑客破解一个 sha1 算法生成的哈希值需要 1 分钟,那么破解 bcrypt 算法生成的哈希值就大概需要 7 天。

简单来说 bcrypt 算法就是重复计算内部的加密(散列)函数很多次(类似的算法还有PBKDF2),所以减慢了整体运算速度。如果你想仔细了解一个 bcrypt 的算法,可以点这里

注意上面的 log_rounds 这个参数是可配置的,而且值每增加 1 表示的是多 10 倍的计算量(对数式的)。所以如果你觉得 100ms 还是太快了,可以把 log_rounds 设置为 13,这样一次计算大概耗时 +1s, 哦不是加,就是 1s。突然想念两句诗...

由于这种算法很慢,确实非常慢,所以似乎被淘汰的彩虹表又可以利用起来。但是 bcrypt 算法是内置就支持“每个密码一个不同的盐”,所以彩虹表是没有用的。说到底,破解密码只是时间的问题,如果时间很长,那么就变成了不可能。

Mozilla 的密码存储

Mozilla 在这篇文章里介绍了他们使用的密码存储算法: password -> bcrypt(HMAC(password, local_salt), gensalt(log_rounds)),这其实是利用了两个算法的组合。

跟直接使用 bcrypt 的区别在于,这个算法首先还对密码进行了一次 HMAC 计算。Mozilla 解释这样的好处是: 用于计算 HMAC 算法的 local_salt 是直接存储在服务器而不是数据库中的,所以如果黑客攻破了数据库,他还需要再攻破服务器取得 local_salt 的值才行。

虽然听起来更可靠,但我还是觉得跟直接计算 bcrypt 比起来,HMAC 这一步有点多余。

结语

上面说了这么多,总结起来就是: 如果你想要安全的存储用户的密码,非常重要的一点是选择一个“慢”的密码存储算法,比如 bcrypt。 但是最重要的一点是,最好提示你的用户能够选择复杂一点的密码,类似 16 位以上的无序字母和数字。假如某个密码是 123456 或者 654321,那么没什么算法能够保证它是安全的,因为黑客总是从最简单最常用的一些密码列表开始匹配。

i-change-all-my-passwords-to-incorrect-so-whenever-i-forget-it-says-your-password-is-incorrect

vcxiaohan commented 7 years ago

你好,我一直有个问题,bcrypt每次加密后的hash都是不同的,为什么验证的时候它能知道这些不同的hash值对应的密码原文是同一个呢?

luokuning commented 7 years ago

@vcxiaohan 其实 bcrypt 算法生成的加密文本已经包含了计算用的 salt。所以给定一个密文和密码,bcrypt 可以通过密文获取对应的 salt,然后再对密码进行相同的运算,如果生成的结果与老密文相同,则表示密码是一样的。

举个栗子,假如一个密文是 $2a$10$vI8aWBnW3fID.ZQ4/zo1G.q1lRps.9cGLcZEiGDMVr5yUP1KUOYTa, 那么通过 $ 分隔符我们可以得到下面三个信息:

  1. 2a 表示的是用于此次计算的 bcrypt 算法版本;
  2. 10 表示的是 log_rounds 值;
  3. vI8aWBnW3fID.ZQ4/zo1G.q1lRps.9cGLcZEiGDMVr5yUP1KUOYTa 是 salt 和加密文本的拼接值 (经过了 base 64 编码,前面 22 个字母是 salt 的十六进制值。
vcxiaohan commented 7 years ago

http://www.imooc.com/ 这个网站在前端也做了加密,每次传的password字段值也不一样,有点好奇原理

luokuning commented 7 years ago

你说的值不一样是指在浏览器控制台看到方法传的参数值呢还是指抓包看到网络上的实际传输值呢?

一般来说网站用的 HTTPS 协议的话是不需要前端传输的时候再加密了。

vcxiaohan commented 7 years ago

我在想前端有没有必要加密密码,我看它这么网站,点击登录后,在跳出来的弹框中输入账号、密码,你打开控制台,点击登录按钮,可以看到前端传过去的是被加密过的字符串,多点击几次,每次传的值都不一样

luokuning commented 7 years ago

前端脚本没有必要对密码加密,重要的是传输过程中的数据需要加密,所以会用 HTTPS 协议来加密传输敏感的数据。

vcxiaohan commented 7 years ago

好的,谢啦

hack2012 commented 4 years ago

你好,我一直有个问题,bcrypt每次加密后的hash都是不同的,为什么验证的时候它能知道这些不同的hash值对应的密码原文是同一个呢?

我也同样有这个问题,看了网上的bscrypt算法,都是明文密码和密文对比,这样无法保证传输过程中明文密码安全性。

hack2012 commented 4 years ago

http://www.imooc.com/ 这个网站在前端也做了加密,每次传的password字段值也不一样,有点好奇原理

看上去像是rsa加密