10.2 密码重设
最后更新于:2022-04-01 22:30:06
# 10.2 密码重设
完成账户激活功能后(从而确认了用户的电子邮件地址可用),我们要处理一种常见的问题:用户忘记密码。我们会看到,密码重设的很多步骤和账户激活类似,所以这里会用到 [10.1 节](#account-activation)学到的知识。不过,开头不一样,和账户激活功能不同的是,密码重设要修改一个视图,还要创建两个表单(处理电子邮件地址提交和设定新密码)。
编写代码之前,我们先构思要实现的重设密码步骤。首先,我们要在演示应用的登录表单中添加“Forgot Password”(忘记密码)链接,如[图 10.7](#fig-login-forgot-password-mockup) 所示。
![login forgot password mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306a5efc8.png)图 10.7:“Forgot Password”链接的构思图
点击“Forgot Password”链接后打开一个页面,这个页面中有一个表单,要求输入电子邮件地址,提交后向这个地址发送一封包含密码重设链接的邮件,如[图 10.8](#fig-forgot-password-form-mockup) 所示。
![forgot password form mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306a74aac.png)图 10.8:“Forgot Password”表单的构思图
点击密码重设链接会打开一个表单,用户在这个表单中重设密码(还要填写密码确认),如[图 10.9](#fig-reset-password-form-mockup) 所示。
![reset password form mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306a84c37.png)图 10.9:重设密码表单的构思图
和账户激活一样,我们要把“密码重设”看做一个资源,每个重设密码操作都有一个重设令牌和对应的摘要。主要的步骤如下:
1. 用户请求重设密码时,使用提交的电子邮件地址查找用户;
2. 如果数据库中有这个电子邮件地址,生成一个重设令牌和对应的摘要;
3. 把重设摘要保存在数据库中,然后给用户发送一封邮件,其中有一包含重设令牌和用户电子邮件地址的链接;
4. 用户点击这个链接后,使用电子邮件地址查找用户,然后对比令牌和摘要;
5. 如果匹配,显示重设密码的表单。
## 10.2.1 资源
和账户激活一样([10.1.1 节](#account-activations-resource)),第一步要为资源生成控制器:
```
$ rails generate controller PasswordResets new edit --no-test-framework
```
注意,我们指定了不生成测试的参数,因为我们不需要控制器测试(和 [10.1.4 节](#activation-test-and-refactoring)一样,要使用集成测试),所以最好不生成。
我们需要两个表单,一个请求重设密码([图 10.8](#fig-forgot-password-form-mockup)),一个修改用户模型中的密码([图 10.9](#fig-reset-password-form-mockup)),所以需要为 `new`、`create`、`edit` 和 `update` 四个动作制定路由——通过[代码清单 10.37](#listing-password-resets-resource) 中高亮显示的那行 `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](#table-restful-password-resets) 中的 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](#listing-log-in-password-reset) 所示。添加后的效果如[图 10.10](#fig-forgot-password-link) 所示。
##### 代码清单 10.38:添加打开忘记密码表单的链接
app/views/sessions/new.html.erb
```
<% provide(:title, "Log in") %>
```
![forgot password link](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306aa41d7.png)图 10.10:添加“Forgot Password”链接后的登录页面
密码重设所需的数据模型和账户激活的类似([图 10.1](#fig-user-model-account-activation))。参照“记住我”功能([8.4 节](chapter8.html#remember-me))和账户激活功能([10.1 节](#account-activation)),密码重设需要一个虚拟的重设令牌属性,在重设密码的邮件中使用,以及一个重设摘要属性,用来取回用户。 如果存储未哈希的令牌,能访问数据库的攻击者就能发送一封重设密码邮件给用户,然后使用令牌和邮件地址访问对应的密码重设链接,从而获得账户控制权。因此,必须存储令牌的摘要。为了进一步保障安全,我们还计划过几个小时后让重设链接失效,所以要记录重设邮件发送的时间。据此,我们要添加两个属性:`reset_digest` 和 `reset_sent_at`,如[图 10.11](#fig-user-model-password-reset) 所示。
![user model password reset](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306abc0c6.png)图 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](chapter8.html#listing-login-form)),编写请求重设密码的表单。为了便于参考,我们再把这个表单列出来,如[代码清单 10.39](#listing-login-form-redux) 所示。
##### 代码清单 10.39:登录表单的代码
app/views/sessions/new.html.erb
```
<% provide(:title, "Log in") %>
```
请求重设密码的表单和[代码清单 10.39](#listing-login-form-redux) 有很多共通之处,最大的区别是,`form_for` 中的资源和地址不一样,而且也没有密码字段。请求重设密码的表单如[代码清单 10.40](#listing-new-password-reset) 所示,渲染的结果如[图 10.12](#fig-forgot-password-form) 所示。
##### 代码清单 10.40:请求重设密码页面的视图
app/views/password_resets/new.html.erb
```
<% provide(:title, "Forgot password") %>
```
![forgot password form](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306ad1c8f.png)图 10.12:“Forgot Password”表单
提交[图 10.12](#fig-forgot-password-form) 中的表单后,我们要通过电子邮件地址查找用户,更新这个用户的 `reset_token`、`reset_digest` 和 `reset_sent_at` 属性,然后重定向到根地址,并显示一个闪现消息。和登录一样([代码清单 8.9](chapter8.html#listing-correct-login-failure)),如果提交的数据无效,我们要重新渲染这个页面,并且显示一个 `flash.now` 消息。据此,写出的 `create` 动作如[代码清单 10.41](#listing-create-password-reset) 所示。
##### 代码清单 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](#listing-user-model-password-reset) 所示。
##### 代码清单 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](#fig-invalid-email-password-reset) 所示,提交无效电子邮件地址时,应用的表现正常。为了让提交有效地址时应用也能正常运行,我们要定义发送密码重设邮件的方法,这一步会在 [10.2.3 节](#password-reset-mailer-method)完成。
![invalid email password reset](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b0bd8a.png)图 10.13:提交无效电子邮件地址后显示的“Forgot Password”表单
## 10.2.3 邮件程序
[代码清单 10.42](#listing-user-model-password-reset) 中发送密码重设邮件的代码是:
```
UserMailer.password_reset(self).deliver_now
```
让这个邮件程序运作起来所需的代码几乎和 [10.1.2 节](#account-activation-mailer-method)的账户激活邮件程序一样。我们首先在 `UserMailer` 中定义 `password_reset` 方法([代码清单 10.43](#listing-mail-password-reset)),然后再编写邮件的纯文本视图([代码清单 10.44](#listing-password-reset-text))和 HTML 视图([代码清单 10.45](#listing-password-reset-html))。
##### 代码清单 10.43:发送密码重设链接
app/mailers/user_mailer.rb
```
class UserMailer < ApplicationMailer
default from: "noreply@example.com"
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
```
```
注意,在[代码清单 10.50](#listing-password-reset-form) 中,使用的表单字段辅助方法是
```
hidden_field_tag :email, @user.email
```
而不是
```
f.hidden_field :email, @user.email
```
因为在重设密码的链接中,邮件地址在 `params[:email]` 中,如果使用后者,就会把邮件地址放入 `params[:user][:email]` 中。
为了正确渲染这个表单,我们要在 `PasswordResetsController` 的 `edit` 控制器中定义 `@user` 变量。和账户激活一样([代码清单 10.29](#listing-account-activation-edit-action)),我们要找到 `params[:email]` 中电子邮件地址对应的用户,确认这个用户已经激活,然后使用[代码清单 10.24](#listing-generalized-authenticated-p) 中的 `authenticated?` 方法认证 `params[:id]` 中的令牌。因为在 `edit` 和 `update` 动作中都要使用 `@user`,所以我们要把查找用户和认证令牌的代码写入一个事前过滤器中,如[代码清单 10.51](#listing-password-reset-edit-action) 所示。
##### 代码清单 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](#listing-password-reset-edit-action) 中的 `authenticated?(:reset, params[:id])`,[代码清单 10.26](#listing-generalized-current-user) 中的 `authenticated?(:remember, cookies[:remember_token])`,以及[代码清单 10.29](#listing-account-activation-edit-action) 中的 `authenticated?(:activation, params[:id])`,就是[表 10.1](#table-password-token-digest) 中 `authenticated?` 方法的三个用例。
现在,点击[代码清单 10.49](#listing-password-reset-email) 中的链接后,会显示密码重设表单,如[图 10.17](#fig-password-reset-form) 所示。
![password reset form](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306bb4777.png)图 10.17:密码重设表单
`edit` 动作对应的 `update` 动作要考虑四种情况:密码重设超时失效,重设成功,密码无效导致的重设失败,密码和密码确认为空值时导致的密码重设失败(此时看起来像是成功了)。前三种情况对应[代码清单 10.52](#listing-password-reset-update-action) 中外层 `if` 语句的三个分支。因为这个表单会修改 Active Record 模型(用户模型),所以我们可以使用共用的局部视图渲染错误消息。密码为空值的情况比较特殊,因为用户模型的验证允许出现这种情况(参见[代码清单 9.10](chapter9.html#listing-allow-blank-password)),所以要特别处理,直接在 `@user` 对象的错误消息中添加一个错误:[[8](#fn-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 节](#password-reset-mailer-method)的邮件模板所示,如果邮件发出后两个小时内没重设密码,就认为此次请求超时失效了。这个设想可以通过下面的 Ruby 代码实现:
```
reset_sent_at < 2.hours.ago
```
如果你把 `<` 当成小于号,读成“密码重设邮件发出少于两小时”就错了,和想表达的意思正好相反。 这里,最好把 `<` 理解成“超过”,读成“密码重设邮件已经发出超过两小时”,这才是我们想表达的意思。`password_reset_expired?` 方法的定义如[代码清单 10.53](#listing-user-model-password-reset-expired) 所示。(对这个比较算式的证明参见 [10.6 节](#proof-of-expiration-comparison)。)
##### 代码清单 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](#listing-password-reset-update-action) 中的 `update` 动作可以使用了。密码重设失败和成功后显示的页面分别如[图 10.18](#fig-password-reset-failure) 和[图 10.19](#fig-password-reset-success) 所示。(稍等一会,[10.5 节](#account-activation-and-password-reset-exercises)中有一题,为第三个分支编写测试。)
![password reset failure](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306bd376d.png)图 10.18:密码重设失败![password reset success](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306bebaae.png)图 10.19:密码重设成功
## 10.2.5 测试
本节,我们要编写一个集成测试,覆盖[代码清单 10.52](#listing-password-reset-update-action) 中的两个分支:重设失败和重设成功。(前面说过,第三个分支的测试留作练习。)首先,为重设密码生成一个测试文件:
```
$ rails generate integration_test password_resets
invoke test_unit
create test/integration/password_resets_test.rb
```
这个测试的步骤大致和[代码清单 10.31](#listing-signup-with-account-activation-test) 中的账户激活测试差不多,不过开头有点不同。首先访问“Forgot Password”表单,分别提交有效和无效的电子邮件地址,电子邮件地址有效时要创建密码重设令牌,并且发送重设邮件。然后,访问邮件中的链接,分别提交无效和有效的密码,验证各自的表现是否正确。最终写出的测试如[代码清单 10.54](#listing-password-reset-integration-test) 所示。这是一个不错的练习,可以锻炼阅读代码的能力。
##### 代码清单 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](#listing-password-reset-integration-test) 中的大多数用法前面都见过,但是针对 `input` 标签的测试有点陌生:
```
assert_select "input[name=email][type=hidden][value=?]", user.email
```
这行代码的意思是,页面中有 `name` 属性、类型(隐藏)和电子邮件地址都正确的 `input` 标签:
```
```
现在,测试组件应该能通过:
##### 代码清单 10.55:**GREEN**
```
$ bundle exec rake test
```
';
Log in
<%= 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 %>
Remember me on this computer
<% end %>
<%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>
New user? <%= link_to "Sign up now!", signup_path %>
Log in
<%= 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 %>
Remember me on this computer
<% end %>
<%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>
New user? <%= link_to "Sign up now!", signup_path %>
Forgot password
<%= 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 %>
Password reset
To reset your password click the link below:
<%= link_to "Reset password", 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.1.2 节](#account-activation-mailer-method)),我们可以使用 Rails 提供的邮件预览程序预览密码重设邮件。参照[代码清单 10.16](#listing-account-activation-preview),密码重设的邮件预览程序如[代码清单 10.46](#listing-password-reset-preview) 所示。 ##### 代码清单 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](#fig-password-reset-html-preview) 和[图 10.15](#fig-password-reset-text-preview) 所示。 ![password reset html preview](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b4195c.png)图 10.14:预览 HTML 格式的密码重设邮件![password reset text preview](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b7b091.png)图 10.15:预览纯文本格式的密码重设邮件 参照账户激活邮件程序的测试([代码清单 10.18](#listing-real-account-activation-test)),密码重设邮件程序的测试如[代码清单 10.47](#listing-password-reset-mailer-test) 所示。注意,我们要创建密码重设令牌,以便在视图中使用。这一点和激活令牌不一样,激活令牌使用 `before_create` 回调创建([代码清单 10.3](#listing-user-model-activation-code)),但是密码重设令牌只会在用户成功提交“Forgot Password”表单后创建。在集成测试中很容易创建密码重设令牌(参见[代码清单 10.54](#listing-password-reset-integration-test)),但在邮件程序的测试中必须手动创建。 ##### 代码清单 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 ["noreply@example.com"], 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 ["noreply@example.com"], 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](#listing-mail-password-reset)、[代码清单 10.44](#listing-password-reset-text) 和[代码清单 10.45](#listing-password-reset-html) 之后,提交有效电子邮件地址后显示的页面如[图 10.16](#fig-valid-email-password-reset) 所示。服务器日志中记录的邮件类似于[代码清单 10.49](#listing-password-reset-email)。 ![valid email password reset](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b96cce.png)图 10.16:提交有效电子邮件地址后显示的页面 ##### 代码清单 10.49:服务器日志中记录的一封密码重设邮件 ``` Sent mail to michael@michaelhartl.com (66.8ms) Date: Thu, 04 Sep 2014 01:04:59 +0000 From: noreply@example.com To: michael@michaelhartl.com Message-ID: <5407babbee139_8722b257d04576a@mhartl-rails-tutorial-953753.mail> 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: 7bitPassword reset
To reset your password click the link below:
Reset passwordThis 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-- ``` ## 10.2.4 重设密码 为了让下面这种形式的链接生效,我们要编写一个表单,重设密码。 ``` http://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=foo%40bar.com ``` 这个表单的目的和编辑用户资料的表单([代码清单 9.2](chapter9.html#listing-user-edit-view))类似,不过现在只需更新密码和密码确认字段。而且处理起来有点复杂,因为我们希望通过电子邮件地址查找用户,也就是说,在 `edit` 动作和 `update` 动作中都需要使用邮件地址。在 `edit` 动作中可以轻易的获取邮件地址,因为链接中有。可是提交表单后,邮件地址就没有了。为了解决这个问题,我们可以使用一个“隐藏字段”,把这个字段的值设为邮件地址(不会显示),和表单中的其他数据一起提交给 `update` 动作,如[代码清单 10.50](#listing-password-reset-form) 所示。 ##### 代码清单 10.50:重设密码的表单 app/views/password_resets/edit.html.erb ``` <% provide(:title, 'Reset password') %>Reset password
<%= 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 %>