# 10.1 账户激活
目前,用户注册后立即就能完全控制自己的账户([第 7 章](chapter7.html#sign-up))。本节,我们要添加一步,激活用户的账户,从而确认用户拥有注册时使用的电子邮件地址。为此,我们要为用户创建激活令牌和摘要,然后给用户发送一封电子邮件,提供包含令牌的链接。用户点击这个链接后,激活这个账户。
我们要采取的实现步骤与注册用户([8.2 节](chapter8.html#logging-in))和记住用户([8.4 节](chapter8.html#remember-me))差不多,如下所示:
1. 用户一开始处于“未激活”状态;
2. 用户注册后,生成一个激活令牌和对应的激活摘要;
3. 把激活摘要存储在数据库中,然后给用户发送一封电子邮件,提供一个包含激活令牌和用户电子邮件地址的链接;[[2](#fn-2)]
4. 用户点击这个链接后,使用电子邮件地址查找用户,并且对比令牌和摘要;
5. 如果令牌和摘要匹配,就把状态由“未激活”改为“已激活”。
因为与密码和记忆令牌类似,实现账户激活(以及密码重设)功能时可以继续使用前面的很多方法,包括 `User.digest`、`User.new_token` 和修改过的 `user.authenticated?`。这几个功能(包括 [10.2 节](#password-reset)要实现的密码重设)之间的对比,如[表 10.1](#table-password-token-digest) 所示。我们会在 [10.1.3 节](#activating-the-account)定义可用于表中所有情况的通用版 `authenticated?` 方法。
表 10.1:登录,记住状态,账户激活和密码重设之间的对比
| 查找方式 | 字符串 | 摘要 | 认证 |
| --- | --- | --- | --- |
| `email` | `password` | `password_digest` | `authenticate(password)` |
| `id` | `remember_token` | `remember_digest` | `authenticated?(:remember, token)` |
| `email` | `activation_token` | `activation_digest` | `authenticated?(:activation, token)` |
| `email` | `reset_token` | `reset_digest` | `authenticated?(:reset, token)` |
和之前一样,我们要在主题分支中开发新功能。读到 [10.3 节](#email-in-production)会发现,账户激活和密码重设需要共用一些电子邮件设置,合并到 `master` 分支之前,要把这些设置应用到这两个功能上,所以在一个分支中开发这两个功能比较方便:
```
$ git checkout master
$ git checkout -b account-activation-password-resets
```
## 10.1.1 资源
和会话一样([8.1 节](chapter8.html#sessions)),我们要把“账户激活”看做一个资源,不过这个资源不对应模型,相关的数据(激活令牌和激活状态)存储在用户模型中。然而,我们要通过标准的 REST URL 处理账户激活操作。激活链接会改变用户的激活状态,所以我们计划在 `edit` 动作中处理。[[3](#fn-3)]所需的控制器使用下面的命令生成:[[4](#fn-4)]
```
$ rails generate controller AccountActivations --no-test-framework
```
我们需要使用下面的方法生成一个 URL,放在激活邮件中:
```
edit_account_activation_url(activation_token, ...)
```
因此,我们需要为 `edit` 动作设定一个具名路由——通过[代码清单 10.1](#listing-account-activations-route) 中高亮显示的那行 `resources` 实现。
##### 代码清单 10.1:添加账户激活所需的资源路由
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] end
```
接下来,我们需要一个唯一的激活令牌,用来激活用户。密码、记忆令牌和密码重设([10.2 节](#password-reset))需要考虑很多安全隐患,因为如果攻击者获取了这些信息就能完全控制账户。账户激活则不需要这么麻烦,但如果不哈希激活令牌,账户也有一定危险。[[5](#fn-5)]所以,参照记住登录状态的做法([8.4 节](chapter8.html#remember-me)),我们会公开令牌,而在数据库中存储哈希摘要。这么做,我们可以使用下面的方式获取激活令牌:
```
user.activation_token
```
使用下面的代码认证用户:
```
user.authenticated?(:activation, token)
```
(不过得先修改[代码清单 8.33](chapter8.html#listing-authenticated-p) 中定义的 `authenticated?` 方法。)我们还要定义一个布尔值属性 `activated`,使用自动生成的布尔值方法检查用户的激活状态(类似 [9.4.1 节](chapter9.html#administrative-users)使用的方法):
```
if user.activated? ...
```
最后,我们还要记录激活的日期和时间,虽然本书用不到,但说不定以后需要使用。完整的数据模型如[图 10.1](#fig-user-model-account-activation) 所示。
![user model account activation](https://box.kancloud.cn/2016-05-11_57333066c61a2.png)图 10.1:添加账户激活相关属性后的用户模型
下面的命令生成一个迁移,添加这些属性。我们在命令行中指定了要添加的三个属性:
```
$ rails generate migration add_activation_to_users \
> activation_digest:string activated:boolean activated_at:datetime
```
和 `admin` 属性一样([代码清单 9.50](chapter9.html#listing-admin-migration)),我们要把 `activated` 属性的默认值设为 `false`,如[代码清单 10.2](#listing-add-activation-to-users-migration) 所示。
##### 代码清单 10.2:添加账户激活所需属性的迁移
db/migrate/[timestamp]_add_activation_to_users.rb
```
class AddActivationToUsers < ActiveRecord::Migration
def change
add_column :users, :activation_digest, :string
add_column :users, :activated, :boolean, default: false add_column :users, :activated_at, :datetime
end
end
```
然后像之前一样,执行迁移:
```
$ bundle exec rake db:migrate
```
因为每个新注册的用户都得激活,所以我们应该在创建用户对象之前为用户分配激活令牌和摘要。类似的操作在 [6.2.5 节](chapter6.html#uniqueness-validation)见过,那时我们要在用户存入数据库之前把电子邮件地址转换成小写形式。我们使用的是 `before_save` 回调和 `downcase` 方法([代码清单 6.31](chapter6.html#listing-email-downcase))。`before_save` 回调在保存对象之前,包括创建对象和更新对象,自动调用。不过现在我们只想在创建用户之前调用回调,创建激活摘要。为此,我们要使用 `before_create` 回调,按照下面的方式定义:
```
before_create :create_activation_digest
```
这种写法叫“方法引用”,Rails 会寻找一个名为 `create_activation_digest` 的方法,在创建用户之前调用。(在[代码清单 6.31](chapter6.html#listing-email-downcase) 中,我们直接把一个块传给 `before_save`。不过方法引用是推荐的做法。)`create_activation_digest` 方法只会在用户模型内使用,没必要公开。如 [7.3.2 节](chapter7.html#strong-parameters)所示,在 Ruby 中可以使用 `private` 实现这个需求:
```
private
def create_activation_digest
# 创建令牌和摘要
end
```
在一个类中,`private` 之后的方法都会自动“隐藏”。我们可以在控制器会话中验证这一点:
```
$ rails console
>> User.first.create_activation_digest
NoMethodError: private method `create_activation_digest' called for #<User>
```
这个 `before_create` 回调的作用是为用户分配令牌和对应的摘要,实现的方法如下所示:
```
self.activation_token = User.new_token
self.activation_digest = User.digest(activation_token)
```
这里用到了实现“记住我”功能时用来生成令牌和摘要的方法。我们可以把这两行代码和[代码清单 8.32](chapter8.html#listing-user-model-remember) 中的 `remember` 方法比较一下:
```
# 为了持久会话,在数据库中记住用户
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
```
二者之间的主要区别是,`remember` 方法中使用的是 `update_attribute`。因为,创建记忆令牌和摘要时,用户已经存在于数据库中了,而 `before_create` 回调在创建用户之前执行。有了这个回调,使用 `User.new` 新建用户后(例如用户注册后,参见[代码清单 7.17](chapter7.html#listing-create-action-strong-parameters)),会自动赋值 `activation_token` 和 `activation_digest` 属性,而且因为 `activation_digest` 对应数据库中的一个列([图 10.1](#fig-user-model-account-activation)),所以保存用户时会自动把属性的值存入数据库。
综上所述,用户模型如[代码清单 10.3](#listing-user-model-activation-code) 所示。因为激活令牌是虚拟属性,所以我们又添加了一个 `attr_accessor`。注意,我们还把电子邮件地址转换成小写的回调改成了方法引用形式。
##### 代码清单 10.3:在用户模型中添加账户激活相关的代码 GREEN
app/models/user.rb
```
class User < ActiveRecord::Base
attr_accessor :remember_token, :activation_token before_save :downcase_email before_create :create_activation_digest validates :name, presence: true, length: { maximum: 50 }
.
.
.
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.4](#listing-seed-users-activated) 和[代码清单 10.5](#listing-fixture-users-activated) 所示。(`Time.zone.now` 是 Rails 提供的辅助方法,基于服务器使用的时区,返回当前时间戳。)
##### 代码清单 10.4:激活种子数据中的用户
db/seeds.rb
```
User.create!(name: "Example User",
email: "example@railstutorial.org",
password: "foobar",
password_confirmation: "foobar",
admin: true,
activated: true, activated_at: Time.zone.now)
99.times do |n|
name = Faker::Name.name
email = "example-#{n+1}@railstutorial.org"
password = "password"
User.create!(name: name,
email: email,
password: password,
password_confirmation: password,
activated: true, activated_at: Time.zone.now) end
```
##### 代码清单 10.5:激活固件中的用户
test/fixtures/users.yml
```
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
admin: true
activated: true activated_at: <%= Time.zone.now %>
archer:
name: Sterling Archer
email: duchess@example.gov
password_digest: <%= User.digest('password') %>
activated: true activated_at: <%= Time.zone.now %>
lana:
name: Lana Kane
email: hands@example.gov
password_digest: <%= User.digest('password') %>
activated: true activated_at: <%= Time.zone.now %>
malory:
name: Malory Archer
email: boss@example.gov
password_digest: <%= User.digest('password') %>
activated: true activated_at: <%= Time.zone.now %>
<% 30.times do |n| %>
user_<%= n %>:
name: <%= "User #{n}" %>
email: <%= "user-#{n}@example.com" %>
password_digest: <%= User.digest('password') %>
activated: true activated_at: <%= Time.zone.now %> <% end %>
```
为了应用[代码清单 10.4](#listing-seed-users-activated) 中的改动,我们要还原数据库,然后像之前一样写入数据:
```
$ bundle exec rake db:migrate:reset
$ bundle exec rake db:seed
```
## 10.1.2 邮件程序
写好模型后,我们要编写发送账户激活邮件的代码了。我们要使用 Action Mailer 库创建一个邮件程序,在用户控制器的 `create` 动作中发送一封包含激活链接的邮件。邮件程序的结构和控制器动作差不多,邮件模板使用视图定义。这一节的任务是创建邮件程序,以及编写视图,写入激活账户所需的激活令牌和电子邮件地址。
与模型和控制器一样,我们可以使用 `rails generate` 生成邮件程序:
```
$ rails generate mailer UserMailer account_activation password_reset
```
我们使用这个命令生成了所需的 `account_activation` 方法,以及 [10.2 节](#password-reset)要使用的 `password_reset` 方法。
生成邮件程序时,Rails 还为每个邮件程序生成了两个视图模板,一个用于纯文本邮件,一个用于 HTML 邮件。账户激活邮件程序的两个视图如[代码清单 10.6](#listing-generated-account-activation-view-text) 和[代码清单 10.7](#listing-generated-account-activation-view-html) 所示。
##### 代码清单 10.6:生成的账户激活邮件视图,纯文本格式
app/views/user_mailer/account_activation.text.erb
```
UserMailer#account_activation
<%= @greeting %>, find me in app/views/user_mailer/account_activation.text.erb
```
##### 代码清单 10.7:生成的账户激活邮件视图,HTML 格式
app/views/user_mailer/account_activation.html.erb
```
<h1>UserMailer#account_activation</h1>
<p>
<%= @greeting %>, find me in app/views/user_mailer/account_activation.html.erb
</p>
```
我们看一下生成的邮件程序,了解它是如何工作的,如[代码清单 10.8](#listing-generated-application-mailer) 和[代码清单 10.9](#listing-generated-user-mailer)所示。代码[代码清单 10.8](#listing-generated-application-mailer) 设置了一个默认的发件人地址(`from`),整个应用中的全部邮件程序都会使用这个地址。(这个代码清单还设置了各种邮件格式使用的布局。本书不会讨论邮件的布局,生成的 HTML 和纯文本格式邮件布局在 `app/views/layouts` 文件夹中。)[代码清单 10.9](#listing-generated-user-mailer) 中的每个方法中都设置了收件人地址。在生成的代码中还有一个实例变量 `@greeting`,这个变量可在邮件程序的视图中使用,就像控制器中的实例变量可以在普通的视图中使用一样。
##### 代码清单 10.8:生成的 `ApplicationMailer`
app/mailers/application_mailer.rb
```
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout 'mailer'
end
```
##### 代码清单 10.9:生成的 `UserMailer`
app/mailers/user_mailer.rb
```
class UserMailer < ActionMailer::Base
# Subject can be set in your I18n file at config/locales/en.yml
# with the following lookup:
#
# en.user_mailer.account_activation.subject
#
def account_activation
@greeting = "Hi"
mail to: "to@example.org" end
# Subject can be set in your I18n file at config/locales/en.yml
# with the following lookup:
#
# en.user_mailer.password_reset.subject
#
def password_reset
@greeting = "Hi"
mail to: "to@example.org" end
end
```
为了发送激活邮件,我们首先要修改生成的模板,如[代码清单 10.10](#listing-application-mailer) 所示。然后要创建一个实例变量,其值是用户对象,以便在视图中使用,然后把邮件发给 `user.email`。如[代码清单 10.11](#listing-mail-account-activation) 所示,`mail` 方法还可以接受 `subject` 参数,指定邮件的主题。
##### 代码清单 10.10:在 `ApplicationMailer` 中设定默认的发件人地址
```
class ApplicationMailer < ActionMailer::Base
default from: "noreply@example.com"
layout 'mailer'
end
```
##### 代码清单 10.11:发送账户激活链接
app/mailers/user_mailer.rb
```
class UserMailer < ApplicationMailer
def account_activation(user) @user = user mail to: user.email, subject: "Account activation" end
def password_reset
@greeting = "Hi"
mail to: "to@example.org"
end
end
```
和普通的视图一样,在邮件程序的视图中也可以使用嵌入式 Ruby。在邮件中我们要添加一个针对用户的欢迎消息,以及一个激活链接。我们计划使用电子邮件地址查找用户,然后使用激活令牌认证用户,所以链接中要包含电子邮件地址和令牌。因为我们把“账户激活”视作一个资源,所以可以把令牌作为参数传给[代码清单 10.1](#listing-account-activations-route) 中定义的具名路由:
```
edit_account_activation_url(@user.activation_token, ...)
```
我们知道,`edit_user_url(user)` 生成的地址是下面这种形式:
```
http://www.example.com/users/1/edit
```
那么,账户激活的链接应该是这种形式:
```
http://www.example.com/account_activations/q5lt38hQDc_959PVoo6b7A/edit
```
其中,`q5lt38hQDc_959PVoo6b7A` 是使用 `new_token` 方法([代码清单 8.31](chapter8.html#listing-token-method))生成的 base64 字符串,可安全地在 URL 中使用。这个值的作用和 /users/1/edit 中的用户 ID 一样,在 `AccountActivationsController` 的 `edit` 动作中可以通过 `params[:id]` 获取。
为了包含电子邮件地址,我们要使用“查询参数”(query parameter)。查询参数放在 URL 中的问号后面,使用键值对形式指定:[[6](#fn-6)]
```
account_activations/q5lt38hQDc_959PVoo6b7A/edit?email=foo%40example.com
```
注意,电子邮件地址中的“@”被替换成了 `%40`,也就是被转义了,这样,URL 才是有效的。在 Rails 中设定查询参数的方法是,把一个哈希传给具名路由:
```
edit_account_activation_url(@user.activation_token, email: @user.email)
```
使用这种方式设定查询参数,Rails 会自动转义所有特殊字符。而且,在控制器中会自动反转义电子邮件地址,通过 `params[:email]` 可以获取电子邮件地址。
定义好实例变量 `@user` 之后([代码清单 10.11](#listing-mail-account-activation)),我们可以使用 `edit` 动作的具名路由和嵌入式 Ruby 创建所需的链接了,如[代码清单 10.12](#listing-account-activation-view-text) 和[代码清单 10.13](#listing-account-activation-view-html) 所示。注意,在[代码清单 10.13](#listing-account-activation-view-html) 中,我们使用 `link_to` 方法创建有效的链接。
##### 代码清单 10.12:账户激活邮件的纯文本视图
app/views/user_mailer/account_activation.text.erb
```
Hi <%= @user.name %>,
Welcome to the Sample App! Click on the link below to activate your account:
<%= edit_account_activation_url(@user.activation_token, email: @user.email) %>
```
##### 代码清单 10.13:账户激活邮件的 HTML 视图
app/views/user_mailer/account_activation.html.erb
```
<h1>Sample App</h1>
<p>Hi <%= @user.name %>,</p>
<p>
Welcome to the Sample App! Click on the link below to activate your account:
</p>
<%= link_to "Activate", edit_account_activation_url(@user.activation_token,
email: @user.email) %>
```
若想查看这两个邮件视图的效果,我们可以使用邮件预览功能。Rails 提供了一些特殊的 URL,用来预览邮件。首先,我们要在应用的开发环境中添加一些设置,如[代码清单 10.14](#listing-development-email-settings) 所示。
##### 代码清单 10.14:开发环境中的邮件设置
config/environments/development.rb
```
Rails.application.configure do
.
.
.
config.action_mailer.raise_delivery_errors = true
config.action_mailer.delivery_method = :test
host = 'example.com'
config.action_mailer.default_url_options = { host: host }
.
.
.
end
```
[代码清单 10.14](#listing-development-email-settings) 中设置的主机地址是 `'example.com'`,你应该使用你的开发环境的主机地址。例如,在我的系统中,可以使用下面的地址(包括云端 IDE 和本地服务器):
```
host = 'rails-tutorial-c9-mhartl.c9.io' # 云端 IDE
host = 'localhost:3000' # 本地主机
```
然后重启开发服务器,让[代码清单 10.14](#listing-development-email-settings) 中的设置生效。接下来,我们要修改邮件程序的预览文件。生成邮件程序时已经自动生成了这个文件,如[代码清单 10.15](#listing-generated-user-mailer-previews) 所示。
##### 代码清单 10.15:生成的邮件预览程序
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
UserMailer.account_activation
end
# Preview this email at
# http://localhost:3000/rails/mailers/user_mailer/password_reset
def password_reset
UserMailer.password_reset
end
end
```
因为[代码清单 10.11](#listing-mail-account-activation) 中定义的 `account_activation` 方法需要一个有效的用户作为参数,所以[代码清单 10.15](#listing-generated-user-mailer-previews) 中的代码现在还不能使用。为了解决这个问题,我们要定义 `user` 变量,把开发数据库中的第一个用户赋值给它,然后作为参数传给 `UserMailer.account_activation`,如[代码清单 10.16](#listing-account-activation-preview) 所示。注意,在这段代码中,我们还给 `user.activation_token` 赋了值,因为[代码清单 10.12](#listing-account-activation-view-text) 和[代码清单 10.13](#listing-account-activation-view-html) 中的模板要使用账户激活令牌。(`activation_token` 是虚拟属性,所以数据库中的用户并没有激活令牌。)
##### 代码清单 10.16:预览账户激活邮件所需的方法
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
UserMailer.password_reset
end
end
```
这样修改之后,我们就可以访问注释中提示的 URL 预览账户激活邮件了。(如果使用云端 IDE,要把 `localhost:3000` 换成相应的 URL。)HTML 和纯文本邮件分别如[图 10.2](#fig-account-activation-html-preview) 和[图 10.3](#fig-account-activation-text-preview) 所示。
![account activation html preview](https://box.kancloud.cn/2016-05-11_57333066da535.png)图 10.2:预览 HTML 格式的账户激活邮件![account activation text preview](https://box.kancloud.cn/2016-05-11_57333066f37bf.png)图 10.3:预览纯文本格式的账户激活邮件
最后,我们要编写一些测试,再次确认邮件的内容。这并不难,因为 Rails 生成了一些有用的测试示例,如[代码清单 10.17](#listing-generated-user-mailer-test) 所示。
##### 代码清单 10.17:Rails 生成的 `UserMailer` 测试
test/mailers/user_mailer_test.rb
```
require 'test_helper'
class UserMailerTest < ActionMailer::TestCase
test "account_activation" do
mail = UserMailer.account_activation
assert_equal "Account activation", mail.subject
assert_equal ["to@example.org"], mail.to
assert_equal ["from@example.com"], mail.from
assert_match "Hi", mail.body.encoded
end
test "password_reset" do
mail = UserMailer.password_reset
assert_equal "Password reset", mail.subject
assert_equal ["to@example.org"], mail.to
assert_equal ["from@example.com"], mail.from
assert_match "Hi", mail.body.encoded
end
end
```
[代码清单 10.17](#listing-generated-user-mailer-test) 中使用了强大的 `assert_match` 方法。这个方法既可以匹配字符串,也可以匹配正则表达式:
```
assert_match 'foo', 'foobar' # true
assert_match 'baz', 'foobar' # false
assert_match /\w+/, 'foobar' # true
assert_match /\w+/, '$#!*+@' # false
```
[代码清单 10.18](#listing-real-account-activation-test) 使用 `assert_match` 检查邮件正文中是否有用户的名字、激活令牌和转义后的电子邮件地址。注意,转义用户电子邮件地址使用的方法是 `CGI::escape(user.email)`。[[7](#fn-7)](其实还有第三种方法,`ERB::Util` 中的 [`url_encode` 方法](http://apidock.com/ruby/ERB/Util/url_encode)有同样的效果。)
##### 代码清单 10.18:测试现在这个邮件程序 RED
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
end
```
注意,我们在[代码清单 10.18](#listing-real-account-activation-test) 中为用户固件指定了激活令牌,因为固件中没有虚拟属性。
为了让这个测试通过,我们要修改测试环境的配置,设定正确的主机地址,如[代码清单 10.19](#listing-test-domain-host) 所示。
##### 代码清单 10.19:设定测试环境的主机地址
config/environments/test.rb
```
Rails.application.configure do
.
.
.
config.action_mailer.delivery_method = :test
config.action_mailer.default_url_options = { host: 'example.com' } .
.
.
end
```
现在,邮件程序的测试应该可以通过了:
##### 代码清单 10.20:**GREEN**
```
$ bundle exec rake test:mailers
```
若要在我们的应用中使用这个邮件程序,只需在处理用户注册的 `create` 动作中添加几行代码,如[代码清单 10.21](#listing-user-signup-with-account-activation) 所示。注意,[代码清单 10.21](#listing-user-signup-with-account-activation) 修改了注册后的重定向地址。之前,我们把用户重定向到资料页面([7.4 节](chapter7.html#successful-signups)),可是现在需要先激活,再转向这个页面就不合理了,所以把重定向地址改成了根地址。
##### 代码清单 10.21:在注册过程中添加账户激活 RED
app/controllers/users_controller.rb
```
class UsersController < ApplicationController
.
.
.
def create
@user = User.new(user_params)
if @user.save
UserMailer.account_activation(@user).deliver_now flash[:info] = "Please check your email to activate your account." redirect_to root_url else
render 'new'
end
end
.
.
.
end
```
因为现在重定向到根地址而不是资料页面,而且不会像之前那样自动登入用户,所以测试组件无法通过,不过应用能按照我们设计的方式运行。我们暂时把导致失败的测试注释掉,如[代码清单 10.22](#listing-comment-out-failing-tests) 所示。我们会在 [10.1.4 节](#activation-test-and-refactoring)去掉注释,并且为账户激活编写能通过的测试。
##### 代码清单 10.22:临时注释掉失败的测试 GREEN
test/integration/users_signup_test.rb
```
require 'test_helper'
class UsersSignupTest < ActionDispatch::IntegrationTest
test "invalid signup information" do
get signup_path
assert_no_difference 'User.count' do
post users_path, user: { name: "",
email: "user@invalid",
password: "foo",
password_confirmation: "bar" }
end
assert_template 'users/new'
assert_select 'div#error_explanation'
assert_select 'div.field_with_errors'
end
test "valid signup information" do
get signup_path
assert_difference 'User.count', 1 do
post_via_redirect users_path, user: { name: "Example User",
email: "user@example.com",
password: "password",
password_confirmation: "password" }
end
# assert_template 'users/show' # assert is_logged_in? end
end
```
如果现在注册,重定向后显示的页面如[图 10.4](#fig-redirected-not-activated) 所示,而且会生成一封邮件,如[代码清单 10.23](#listing-account-activation-email) 所示。注意,在开发环境中并不会真发送邮件,不过能在服务器的日志中看到(可能要往上滚动才能看到)。[10.3 节](#email-in-production)会介绍如何在生产环境中发送邮件。
##### 代码清单 10.23:在服务器日志中看到的账户激活邮件
```
Sent mail to michael@michaelhartl.com (931.6ms)
Date: Wed, 03 Sep 2014 19:47:18 +0000
From: noreply@example.com
To: michael@michaelhartl.com
Message-ID: <540770474e16_61d3fd1914f4cd0300a0@mhartl-rails-tutorial-953753.mail>
Subject: Account activation
Mime-Version: 1.0
Content-Type: multipart/alternative;
boundary="--==_mimepart_5407704656b50_61d3fd1914f4cd02996a";
charset=UTF-8
Content-Transfer-Encoding: 7bit
----==_mimepart_5407704656b50_61d3fd1914f4cd02996a
Content-Type: text/plain;
charset=UTF-8
Content-Transfer-Encoding: 7bit
Hi Michael Hartl,
Welcome to the Sample App! Click on the link below to activate your account:
http://rails-tutorial-c9-mhartl.c9.io/account_activations/
fFb_F94mgQtmlSvRFGsITw/edit?email=michael%40michaelhartl.com
----==_mimepart_5407704656b50_61d3fd1914f4cd02996a
Content-Type: text/html;
charset=UTF-8
Content-Transfer-Encoding: 7bit
<h1>Sample App</h1>
<p>Hi Michael Hartl,</p>
<p>
Welcome to the Sample App! Click on the link below to activate your account:
</p>
<a href="http://rails-tutorial-c9-mhartl.c9.io/account_activations/
fFb_F94mgQtmlSvRFGsITw/edit?email=michael%40michaelhartl.com">Activate</a>
----==_mimepart_5407704656b50_61d3fd1914f4cd02996a--
```
![redirected not activated](https://box.kancloud.cn/2016-05-11_5733306729172.png)图 10.4:注册后显示的首页,有一个提醒激活的消息
## 10.1.3 激活账户
现在可以正确生成电子邮件了([代码清单 10.23](#listing-account-activation-email)),接下来我们要编写 `AccountActivationsController` 中的 `edit` 动作,激活用户。[10.1.2 节](#account-activation-mailer-method)说过,激活令牌和电子邮件地址可以分别通过 `params[:id]` 和 `params[:email]` 获取。参照密码([代码清单 8.5](chapter8.html#listing-find-authenticate-user))和记忆令牌([代码清单 8.36](chapter8.html#listing-persistent-current-user))的实现方式,我们计划使用下面的代码查找和认证用户:
```
user = User.find_by(email: params[:email])
if user && user.authenticated?(:activation, params[:id])
```
(稍后会看到,上述代码还缺一个判断条件。看看你能否猜到缺了什么。)
上述代码使用 `authenticated?` 方法检查账户激活的摘要和指定的令牌是否匹配,但是现在不起作用,因为 `authenticated?` 方法是专门用来认证记忆令牌的([代码清单 8.33](chapter8.html#listing-authenticated-p)):
```
# 如果指定的令牌和摘要匹配,返回 true
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
```
其中,`remember_digest` 是用户模型的属性,在模型内,我们可以将其改写成:
```
self.remember_digest
```
我们希望以某种方式把这个值变成“变量”,这样才能调用 `self.activation_token`,而不是把合适的参数传给 `authenticated?` 方法。
我们要使用的解决方法涉及到“元编程”(metaprogramming),意思是用程序编写程序。(元编程是 Ruby 最强大的功能,Rails 中很多“神奇”的功能都是通过元编程实现的。)这里的关键是强大的 `send` 方法。这个方法的作用是在指定的对象上调用指定的方法。例如,在下面的控制台会话中,我们在一个 Ruby 原生对象上调用 `send` 方法,获取数组的长度:
```
$ rails console
>> a = [1, 2, 3]
>> a.length
=> 3
>> a.send(:length)
=> 3
>> a.send('length')
=> 3
```
可以看出,把 `:length` 符号或者 `'length'` 字符串传给 `send` 方法的作用和在对象上直接调用 `length` 方法的作用一样。再看一个例子,获取数据库中第一个用户的 `activation_digest` 属性:
```
>> user = User.first
>> user.activation_digest
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> user.send(:activation_digest)
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> user.send('activation_digest')
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> attribute = :activation >> user.send("#{attribute}_digest") => "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
```
注意最后一种调用方式,我们定义了一个 `attribute` 变量,其值为符号 `:activation`,然后使用字符串插值构建传给 `send` 方法的参数。`attribute` 变量的值使用字符串 `'activation'` 也行,不过符号更便利。不管使用什么,插值后,`"#{attribute}_digest"` 的结果都是 `"activation_digest"`。([7.4.2 节](chapter7.html#the-flash)介绍过,插值时会把符号转换成字符串。)
基于上述对 `send` 方法的介绍,我们可以把 `authenticated?` 方法改写成:
```
def authenticated?(remember_token)
digest = self.send('remember_digest')
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(remember_token)
end
```
以此为模板,我们可以为这个方法增加一个参数,代表摘要的名字,然后再使用字符串插值,扩大这个方法的用途:
```
def authenticated?(attribute, token)
digest = self.send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
```
(我们把第二个参数的名字改成了 `token`,以此强调这个方法的用途更广。)因为这个方法在用户模型内,所以可以省略 `self`,得到更符合习惯写法的版本:
```
def authenticated?(attribute, token)
digest = send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
```
现在我们可以像下面这样调用 `authenticated?` 方法实现以前的效果:
```
user.authenticated?(:remember, remember_token)
```
把修改后的 `authenticated?` 方法写入用户模型,如[代码清单 10.24](#listing-generalized-authenticated-p) 所示。
##### 代码清单 10.24:用途更广的 `authenticated?` 方法 RED
app/models/user.rb
```
class User < ActiveRecord::Base
.
.
.
# 如果指定的令牌和摘要匹配,返回 true
def authenticated?(attribute, token)
digest = send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
.
.
.
end
```
如[代码清单 10.24](#listing-generalized-authenticated-p) 的标题所示,测试组件无法通过:
##### 代码清单 10.25:**RED**
```
$ bundle exec rake test
```
失败的原因是,`current_user` 方法([代码清单 8.36](chapter8.html#listing-persistent-current-user))和摘要为 `nil` 的测试([代码清单 8.43](chapter8.html#listing-test-authenticated-invalid-token))使用的都是旧版 `authenticated?`,期望传入的是一个参数而不是两个。因此,我们只需修改这两个地方,换用修改后的 `authenticated?` 方法就能解决这个问题,如[代码清单 10.26](#listing-generalized-current-user) 和[代码清单 10.27](#listing-test-authenticated-invalid-token-updated) 所示。
##### 代码清单 10.26:在 `current_user` 中使用修改后的 `authenticated?` 方法 GREEN
app/helpers/sessions_helper.rb
```
module SessionsHelper
.
.
.
# 返回当前登录的用户(如果有的话)
def current_user
if (user_id = session[:user_id])
@current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.signed[:user_id])
user = User.find_by(id: user_id)
if user && user.authenticated?(:remember, cookies[:remember_token]) log_in user
@current_user = user
end
end
end
.
.
.
end
```
##### 代码清单 10.27:在 `UserTest` 中使用修改后的 `authenticated?` 方法 GREEN
test/models/user_test.rb
```
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com",
password: "foobar", password_confirmation: "foobar")
end
.
.
.
test "authenticated? should return false for a user with nil digest" do assert_not @user.authenticated?(:remember, '') end end
```
修改后,测试应该可以通过了:
##### 代码清单 10.28:**GREEN**
```
$ bundle exec rake test
```
没有坚实的测试组件做后盾,像这样的重构很容易出错,所以我们才要在 [8.4.2 节](chapter8.html#login-with-remembering)和 [8.4.6 节](chapter8.html#remember-tests)排除万难编写测试。
有了[代码清单 10.24](#listing-generalized-authenticated-p) 中定义的 `authenticated?` 方法,现在我们可以编写 `edit` 动作,认证 `params` 哈希中电子邮件地址对应的用户了。我们要使用的判断条件如下所示:
```
if user && !user.activated? && user.authenticated?(:activation, params[:id])
```
注意,这里加入了 `!user.activated?`,就是前面提到的那个缺失的条件,作用是避免激活已经激活的用户。这个条件很重要,因为激活后我们要登入用户,但是不能让获得激活链接的攻击者以这个用户的身份登录。
如果通过了上述判断条件,我们要激活这个用户,并且更新 `activated_at` 中的时间戳:
```
user.update_attribute(:activated, true)
user.update_attribute(:activated_at, Time.zone.now)
```
据此,写出的 `edit` 动作如[代码清单 10.29](#listing-account-activation-edit-action) 所示。注意,在[代码清单 10.29](#listing-account-activation-edit-action) 中我们还处理了激活令牌无效的情况。这种情况很少发生,但处理起来也很容易,直接重定向到根地址即可。
##### 代码清单 10.29:在 `edit` 动作中激活账户
app/controllers/account_activations_controller.rb
```
class AccountActivationsController < ApplicationController
def edit
user = User.find_by(email: params[:email])
if user && !user.activated? && user.authenticated?(:activation, params[:id])
user.update_attribute(:activated, true)
user.update_attribute(:activated_at, Time.zone.now)
log_in user
flash[:success] = "Account activated!"
redirect_to user
else
flash[:danger] = "Invalid activation link"
redirect_to root_url
end
end
end
```
然后,复制粘贴[代码清单 10.23](#listing-account-activation-email) 中的地址,应该就可以激活对应的用户了。例如,在我的系统中,我访问的地址是:
```
http://rails-tutorial-c9-mhartl.c9.io/account_activations/
fFb_F94mgQtmlSvRFGsITw/edit?email=michael%40michaelhartl.com
```
然后会看到如[图 10.5](#fig-activated-user) 所示的页面。
![activated user](https://box.kancloud.cn/2016-05-11_5733306746907.png)图 10.5:成功激活后显示的资料页面
当然,现在激活用户后没有什么实际效果,因为我们还没修改用户登录的方式。为了让账户激活有实际意义,只能允许已经激活的用户登录,即 `user.activated?` 返回 `true` 时才能像之前那样登录,否则重定向到根地址,并且显示一个提醒消息([图 10.6](#fig-not-activated-warning)),如[代码清单 10.30](#listing-preventing-unactivated-logins) 所示。
##### 代码清单 10.30:禁止未激活的用户登录
app/controllers/sessions_controller.rb
```
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
if user.activated? log_in user params[:session][:remember_me] == '1' ? remember(user) : forget(user) redirect_back_or user else message = "Account not activated. " message += "Check your email for the activation link." flash[:warning] = message redirect_to root_url end else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
log_out if logged_in?
redirect_to root_url
end
end
```
![not activated warning](https://box.kancloud.cn/2016-05-11_573330675d32c.png)图 10.6:未激活用户试图登录后看到的提醒消息
至此,激活用户的功能基本完成了,不过还有个地方可以改进。(可以改进的是,不显示未激活的用户。这个改进留作[练习](#account-activation-and-password-reset-exercises)。)[10.1.4 节](#activation-test-and-refactoring)会编写一些测试,再做一些重构,完成整个功能。
## 10.1.4 测试和重构
本节,我们要为账户激活功能添加一些集成测试。我们已经为提交有效信息的注册过程编写了测试,所以我们要把这个测试添加到 [7.4.4 节](chapter7.html#a-test-for-valid-submission)编写的测试中([代码清单 7.26](chapter7.html#listing-a-test-for-valid-submission))。在测试中,我们要添加好多步,不过意图都很明确,看看你是否能理解[代码清单 10.31](#listing-signup-with-account-activation-test) 中的测试。
##### 代码清单 10.31:在用户注册的测试文件中添加账户激活的测试 GREEN
test/integration/users_signup_test.rb
```
require 'test_helper'
class UsersSignupTest < ActionDispatch::IntegrationTest
def setup ActionMailer::Base.deliveries.clear end
test "invalid signup information" do
get signup_path
assert_no_difference 'User.count' do
post users_path, user: { name: "",
email: "user@invalid",
password: "foo",
password_confirmation: "bar" }
end
assert_template 'users/new'
assert_select 'div#error_explanation'
assert_select 'div.field_with_errors'
end
test "valid signup information with account activation" do get signup_path
assert_difference 'User.count', 1 do
post users_path, user: { name: "Example User", email: "user@example.com",
password: "password",
password_confirmation: "password" }
end
assert_equal 1, ActionMailer::Base.deliveries.size
user = assigns(:user)
assert_not user.activated?
# 尝试在激活之前登录
log_in_as(user)
assert_not is_logged_in?
# 激活令牌无效
get edit_account_activation_path("invalid token")
assert_not is_logged_in?
# 令牌有效,电子邮件地址不对
get edit_account_activation_path(user.activation_token, email: 'wrong')
assert_not is_logged_in?
# 激活令牌有效
get edit_account_activation_path(user.activation_token, email: user.email)
assert user.reload.activated?
follow_redirect!
assert_template 'users/show'
assert is_logged_in?
end
end
```
代码很多,不过有一行完全没见过:
```
assert_equal 1, ActionMailer::Base.deliveries.size
```
这行代码确认只发送了一封邮件。`deliveries` 是一个数组,会统计所有发出的邮件,所以我们要在 `setup` 方法中把它清空,以防其他测试发送了邮件([10.2.5 节](#password-reset-test)就会这么做)。[代码清单 10.31](#listing-signup-with-account-activation-test) 还第一次在本书正文中使用了 `assigns` 方法。[8.6 节](chapter8.html#log-in-log-out-exercises)说过,`assigns` 的作用是获取相应动作中的实例变量。例如,用户控制器的 `create` 动作中定义了一个 `@user` 变量,那么我们可以在测试中使用 `assigns(:user)` 获取这个变量的值。最后,注意,[代码清单 10.31](#listing-signup-with-account-activation-test) 把[代码清单 10.22](#listing-comment-out-failing-tests) 中的注释去掉了。
现在,测试组件应该可以通过:
##### 代码清单 10.32:**GREEN**
```
$ bundle exec rake test
```
有了[代码清单 10.31](#listing-signup-with-account-activation-test) 中的测试做后盾,接下来我们可以稍微重构一下了:把处理用户的代码从控制器中移出,放入模型。我们会定义一个 `activate` 方法,用来更新用户激活相关的属性;还要定义一个 `send_activation_email` 方法,发送激活邮件。这两个方法的定义如[代码清单 10.33](#listing-user-activation-methods) 所示,重构后的应用代码如[代码清单 10.34](#listing-user-signup-refactored) 和[代码清单 10.35](#listing-account-activation-refactored) 所示。
##### 代码清单 10.33:在用户模型中添加账户激活相关的方法
app/models/user.rb
```
class User < ActiveRecord::Base
.
.
.
# 激活账户
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
private
.
.
.
end
```
##### 代码清单 10.34:通过用户模型对象发送邮件
app/controllers/users_controller.rb
```
class UsersController < ApplicationController
.
.
.
def create
@user = User.new(user_params)
if @user.save
@user.send_activation_email flash[:info] = "Please check your email to activate your account."
redirect_to root_url
else
render 'new'
end
end
.
.
.
end
```
##### 代码清单 10.35:通过用户模型对象激活账户
app/controllers/account_activations_controller.rb
```
class AccountActivationsController < ApplicationController
def edit
user = User.find_by(email: params[:email])
if user && !user.activated? && user.authenticated?(:activation, params[:id])
user.activate log_in user
flash[:success] = "Account activated!"
redirect_to user
else
flash[:danger] = "Invalid activation link"
redirect_to root_url
end
end
end
```
注意,在[代码清单 10.33](#listing-user-activation-methods) 中没有使用 `user`。如果还像之前那样写就会出错,因为用户模型中没有这个变量:
```
-user.update_attribute(:activated, true)
-user.update_attribute(:activated_at, Time.zone.now)
+update_attribute(:activated, true)
+update_attribute(:activated_at, Time.zone.now)
```
(也可以把 `user` 换成 `self`,但 [6.2.5 节](chapter6.html#uniqueness-validation)说过,在模型内可以不加 `self`。)调用 `UserMailer` 时,还把 `@user` 改成了 `self`:
```
-UserMailer.account_activation(@user).deliver_now
+UserMailer.account_activation(self).deliver_now
```
就算是简单的重构,也可能忽略这些细节,不过好的测试组件能捕获这些问题。现在,测试组件应该仍能通过:
##### 代码清单 10.36:**GREEN**
```
$ bundle exec rake test
```
账户激活功能完成了,我们取得了一定进展,可以提交了:
```
$ git add -A
$ git commit -m "Add account activations"
```
- 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 练习