6.3 添加安全密码

我们已经为 nameemail 字段添加了验证规则,现在要加入用户所需的最后一个常规属性:安全密码。每个用户都要设置一个密码(还要二次确认),数据库中则存储经过哈希加密后的密码。(你可能会困惑。这里所说的“哈希”不是 4.3.3 节介绍的 Ruby 数据结构,而是经过不可逆哈希算法计算得到的结果。)我们还要加入基于密码的认证验证机制,第 8 章会利用这个机制实现用户登录功能。

认证用户的方法是,获取用户提交的密码,哈希加密,再和数据库中存储的密码哈希值对比,如果二者一致,用户提交的就是正确的密码,用户的身份也就通过认证了。我们要对比的是密码哈希值,而不是原始密码,所以不用在数据库中存储用户的密码。因此,就算被脱库了,用户的密码仍然安全。

6.3.1 计算密码哈希值

我们使用的安全密码机制基本上由一个 Rails 方法即可实现,这个方法是 has_secure_password。我们要在用户模型中调用这个方法,如下所示:

class User < ActiveRecord::Base
  .
  .
  .
  has_secure_password
end

在模型中调用这个方法后,会自动添加如下功能:

  • 在数据库中的 password_digest 列存储安全的密码哈希值;

  • 获得一对“虚拟属性”,[17]passwordpassword_confirmation,而且创建用户对象时会执行存在性验证和匹配验证;

  • 获得 authenticate 方法,如果密码正确,返回对应的用户对象,否则返回 false

has_secure_password 发挥功效的唯一要求是,对应的模型中有个名为 password_digest 的属性。(“digest”(摘要)是哈希加密算法中的术语。“密码哈希值”和“密码摘要”是一个意思。)[18]对用户模型来说,我们要实现如图 6.7 所示的数据模型。

user model password digest 3rd edition图 6.7:用户数据模型,多了一个 password_digest 属性

为了实现图 6.7 中的数据模型,首先要创建一个适当的迁移文件,添加 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 中的命令对比一下,那个命令生成创建 users 表的迁移,指定了 name:stringemail:string 两个参数。)加入 password_digest:string 后,我们为 Rails 提供了足够的信息,它会为我们生成一个完整的迁移,如代码清单 6.32 所示。

代码清单 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 哈希算法计算密码摘要。使用 bcrypt 计算密码哈希值,就算攻击者设法获得了数据库副本也无法登录网站。为了在演示应用中使用 bcrypt,我们要把 bcrypt gem 添加到 Gemfile 中,如代码清单 6.33 所示。

代码清单 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 所示。

代码清单 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 中的“RED”所示,测试现在失败,我们可以在命令行中执行下述命令确认:

代码清单 6.35:RED
$ bundle exec rake test

我们在 6.3.1 节说过,has_secure_password 会在 passwordpassword_confirmation 两个虚拟属性上执行验证,但是现在代码清单 6.25 中的 @user 变量没有这两个属性:

def setup
  @user = User.new(name: "Example User", email: "[email protected]")
end

所以,为了让测试组件通过,我们要添加这两个属性,如代码清单 6.36 所示。

代码清单 6.36:添加密码和密码确认 GREEN

test/models/user_test.rb

require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "[email protected]",
 password: "foobar", password_confirmation: "foobar")  end
  .
  .
  .
end

现在测试应该可以通过了:

代码清单 6.37:GREEN
$ bundle exec rake test

6.3.4 节会看到在用户模型中添加 has_secure_password 的作用。在此之前,为了密码的安全,先添加一个小要求。

6.3.3 密码的最短长度

一般来说,最好为密码做些限制,让别人更难猜测。在 Rails 中增强密码强度有很多方法,简单起见,我们只限制最短长度,而且要求密码不能为空。最短长度为 6 是个不错的选择,针对这个验证的测试如代码清单 6.38 所示。

代码清单 6.38:测试密码的最短长度 RED

test/models/user_test.rb

require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "[email protected]",
                     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

这行代码同时为 passwordpassword_confirmation 赋值,值是长度为 5 的字符串,使用字符串连乘创建。

参照 name 属性的 maximum 验证(代码清单 6.16),你或许能猜到限制最短长度所需的代码:

validates :password, length: { minimum: 6 }

在上述代码的基础上,还要加上存在性验证,得出的用户模型如代码清单 6.39 所示。(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 节开发的用户资料页面做准备。同时也看一下在用户模型中添加 has_secure_password 的效果,还要用一下重要的 authenticate 方法。

因为现在还不能在网页中注册(第 7 章实现),我们要在控制台中手动创建新用户。为了方便,我们会使用 6.1.3 节介绍的 create 方法。注意,不要在沙盒模式中启用控制台,否则结果不会存入数据库。所以我们要使用 rails console 启动普通的控制台,然后使用有效的名字和电子邮件地址,以及密码和密码确认,创建一个用户:

$ rails console
>> User.create(name: "Michael Hartl", email: "[email protected]",
?>             password: "foobar", password_confirmation: "foobar")
=> #<User id: 1, name: "Michael Hartl", email: "[email protected]",
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 所示。[19]留意图 6.7 中数据模型的各个属性。

sqlite user row with password 3rd edition图 6.8:SQLite 数据库(db/development.sqlite3)中的一个用户记录

回到控制台,查看 password_digest 属性的值,由此可以看出代码清单 6.39has_secure_password 的作用:

>> user = User.find_by(email: "[email protected]")
>> user.password_digest
=> "$2a$10$YmQTuuDNOszvu5yi7auOC.F4G//FGhyQSWCpghqRWQWITUYlG3XVy"

这是创建用户对象时指定的密码("foobar")的哈希值。这个值由 bcrypt 计算得出,很难反推出原始密码。[20]

6.3.1 节说过,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: "[email protected]",
created_at: "2014-07-25 02:58:28", updated_at: "2014-07-25 02:58:28",
password_digest: "$2a$10$YmQTuuDNOszvu5yi7auOC.F4G//FGhyQSWCpghqRWQW...">

第 8 章会使用 authenticate 方法把注册的用户登入网站。其实,authenticate 方法返回的用户对象并不重要,关键是这个值是“真值”。因为用户对象不是 nil,也不是 false,所以能很好地完成任务:[21]

>> !!user.authenticate("foobar")
=> true