10.2 密码重设
完成账户激活功能后(从而确认了用户的电子邮件地址可用),我们要处理一种常见的问题:用户忘记密码。我们会看到,密码重设的很多步骤和账户激活类似,所以这里会用到 10.1 节学到的知识。不过,开头不一样,和账户激活功能不同的是,密码重设要修改一个视图,还要创建两个表单(处理电子邮件地址提交和设定新密码)。
编写代码之前,我们先构思要实现的重设密码步骤。首先,我们要在演示应用的登录表单中添加“Forgot Password”(忘记密码)链接,如图 10.7 所示。
图 10.7:“Forgot Password”链接的构思图
点击“Forgot Password”链接后打开一个页面,这个页面中有一个表单,要求输入电子邮件地址,提交后向这个地址发送一封包含密码重设链接的邮件,如图 10.8 所示。
图 10.8:“Forgot Password”表单的构思图
点击密码重设链接会打开一个表单,用户在这个表单中重设密码(还要填写密码确认),如图 10.9 所示。
图 10.9:重设密码表单的构思图
和账户激活一样,我们要把“密码重设”看做一个资源,每个重设密码操作都有一个重设令牌和对应的摘要。主要的步骤如下:
用户请求重设密码时,使用提交的电子邮件地址查找用户;
如果数据库中有这个电子邮件地址,生成一个重设令牌和对应的摘要;
把重设摘要保存在数据库中,然后给用户发送一封邮件,其中有一包含重设令牌和用户电子邮件地址的链接;
用户点击这个链接后,使用电子邮件地址查找用户,然后对比令牌和摘要;
如果匹配,显示重设密码的表单。
10.2.1 资源
和账户激活一样(10.1.1 节),第一步要为资源生成控制器:
$ rails generate controller PasswordResets new edit --no-test-framework
注意,我们指定了不生成测试的参数,因为我们不需要控制器测试(和 10.1.4 节一样,要使用集成测试),所以最好不生成。
我们需要两个表单,一个请求重设密码(图 10.8),一个修改用户模型中的密码(图 10.9),所以需要为 new
、create
、edit
和 update
四个动作制定路由——通过代码清单 10.37 中高亮显示的那行 resources
规则实现。
代码清单 10.37:添加“密码重设”资源的路由
config/routes.rb
Rails.application.routes.draw do
root 'static_pages#home'
get 'help' => 'static_pages#help'
get 'about' => 'static_pages#about'
get 'contact' => 'static_pages#contact'
get 'signup' => 'users#new'
get 'login' => 'sessions#new'
post 'login' => 'sessions#create'
delete 'logout' => 'sessions#destroy'
resources :users
resources :account_activations, only: [:edit]
resources :password_resets, only: [:new, :create, :edit, :update] end
添加这个规则后,得到了表 10.2 中的 REST 路由。
表 10.2:定义“密码重设”资源后得到的 REST 路由
HTTP 请求 | URL | 动作 | 具名路由 |
---|---|---|---|
GET | /password_resets/new | new | new_password_reset_path |
POST | /password_resets | create | password_resets_path |
GET | /password_resets/<token>/edit | edit | edit_password_reset_path(token) |
PATCH | /password_resets/<token> | update | password_reset_path(token) |
通过表中第一个路由可以得到指向“Forgot Password”表单的链接:
new_password_reset_path
把这个链接添加到登录表单,如代码清单 10.38 所示。添加后的效果如图 10.10 所示。
代码清单 10.38:添加打开忘记密码表单的链接
app/views/sessions/new.html.erb
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(:session, url: login_path) do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= link_to "(forgot password)", new_password_reset_path %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
<%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
图 10.10:添加“Forgot Password”链接后的登录页面
密码重设所需的数据模型和账户激活的类似(图 10.1)。参照“记住我”功能(8.4 节)和账户激活功能(10.1 节),密码重设需要一个虚拟的重设令牌属性,在重设密码的邮件中使用,以及一个重设摘要属性,用来取回用户。 如果存储未哈希的令牌,能访问数据库的攻击者就能发送一封重设密码邮件给用户,然后使用令牌和邮件地址访问对应的密码重设链接,从而获得账户控制权。因此,必须存储令牌的摘要。为了进一步保障安全,我们还计划过几个小时后让重设链接失效,所以要记录重设邮件发送的时间。据此,我们要添加两个属性:reset_digest
和 reset_sent_at
,如图 10.11 所示。
图 10.11:添加密码重设相关属性后的用户模型
执行下面的命令,创建添加这两个属性的迁移:
$ rails generate migration add_reset_to_users reset_digest:string \
> reset_sent_at:datetime
然后像之前一样执行迁移:
$ bundle exec rake db:migrate
10.2.2 控制器和表单
我们要参照前面为没有模型的资源编写表单的方法,即创建新会话的登录表单(代码清单 8.2),编写请求重设密码的表单。为了便于参考,我们再把这个表单列出来,如代码清单 10.39 所示。
代码清单 10.39:登录表单的代码
app/views/sessions/new.html.erb
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(:session, url: login_path) do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
<%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
请求重设密码的表单和代码清单 10.39 有很多共通之处,最大的区别是,form_for
中的资源和地址不一样,而且也没有密码字段。请求重设密码的表单如代码清单 10.40 所示,渲染的结果如图 10.12 所示。
代码清单 10.40:请求重设密码页面的视图
app/views/password_resets/new.html.erb
<% provide(:title, "Forgot password") %>
<h1>Forgot password</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(:password_reset, url: password_resets_path) do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.submit "Submit", class: "btn btn-primary" %>
<% end %>
</div>
</div>
图 10.12:“Forgot Password”表单
提交图 10.12 中的表单后,我们要通过电子邮件地址查找用户,更新这个用户的 reset_token
、reset_digest
和 reset_sent_at
属性,然后重定向到根地址,并显示一个闪现消息。和登录一样(代码清单 8.9),如果提交的数据无效,我们要重新渲染这个页面,并且显示一个 flash.now
消息。据此,写出的 create
动作如代码清单 10.41 所示。
代码清单 10.41:PasswordResetsController
的 create
动作
app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
def new
end
def create
@user = User.find_by(email: params[:password_reset][:email].downcase)
if @user
@user.create_reset_digest
@user.send_password_reset_email
flash[:info] = "Email sent with password reset instructions"
redirect_to root_url
else
flash.now[:danger] = "Email address not found"
render 'new'
end
end
def edit
end
end
然后要在用户模型中定义 create_reset_digest
方法,如代码清单 10.42 所示。
代码清单 10.42:在用户模型中添加重设密码所需的方法
app/models/user.rb
class User < ActiveRecord::Base
attr_accessor :remember_token, :activation_token, :reset_token before_save :downcase_email
before_create :create_activation_digest
.
.
.
# 激活账户
def activate
update_attribute(:activated, true)
update_attribute(:activated_at, Time.zone.now)
end
# 发送激活邮件
def send_activation_email
UserMailer.account_activation(self).deliver_now
end
# 设置密码重设相关的属性
def create_reset_digest
self.reset_token = User.new_token update_attribute(:reset_digest, User.digest(reset_token)) update_attribute(:reset_sent_at, Time.zone.now) end
# 发送密码重设邮件
def send_password_reset_email
UserMailer.password_reset(self).deliver_now end
private
# 把电子邮件地址转换成小写
def downcase_email
self.email = email.downcase
end
# 创建并赋值激活令牌和摘要
def create_activation_digest
self.activation_token = User.new_token
self.activation_digest = User.digest(activation_token)
end
end
如图 10.13 所示,提交无效电子邮件地址时,应用的表现正常。为了让提交有效地址时应用也能正常运行,我们要定义发送密码重设邮件的方法,这一步会在 10.2.3 节完成。
图 10.13:提交无效电子邮件地址后显示的“Forgot Password”表单
10.2.3 邮件程序
代码清单 10.42 中发送密码重设邮件的代码是:
UserMailer.password_reset(self).deliver_now
让这个邮件程序运作起来所需的代码几乎和 10.1.2 节的账户激活邮件程序一样。我们首先在 UserMailer
中定义 password_reset
方法(代码清单 10.43),然后再编写邮件的纯文本视图(代码清单 10.44)和 HTML 视图(代码清单 10.45)。
代码清单 10.43:发送密码重设链接
app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
default from: "[email protected]"
def account_activation(user)
@user = user
mail to: user.email, subject: "Account activation"
end
def password_reset(user)
@user = user mail to: user.email, subject: "Password reset" end
end
代码清单 10.44:密码重设邮件的纯文本视图
app/views/user_mailer/password_reset.text.erb
To reset your password click the link below:
<%= edit_password_reset_url(@user.reset_token, email: @user.email) %>
This link will expire in two hours.
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
代码清单 10.45:密码重设邮件的 HTML 视图
app/views/user_mailer/password_reset.html.erb
<h1>Password reset</h1>
<p>To reset your password click the link below:</p>
<%= link_to "Reset password",
edit_password_reset_url(@user.reset_token,
email: @user.email) %>
<p>This link will expire in two hours.</p>
<p>
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
</p>
和账户激活邮件一样(10.1.2 节),我们可以使用 Rails 提供的邮件预览程序预览密码重设邮件。参照代码清单 10.16,密码重设的邮件预览程序如代码清单 10.46 所示。
代码清单 10.46:预览密码重设邮件所需的方法
test/mailers/previews/user_mailer_preview.rb
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview
# Preview this email at
# http://localhost:3000/rails/mailers/user_mailer/account_activation
def account_activation
user = User.first
user.activation_token = User.new_token
UserMailer.account_activation(user)
end
# Preview this email at
# http://localhost:3000/rails/mailers/user_mailer/password_reset
def password_reset
user = User.first user.reset_token = User.new_token UserMailer.password_reset(user) end
end
然后就可以预览密码重设邮件了,HTML 格式和纯文本格式分别如图 10.14 和图 10.15 所示。
图 10.14:预览 HTML 格式的密码重设邮件图 10.15:预览纯文本格式的密码重设邮件
参照账户激活邮件程序的测试(代码清单 10.18),密码重设邮件程序的测试如代码清单 10.47 所示。注意,我们要创建密码重设令牌,以便在视图中使用。这一点和激活令牌不一样,激活令牌使用 before_create
回调创建(代码清单 10.3),但是密码重设令牌只会在用户成功提交“Forgot Password”表单后创建。在集成测试中很容易创建密码重设令牌(参见代码清单 10.54),但在邮件程序的测试中必须手动创建。
代码清单 10.47:添加密码重设邮件程序的测试 GREEN
test/mailers/user_mailer_test.rb
require 'test_helper'
class UserMailerTest < ActionMailer::TestCase
test "account_activation" do
user = users(:michael)
user.activation_token = User.new_token
mail = UserMailer.account_activation(user)
assert_equal "Account activation", mail.subject
assert_equal [user.email], mail.to
assert_equal ["[email protected]"], mail.from
assert_match user.name, mail.body.encoded
assert_match user.activation_token, mail.body.encoded
assert_match CGI::escape(user.email), mail.body.encoded
end
test "password_reset" do
user = users(:michael)
user.reset_token = User.new_token mail = UserMailer.password_reset(user)
assert_equal "Password reset", mail.subject
assert_equal [user.email], mail.to
assert_equal ["[email protected]"], mail.from
assert_match user.reset_token, mail.body.encoded
assert_match CGI::escape(user.email), mail.body.encoded
end
end
现在,测试组件应该能通过:
代码清单 10.48:GREEN
$ bundle exec rake test
有了代码清单 10.43、代码清单 10.44 和代码清单 10.45 之后,提交有效电子邮件地址后显示的页面如图 10.16 所示。服务器日志中记录的邮件类似于代码清单 10.49。
图 10.16:提交有效电子邮件地址后显示的页面
代码清单 10.49:服务器日志中记录的一封密码重设邮件
Sent mail to [email protected] (66.8ms)
Date: Thu, 04 Sep 2014 01:04:59 +0000
From: [email protected]
To: [email protected]
Message-ID: <[email protected]>
Subject: Password reset
Mime-Version: 1.0
Content-Type: multipart/alternative;
boundary="--==_mimepart_5407babbe3505_8722b257d045617";
charset=UTF-8
Content-Transfer-Encoding: 7bit
----==_mimepart_5407babbe3505_8722b257d045617
Content-Type: text/plain;
charset=UTF-8
Content-Transfer-Encoding: 7bit
To reset your password click the link below:
http://rails-tutorial-c9-mhartl.c9.io/password_resets/3BdBrXeQZSWqFIDRN8cxHA/
edit?email=michael%40michaelhartl.com
This link will expire in two hours.
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
----==_mimepart_5407babbe3505_8722b257d045617
Content-Type: text/html;
charset=UTF-8
Content-Transfer-Encoding: 7bit
<h1>Password reset</h1>
<p>To reset your password click the link below:</p>
<a href="http://rails-tutorial-c9-mhartl.c9.io/
password_resets/3BdBrXeQZSWqFIDRN8cxHA/
edit?email=michael%40michaelhartl.com">Reset password</a>
<p>This link will expire in two hours.</p>
<p>
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
</p>
----==_mimepart_5407babbe3505_8722b257d045617--
10.2.4 重设密码
为了让下面这种形式的链接生效,我们要编写一个表单,重设密码。
http://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=foo%40bar.com
这个表单的目的和编辑用户资料的表单(代码清单 9.2)类似,不过现在只需更新密码和密码确认字段。而且处理起来有点复杂,因为我们希望通过电子邮件地址查找用户,也就是说,在 edit
动作和 update
动作中都需要使用邮件地址。在 edit
动作中可以轻易的获取邮件地址,因为链接中有。可是提交表单后,邮件地址就没有了。为了解决这个问题,我们可以使用一个“隐藏字段”,把这个字段的值设为邮件地址(不会显示),和表单中的其他数据一起提交给 update
动作,如代码清单 10.50 所示。
代码清单 10.50:重设密码的表单
app/views/password_resets/edit.html.erb
<% provide(:title, 'Reset password') %>
<h1>Reset password</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(@user, url: password_reset_path(params[:id])) do |f| %>
<%= render 'shared/error_messages' %>
<%= hidden_field_tag :email, @user.email %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :password_confirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: 'form-control' %>
<%= f.submit "Update password", class: "btn btn-primary" %>
<% end %>
</div>
</div>
注意,在代码清单 10.50 中,使用的表单字段辅助方法是
hidden_field_tag :email, @user.email
而不是
f.hidden_field :email, @user.email
因为在重设密码的链接中,邮件地址在 params[:email]
中,如果使用后者,就会把邮件地址放入 params[:user][:email]
中。
为了正确渲染这个表单,我们要在 PasswordResetsController
的 edit
控制器中定义 @user
变量。和账户激活一样(代码清单 10.29),我们要找到 params[:email]
中电子邮件地址对应的用户,确认这个用户已经激活,然后使用代码清单 10.24 中的 authenticated?
方法认证 params[:id]
中的令牌。因为在 edit
和 update
动作中都要使用 @user
,所以我们要把查找用户和认证令牌的代码写入一个事前过滤器中,如代码清单 10.51 所示。
代码清单 10.51:重设密码的 edit
动作
app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] .
.
.
def edit
end
private
def get_user
@user = User.find_by(email: params[:email]) end
# 确保是有效用户
def valid_user
unless (@user && @user.activated? && @user.authenticated?(:reset, params[:id])) redirect_to root_url end end
end
代码清单 10.51 中的 authenticated?(:reset, params[:id])
,代码清单 10.26 中的 authenticated?(:remember, cookies[:remember_token])
,以及代码清单 10.29 中的 authenticated?(:activation, params[:id])
,就是表 10.1 中 authenticated?
方法的三个用例。
现在,点击代码清单 10.49 中的链接后,会显示密码重设表单,如图 10.17 所示。
图 10.17:密码重设表单
edit
动作对应的 update
动作要考虑四种情况:密码重设超时失效,重设成功,密码无效导致的重设失败,密码和密码确认为空值时导致的密码重设失败(此时看起来像是成功了)。前三种情况对应代码清单 10.52 中外层 if
语句的三个分支。因为这个表单会修改 Active Record 模型(用户模型),所以我们可以使用共用的局部视图渲染错误消息。密码为空值的情况比较特殊,因为用户模型的验证允许出现这种情况(参见代码清单 9.10),所以要特别处理,直接在 @user
对象的错误消息中添加一个错误:[8]
@user.errors.add(:password, "can't be empty")
代码清单 10.52:重设密码的 update
动作
app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
before_action :get_user, only: [:edit, :update]
before_action :valid_user, only: [:edit, :update]
before_action :check_expiration, only: [:edit, :update]
def new
end
def create
@user = User.find_by(email: params[:password_reset][:email].downcase)
if @user
@user.create_reset_digest
@user.send_password_reset_email
flash[:info] = "Email sent with password reset instructions"
redirect_to root_url
else
flash.now[:danger] = "Email address not found"
render 'new'
end
end
def edit
end
def update
if params[:user][:password].empty? @user.errors.add(:password, "can't be empty")
render 'edit'
elsif @user.update_attributes(user_params) log_in @user
flash[:success] = "Password has been reset."
redirect_to @user
else
render 'edit'
end
end
private
def user_params
params.require(:user).permit(:password, :password_confirmation) end
# 事前过滤器
def get_user
@user = User.find_by(email: params[:email])
end
# 确保是有效用户
def valid_user unless (@user && @user.activated? &&
@user.authenticated?(:reset, params[:id]))
redirect_to root_url
end
end
# 检查重设令牌是否过期
def check_expiration if @user.password_reset_expired?
flash[:danger] = "Password reset has expired."
redirect_to new_password_reset_url
end
end
end
我们把密码重设是否超时失效交给用户模型判断:
@user.password_reset_expired?
所以,我们要定义 password_reset_expired?
方法。如 10.2.3 节的邮件模板所示,如果邮件发出后两个小时内没重设密码,就认为此次请求超时失效了。这个设想可以通过下面的 Ruby 代码实现:
reset_sent_at < 2.hours.ago
如果你把 <
当成小于号,读成“密码重设邮件发出少于两小时”就错了,和想表达的意思正好相反。 这里,最好把 <
理解成“超过”,读成“密码重设邮件已经发出超过两小时”,这才是我们想表达的意思。password_reset_expired?
方法的定义如代码清单 10.53 所示。(对这个比较算式的证明参见 10.6 节。)
代码清单 10.53:在用户模型中定义 password_reset_expired?
方法
app/models/user.rb
class User < ActiveRecord::Base
.
.
.
# 如果密码重设超时失效了,返回 true
def password_reset_expired?
reset_sent_at < 2.hours.ago end
private
.
.
.
end
现在,代码清单 10.52 中的 update
动作可以使用了。密码重设失败和成功后显示的页面分别如图 10.18 和图 10.19 所示。(稍等一会,10.5 节中有一题,为第三个分支编写测试。)
图 10.18:密码重设失败图 10.19:密码重设成功
10.2.5 测试
本节,我们要编写一个集成测试,覆盖代码清单 10.52 中的两个分支:重设失败和重设成功。(前面说过,第三个分支的测试留作练习。)首先,为重设密码生成一个测试文件:
$ rails generate integration_test password_resets
invoke test_unit
create test/integration/password_resets_test.rb
这个测试的步骤大致和代码清单 10.31 中的账户激活测试差不多,不过开头有点不同。首先访问“Forgot Password”表单,分别提交有效和无效的电子邮件地址,电子邮件地址有效时要创建密码重设令牌,并且发送重设邮件。然后,访问邮件中的链接,分别提交无效和有效的密码,验证各自的表现是否正确。最终写出的测试如代码清单 10.54 所示。这是一个不错的练习,可以锻炼阅读代码的能力。
代码清单 10.54:密码重设的集成测试
test/integration/password_resets_test.rb
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear
@user = users(:michael)
end
test "password resets" do
get new_password_reset_path
assert_template 'password_resets/new'
# 电子邮件地址无效
post password_resets_path, password_reset: { email: "" }
assert_not flash.empty?
assert_template 'password_resets/new'
# 电子邮件地址有效
post password_resets_path, password_reset: { email: @user.email }
assert_not_equal @user.reset_digest, @user.reload.reset_digest
assert_equal 1, ActionMailer::Base.deliveries.size
assert_not flash.empty?
assert_redirected_to root_url
# 密码重设表单
user = assigns(:user)
# 电子邮件地址错误
get edit_password_reset_path(user.reset_token, email: "")
assert_redirected_to root_url
# 用户未激活
user.toggle!(:activated)
get edit_password_reset_path(user.reset_token, email: user.email)
assert_redirected_to root_url
user.toggle!(:activated)
# 电子邮件地址正确,令牌不对
get edit_password_reset_path('wrong token', email: user.email)
assert_redirected_to root_url
# 电子邮件地址正确,令牌也对
get edit_password_reset_path(user.reset_token, email: user.email)
assert_template 'password_resets/edit'
assert_select "input[name=email][type=hidden][value=?]", user.email
# 密码和密码确认不匹配
patch password_reset_path(user.reset_token),
email: user.email,
user: { password: "foobaz",
password_confirmation: "barquux" }
assert_select 'div#error_explanation'
# 密码为空值
patch password_reset_path(user.reset_token),
email: user.email,
user: { password: "",
password_confirmation: "" }
assert_select 'div#error_explanation'
# 密码和密码确认有效
patch password_reset_path(user.reset_token),
email: user.email,
user: { password: "foobaz",
password_confirmation: "foobaz" }
assert is_logged_in?
assert_not flash.empty?
assert_redirected_to user
end
end
代码清单 10.54 中的大多数用法前面都见过,但是针对 input
标签的测试有点陌生:
assert_select "input[name=email][type=hidden][value=?]", user.email
这行代码的意思是,页面中有 name
属性、类型(隐藏)和电子邮件地址都正确的 input
标签:
<input id="email" name="email" type="hidden" value="[email protected]" />
现在,测试组件应该能通过:
代码清单 10.55:GREEN
$ bundle exec rake test