# 10.2 密碼重設
完成賬戶激活功能后(從而確認了用戶的電子郵件地址可用),我們要處理一種常見的問題:用戶忘記密碼。我們會看到,密碼重設的很多步驟和賬戶激活類似,所以這里會用到 [10.1 節](#account-activation)學到的知識。不過,開頭不一樣,和賬戶激活功能不同的是,密碼重設要修改一個視圖,還要創建兩個表單(處理電子郵件地址提交和設定新密碼)。
編寫代碼之前,我們先構思要實現的重設密碼步驟。首先,我們要在演示應用的登錄表單中添加“Forgot Password”(忘記密碼)鏈接,如[圖 10.7](#fig-login-forgot-password-mockup) 所示。
圖 10.7:“Forgot Password”鏈接的構思圖
點擊“Forgot Password”鏈接后打開一個頁面,這個頁面中有一個表單,要求輸入電子郵件地址,提交后向這個地址發送一封包含密碼重設鏈接的郵件,如[圖 10.8](#fig-forgot-password-form-mockup) 所示。
圖 10.8:“Forgot Password”表單的構思圖
點擊密碼重設鏈接會打開一個表單,用戶在這個表單中重設密碼(還要填寫密碼確認),如[圖 10.9](#fig-reset-password-form-mockup) 所示。
圖 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") %>
<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](#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) 所示。
圖 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") %>
<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](#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") %>
<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](#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)完成。
圖 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
```
<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 節](#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) 所示。
圖 10.14:預覽 HTML 格式的密碼重設郵件圖 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)。
圖 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: 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](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') %>
<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](#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) 所示。
圖 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)中有一題,為第三個分支編寫測試。)
圖 10.18:密碼重設失敗圖 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` 標簽:
```
<input id="email" name="email" type="hidden" value="michael@example.com" />
```
現在,測試組件應該能通過:
##### 代碼清單 10.55:**GREEN**
```
$ bundle exec rake test
```
- Ruby on Rails 教程
- 致中國讀者
- 序
- 致謝
- 作者譯者簡介
- 版權和代碼授權協議
- 第 1 章 從零開始,完成一次部署
- 1.1 簡介
- 1.2 搭建環境
- 1.3 第一個應用
- 1.4 使用 Git 做版本控制
- 1.5 部署
- 1.6 小結
- 1.7 練習
- 第 2 章 玩具應用
- 2.1 規劃應用
- 2.2 用戶資源
- 2.3 微博資源
- 2.4 小結
- 2.5 練習
- 第 3 章 基本靜態的頁面
- 3.1 創建演示應用
- 3.2 靜態頁面
- 3.3 開始測試
- 3.4 有點動態內容的頁面
- 3.5 小結
- 3.6 練習
- 3.7 高級測試技術
- 第 4 章 Rails 背后的 Ruby
- 4.1 導言
- 4.2 字符串和方法
- 4.3 其他數據類型
- 4.4 Ruby 類
- 4.5 小結
- 4.6 練習
- 第 5 章 完善布局
- 5.1 添加一些結構
- 5.2 Sass 和 Asset Pipeline
- 5.3 布局中的鏈接
- 5.4 用戶注冊:第一步
- 5.5 小結
- 5.6 練習
- 第 6 章 用戶模型
- 6.1 用戶模型
- 6.2 用戶數據驗證
- 6.3 添加安全密碼
- 6.4 小結
- 6.5 練習
- 第 7 章 注冊
- 7.1 顯示用戶的信息
- 7.2 注冊表單
- 7.3 注冊失敗
- 7.4 注冊成功
- 7.5 專業部署方案
- 7.6 小結
- 7.7 練習
- 第 8 章 登錄和退出
- 8.1 會話
- 8.2 登錄
- 8.3 退出
- 8.4 記住我
- 8.5 小結
- 8.6 練習
- 第 9 章 更新,顯示和刪除用戶
- 9.1 更新用戶
- 9.2 權限系統
- 9.3 列出所有用戶
- 9.4 刪除用戶
- 9.5 小結
- 9.6 練習
- 第 10 章 賬戶激活和密碼重設
- 10.1 賬戶激活
- 10.2 密碼重設
- 10.3 在生產環境中發送郵件
- 10.4 小結
- 10.5 練習
- 10.6 證明超時失效的比較算式
- 第 11 章 用戶的微博
- 11.1 微博模型
- 11.2 顯示微博
- 11.3 微博相關的操作
- 11.4 微博中的圖片
- 11.5 小結
- 11.6 練習
- 第 12 章 關注用戶
- 12.1 “關系”模型
- 12.2 關注用戶的網頁界面
- 12.3 動態流
- 12.4 小結
- 12.5 練習