ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
# 6.3 添加安全密码 我们已经为 `name` 和 `email` 字段添加了验证规则,现在要加入用户所需的最后一个常规属性:安全密码。每个用户都要设置一个密码(还要二次确认),数据库中则存储经过哈希加密后的密码。(你可能会困惑。这里所说的“哈希”不是 [4.3.3 节](chapter4.html#hashes-and-symbols)介绍的 Ruby 数据结构,而是经过不可逆[哈希算法](http://en.wikipedia.org/wiki/Hash_function)计算得到的结果。)我们还要加入基于密码的认证验证机制,[第 8 章](chapter8.html#log-in-log-out)会利用这个机制实现用户登录功能。 认证用户的方法是,获取用户提交的密码,哈希加密,再和数据库中存储的密码哈希值对比,如果二者一致,用户提交的就是正确的密码,用户的身份也就通过认证了。我们要对比的是密码哈希值,而不是原始密码,所以不用在数据库中存储用户的密码。因此,就算被脱库了,用户的密码仍然安全。 ## 6.3.1 计算密码哈希值 我们使用的安全密码机制基本上由一个 Rails 方法即可实现,这个方法是 `has_secure_password`。我们要在用户模型中调用这个方法,如下所示: ``` class User < ActiveRecord::Base . . . has_secure_password end ``` 在模型中调用这个方法后,会自动添加如下功能: * 在数据库中的 `password_digest` 列存储安全的密码哈希值; * 获得一对“虚拟属性”,[[17](#fn-17)]`password` 和 `password_confirmation`,而且创建用户对象时会执行存在性验证和匹配验证; * 获得 `authenticate` 方法,如果密码正确,返回对应的用户对象,否则返回 `false`。 `has_secure_password` 发挥功效的唯一要求是,对应的模型中有个名为 `password_digest` 的属性。(“digest”(摘要)是[哈希加密算法](http://en.wikipedia.org/wiki/Cryptographic_hash_function)中的术语。“密码哈希值”和“密码摘要”是一个意思。)[[18](#fn-18)]对用户模型来说,我们要实现如[图 6.7](#fig-user-model-password-digest) 所示的数据模型。 ![user model password digest 3rd edition](https://box.kancloud.cn/2016-05-11_5732bd0d53d67.png)图 6.7:用户数据模型,多了一个 `password_digest` 属性 为了实现[图 6.7](#fig-user-model-password-digest) 中的数据模型,首先要创建一个适当的迁移文件,添加 `password_digest` 列。迁移的名字随意,不过最好以 `to_users` 结尾,因为这样 Rails 会自动生成一个向 `users` 表中添加列的迁移。我们把这个迁移命名为 `add_password_digest_to_users`,生成迁移的命令如下: ``` $ rails generate migration add_password_digest_to_users password_digest:string ``` 在这个命令中,我们还加入了参数 `password_digest:string`,指定想添加的列名和类型。(和[代码清单 6.1](#listing-generate-user-model) 中的命令对比一下,那个命令生成创建 `users` 表的迁移,指定了 `name:string` 和 `email:string` 两个参数。)加入 `password_digest:string` 后,我们为 Rails 提供了足够的信息,它会为我们生成一个完整的迁移,如[代码清单 6.32](#listing-password-migration) 所示。 ##### 代码清单 6.32:在 `users` 表中添加 `password_digest` 列的迁移 db/migrate/[timestamp]_add_password_digest_to_users.rb ``` class AddPasswordDigestToUsers < ActiveRecord::Migration def change add_column :users, :password_digest, :string end end ``` 这个迁移使用 `add_column` 方法把 `password_digest` 列添加到 `users` 表中。执行下述命令在数据库中运行迁移: ``` $ bundle exec rake db:migrate ``` `has_secure_password` 方法使用先进的 [bcrypt](http://en.wikipedia.org/wiki/Bcrypt) 哈希算法计算密码摘要。使用 bcrypt 计算密码哈希值,就算攻击者设法获得了数据库副本也无法登录网站。为了在演示应用中使用 bcrypt,我们要把 `bcrypt` gem 添加到 `Gemfile` 中,如[代码清单 6.33](#listing-bcrypt-ruby) 所示。 ##### 代码清单 6.33:把 `bcrypt` gem 添加到 `Gemfile` 中 ``` source 'https://rubygems.org' gem 'rails', '4.2.2' gem 'bcrypt', '3.1.7' . . . ``` 然后像往常一样,执行 `bundle install` 命令: ``` $ bundle install ``` ## 6.3.2 用户有安全的密码 现在我们已经在用户模型中添加了 `password_digest` 属性,也安装了 bcrypt,下面可以在用户模型中添加 `has_secure_password` 方法了,如[代码清单 6.34](#listing-has-secure-password) 所示。 ##### 代码清单 6.34:在用户模型中添加 `has_secure_password` 方法 RED app/models/user.rb ``` class User < ActiveRecord::Base before_save { self.email = email.downcase } validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } has_secure_password end ``` 如[代码清单 6.34](#listing-has-secure-password) 中的“**RED**”所示,测试现在失败,我们可以在命令行中执行下述命令确认: ##### 代码清单 6.35:**RED** ``` $ bundle exec rake test ``` 我们在 [6.3.1 节](#a-hashed-password)说过,`has_secure_password` 会在 `password` 和 `password_confirmation` 两个虚拟属性上执行验证,但是现在[代码清单 6.25](#listing-validates-uniqueness-of-email-case-insensitive-test) 中的 `@user` 变量没有这两个属性: ``` def setup @user = User.new(name: "Example User", email: "user@example.com") end ``` 所以,为了让测试组件通过,我们要添加这两个属性,如[代码清单 6.36](#listing-test-with-password-confirmation) 所示。 ##### 代码清单 6.36:添加密码和密码确认 GREEN test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com", password: "foobar", password_confirmation: "foobar") end . . . end ``` 现在测试应该可以通过了: ##### 代码清单 6.37:**GREEN** ``` $ bundle exec rake test ``` [6.3.4 节](#creating-and-authenticating-a-user)会看到在用户模型中添加 `has_secure_password` 的作用。在此之前,为了密码的安全,先添加一个小要求。 ## 6.3.3 密码的最短长度 一般来说,最好为密码做些限制,让别人更难猜测。在 Rails 中增强密码强度有很多方法,简单起见,我们只限制最短长度,而且要求密码不能为空。最短长度为 6 是个不错的选择,针对这个验证的测试如[代码清单 6.38](#listing-minimum-password-length-test) 所示。 ##### 代码清单 6.38:测试密码的最短长度 RED test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com", password: "foobar", password_confirmation: "foobar") end . . . test "password should be present (nonblank)" do @user.password = @user.password_confirmation = " " * 6 assert_not @user.valid? end test "password should have a minimum length" do @user.password = @user.password_confirmation = "a" * 5 assert_not @user.valid? end end ``` 注意这段代码中使用的双重赋值: ``` @user.password = @user.password_confirmation = "a" * 5 ``` 这行代码同时为 `password` 和 `password_confirmation` 赋值,值是长度为 5 的字符串,使用字符串连乘创建。 参照 `name` 属性的 `maximum` 验证([代码清单 6.16](#listing-length-validation)),你或许能猜到限制最短长度所需的代码: ``` validates :password, length: { minimum: 6 } ``` 在上述代码的基础上,还要加上存在性验证,得出的用户模型如[代码清单 6.39](#listing-password-implementation) 所示。(`has_secure_password` 方法本身会验证存在性,但是可惜,只会验证有没有密码,因此用户可以创建 “ ”(6 个空格)这样的无效密码。) ##### 代码清单 6.39:实现安全密码的全部代码 GREEN app/models/user.rb ``` class User < ActiveRecord::Base before_save { self.email = email.downcase } validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } has_secure_password validates :password, presence: true, length: { minimum: 6 } end ``` 现在,测试应该可以通过了: ##### 代码清单 6.40:**GREEN** ``` $ bundle exec rake test:models ``` ## 6.3.4 创建并认证用户 至此,基本的用户模型已经完成了。接下来,我们要在数据库中创建一个用户,为 [7.1 节](chapter7.html#showing-users)开发的用户资料页面做准备。同时也看一下在用户模型中添加 `has_secure_password` 的效果,还要用一下重要的 `authenticate` 方法。 因为现在还不能在网页中注册([第 7 章](chapter7.html#sign-up)实现),我们要在控制台中手动创建新用户。为了方便,我们会使用 [6.1.3 节](#creating-user-objects)介绍的 `create` 方法。注意,不要在沙盒模式中启用控制台,否则结果不会存入数据库。所以我们要使用 `rails console` 启动普通的控制台,然后使用有效的名字和电子邮件地址,以及密码和密码确认,创建一个用户: ``` $ rails console >> User.create(name: "Michael Hartl", email: "mhartl@example.com", ?> password: "foobar", password_confirmation: "foobar") => #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2014-09-11 14:26:42", updated_at: "2014-09-11 14:26:42", password_digest: "$2a$10$sLcMI2f8VglgirzjSJOln.Fv9NdLMbqmR4rdTWIXY1G..."> ``` 为了确认结果,我们使用 SQLite 数据库浏览器看一下开发数据库(`db/development.sqlite3`)中的 `users` 表,如[图 6.8](#fig-sqlite-user-row) 所示。[[19](#fn-19)]留意[图 6.7](#fig-user-model-password-digest) 中数据模型的各个属性。 ![sqlite user row with password 3rd edition](https://box.kancloud.cn/2016-05-11_5732bd0d62956.png)图 6.8:SQLite 数据库(`db/development.sqlite3`)中的一个用户记录 回到控制台,查看 `password_digest` 属性的值,由此可以看出[代码清单 6.39](#listing-password-implementation)中 `has_secure_password` 的作用: ``` >> user = User.find_by(email: "mhartl@example.com") >> user.password_digest => "$2a$10$YmQTuuDNOszvu5yi7auOC.F4G//FGhyQSWCpghqRWQWITUYlG3XVy" ``` 这是创建用户对象时指定的密码(`"foobar"`)的哈希值。这个值由 bcrypt 计算得出,很难反推出原始密码。[[20](#fn-20)] [6.3.1 节](#a-hashed-password)说过,`has_secure_password` 会自动在对应的模型对象中添加 `authenticate` 方法。这个方法会计算给定密码的哈希值,然后和数据库中 `password_digest` 列中的值比较,以此判断用户提供的密码是否正确。我们可以在刚创建的用户上试几个错误密码: ``` >> user.authenticate("not_the_right_password") false >> user.authenticate("foobaz") false ``` 我们提供的密码都是错误的,所以 `user.authenticate` 返回 `false`。如果提供正确的密码,`authenticate` 方法会返回数据库中对应的用户: ``` >> user.authenticate("foobar") => #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2014-07-25 02:58:28", updated_at: "2014-07-25 02:58:28", password_digest: "$2a$10$YmQTuuDNOszvu5yi7auOC.F4G//FGhyQSWCpghqRWQW..."> ``` [第 8 章](chapter8.html#log-in-log-out)会使用 `authenticate` 方法把注册的用户登入网站。其实,`authenticate` 方法返回的用户对象并不重要,关键是这个值是“真值”。因为用户对象不是 `nil`,也不是 `false`,所以能很好地完成任务:[[21](#fn-21)] ``` >> !!user.authenticate("foobar") => true ```