你可能已经在你的某个应用中实现了认证,要么直接使用 Rails 的has_secure_password ,要么间接通过 Devise 这样的 gem。无论哪种方式,你都以某种方式保存了用户的密码。
那么密码是如何安全地存储在数据库中的呢?让我们来看看在Ruby on Rails这样的框架中安全保存用户密码的基本技术。
在Ruby on Rails的控制台中,如果你使用Devise,并且用密码查询一个用户,你会看到一些看起来像乱码的东西:

以上是数据库中用户的明文密码的密码哈希值。这个值是密码哈希函数的结果;有些框架就是这样把用户的密码安全地存储在数据库中的。
你在前面的截图中看到的值不是一个加密的密码。它是一个用户选择作为密码的明文的哈希值。
加密和哈希的区别
由于密码是应用程序中唯一的认证手段,应用程序需要一些手段来存储密码,以允许用户进入他们的账户。加密是一种存储密码的方式,但不够安全。散列是一种更安全的方式。
加密和散列的区别在于:
- 加密是一个双向的数学函数。
- 散列是一个单向的数学函数。
通过加密,如果一个人得到一个密钥,他们可以解密加密的内容。通过散列,从散列函数中得出的值不能被逆转以显示出纯文本,这就像把一头牛转换成牛排。
密码散列是如何帮助认证的
当你这样想的时候,对密码进行散列而不是加密的想法就更有意义了:任何人类可以加密的东西......只要有合适的密钥,人类就可以解密。密钥必须存储在服务器端的某个地方。
如果一个攻击者得到了你的加密密码的数据库,他们会试图得到钥匙。如果你能以某种方式摆脱首先存储密钥的额外攻击载体,你就会给攻击者提供一个更有挑战性的时间来弄清你的用户密码是什么。
前面的陈述并不是说密码洗练是没有缺陷的。讨论攻击者如何减轻哈希函数的影响,超出了我们今天的范围。
下面是哈希函数如何使认证成为可能:
- 一个用户(让我们称她为Alice)在你的网站上创建了一个账户。
- 爱丽丝选择了一个用户名和一个密码。
- 服务器对爱丽丝的密码进行哈希处理,保留一个ID的明文,并将其存储在数据库中。
- 爱丽丝在以后的时间来到网站,她输入她的ID和密码。
- 她的登录信息被安全地(希望是通过HTTPS)传输到你网站的后台。
- 后台服务器收到登录信息后,在数据库中查找Alice,并将她的密码通过与她注册时的密码散列相同的哈希函数。
- 如果Alice输入的密码散列值与存储在数据库中的密码相匹配,Alice就被授予访问权。否则,该网站就会拒绝Alice的访问。
让我们看看这在代码中是如何实现的。
Rails中的bcrypt
当你用Rails启动一个新的应用程序时,你可能会注意到它附带了bcrypt 的注释。当你推出你的认证逻辑时,它是用来创建密码摘要的。
为了利用bcrypt,你可以注释掉bcrypt ,重新运行bundler ,并将has_secure_password 添加到你想启用认证的ActiveRecord模型中。
当你把has_secure_password 添加到你的模型中时,你会得到一个类似于authenticate 的方法,它是authenticate_password 的别名,将你发送给它的明文与一个哈希值相匹配。
让下面这个模型成为用户模型:
class User < ActiveRecord::Base
has_secure_password
end
我们可以添加一个迁移,将password_digest 字符串添加到我们的数据库模式中:
ActiveRecord::Schema.define(version: 2021_01_30_173626) do
create_table "users", force: :cascade do |t|
t.string "name"
t.string "password_digest"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
end
有了上述内容,我们可以利用bcrypt 来存储用户的密码。我们现在需要做的就是像这样创建一个有密码的用户:
User.create(name: "Alice", password: "sekreet")
这样就会返回:
注意我们是如何用name 和password 参数创建用户的,但我们得到的是name 和password_digest :这就是has_secure_password 将明文password 存储为password_digest 的神奇之处。我们不是在存储密码;我们是在保存密码的摘要,所以我们创建了一个叫做password_digest 的字符串列来保存我们迁移中的哈希值是有意义的。
摘要"、"哈希值"、"哈希码 "或只是 "哈希 "都是同一事物的不同名称。我将交替使用它。

在这一点上,我们可以看到 "sekreet "的明文值是如何通过运行User.last.password_digest 进行散列的。
这里的值$2a$12$Uu744tDT28y8Zc5.bTxgUetv4oqAKzoihNjnMvendmt5xbNBuarcK ,是将 "sekreet "通过bcrypt 散列函数的结果。这个摘要是不可逆的。没有钥匙来 "解密 "这个散列。
在我们的后端,我们可以通过询问用户的密码来检查他们是否是他们所声称的人,用bcrypt ,看看摘要是否与存储的摘要相符。换句话说,我们要检查他们注册的密码是否匹配。如果匹配,我们就返回用户(或任何调用has_secure_password 的模型),否则 Rails 就返回false 。
现在我们知道了bcrypt 在 Rails 中的作用,让我们放大一点,仔细看看它是什么以及它是如何工作的。
什么是 bcrypt?
如果你读到这里,你可能知道bcrypt 是一个密码隐藏函数。Niels Provos和David Mazières设计了这个函数。它结合了一个盐,以防止彩虹表。
新的术语。让我们来定义它们。
什么是彩虹表?
如果像bcrypt 这样的哈希函数接受一个明文输入并产生一个哈希代码,攻击者知道对于像 "sekreet "这样的明文密码将总是返回相同的哈希代码。一个知道你的后端认证系统使用bcrypt 的攻击者,如果他们掌握了数据库,就会有很大的收获。
彩虹表是一个已知散列算法产生的常用密码及其散列代码的列表:
bcrypt 如果 "password13 "的哈希码是$2a$12$Uu744tDT28y8Zc5.bTxgUetv4oqAKzoihNjnMvendmt5xbNBuarcK ,那么攻击者只需要遍历你的数据库并找到$2a$12$Uu744tDT28y8Zc5.bTxgUetv4oqAKzoihNjnMvendmt5xbNBuarcK ,那么该行的用户密码就是 "password13"。
什么是盐?
为了对付彩虹表攻击,像bcrypt 这样的哈希函数包含了一个盐。盐是一种随机数据,我们将其添加到哈希函数的输入数据中。盐通过使每一个哈希代码不同,即使是相同的数据输入,也能加强安全性:
| 用户 | 盐 | 要散列的输入 | 哈希代码 |
|---|---|---|---|
| 爱丽丝 | skIfs | skIfspassword | 2ayWbud6Fl/x...bMW |
| 鲍勃 | sahsF | 密码 | 2a1hgi9kgehS...v5y |
| Emmanuel | 鲍勃 | 密码 | 12$Uu744tDT28…rcK |
上表说明了bcrypt 是如何为所有三个用户产生不同的哈希值的,尽管他们的明文密码是相同的。一个攻击者通过bcrypt ,会得到一个不同的哈希值,所以他没有办法知道Alice、Bob和Emmanuel的密码是 "password"。
我们知道了什么是盐,现在是一个很好的时间来研究bcrypt 的密码散列的结构。
一个值得注意的特性是哈希码的长度,它们是固定长度的;不管你的密码是什么长度(上限为72字节)。一个典型的密码散列看起来像这样。

算法标识符显示的是crypt(一种用于加密的UNIX实用程序)产生的哈希值。成本系数指定了密钥扩展迭代次数的2次方,这是对crypt算法的输入。
bcrypt算法的工作原理概述
有几个密码加密函数。Rails和Devise使用的是通过bcrypt-rubygem的bcrypt 。它是基于Blowfish密码,给我们提供了bcrypt 中的 "b"。crypt 来自UNIX密码系统使用的散列函数。
bcrypt 的实现细节超出了这篇文章的范围。下面是该函数工作原理的一个鸟瞰图:

- 该函数接受三个参数。
- 一个叫做
EksBlowfishSetup的函数接受成本、盐和密钥,并建立一个状态。它使用密码作为密钥。 - 一个192位的魔法值 "OrpheanBeholderScryDoubt "在ECB模式下与先前步骤的状态进行了64次加密。
- 然后,成本、盐和加密值被串联起来,并以密码及其版本为前缀。
这个复杂过程的结果就是存储在密码摘要中的内容,然后用于认证和其他目的。bcrypt-ruby ,所以我们不必做这些事情。
Rails如何采用bcrypt
我们之前说过,Ruby on Rails通过bcrypt-ruby gem使用bcrypt 散列函数。Rails 在ActiveModelSecurePassword 中需要bcrypt 。我们看到了如何添加has_secure_password 给你所有的魔法。这就是Rails中的神奇之处:
module ActiveModel
module SecurePassword
extend ActiveSupport::Concern
MAX_PASSWORD_LENGTH_ALLOWED = 72
class << self
attr_accessor :min_cost # :nodoc:
end
self.min_cost = false
module ClassMethods
def has_secure_password(attribute = :password, validations: true)
begin
require "bcrypt"
rescue LoadError
$stderr.puts "You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install"
raise
end
# ...
end
end
# ...
end
end
我们已经结束了对bcrypt 的学习,以及它在我们的Ruby应用程序中给我们带来的东西。我们可以讨论很多东西;我们可以谈论安全问题,Argon2的比较,bcrypt 如何适应不断增加的硬件能力,对抗暴力攻击,密钥扩展等等;但那样我们就会写一本小书了。
加密和散列是复杂的课题。值得庆幸的是,我们不需要成为这些计算机安全领域的专家来利用密码学。
不过,在某些情况下,了解一下引擎盖下发生的事情确实有帮助。我希望这篇文章能让你对散列验证的主题有一点了解。