6.3 添加安全密码
我们已经为 name
和 email
字段添加了验证规则,现在要加入用户所需的最后一个常规属性:安全密码。每个用户都要设置一个密码(还要二次确认),数据库中则存储经过哈希加密后的密码。(你可能会困惑。这里所说的“哈希”不是 4.3.3 节介绍的 Ruby 数据结构,而是经过不可逆哈希算法计算得到的结果。)我们还要加入基于密码的认证验证机制,第 8 章会利用这个机制实现用户登录功能。
认证用户的方法是,获取用户提交的密码,哈希加密,再和数据库中存储的密码哈希值对比,如果二者一致,用户提交的就是正确的密码,用户的身份也就通过认证了。我们要对比的是密码哈希值,而不是原始密码,所以不用在数据库中存储用户的密码。因此,就算被脱库了,用户的密码仍然安全。
6.3.1 计算密码哈希值
我们使用的安全密码机制基本上由一个 Rails 方法即可实现,这个方法是 has_secure_password
。我们要在用户模型中调用这个方法,如下所示:
class User < ActiveRecord::Base
.
.
.
has_secure_password
end
在模型中调用这个方法后,会自动添加如下功能:
在数据库中的
password_digest
列存储安全的密码哈希值;获得一对“虚拟属性”,[17]
password
和password_confirmation
,而且创建用户对象时会执行存在性验证和匹配验证;获得
authenticate
方法,如果密码正确,返回对应的用户对象,否则返回false
。
has_secure_password
发挥功效的唯一要求是,对应的模型中有个名为 password_digest
的属性。(“digest”(摘要)是哈希加密算法中的术语。“密码哈希值”和“密码摘要”是一个意思。)[18]对用户模型来说,我们要实现如图 6.7 所示的数据模型。
图 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:string
和 email: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
会在 password
和 password_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
这行代码同时为 password
和 password_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 中数据模型的各个属性。
图 6.8:SQLite 数据库(db/development.sqlite3
)中的一个用户记录
回到控制台,查看 password_digest
属性的值,由此可以看出代码清单 6.39中 has_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