在线版的内容可能落后于电子书,如果想及时获得更新,请购买电子书

第 11 章 激活账户

目前,用户注册后立即就能完全控制自己的账户(第 7 章)。本章,我们将添加一步,激活用户的账户,从而确认用户拥有注册时填写的电子邮件地址。为此,我们要为用户创建激活令牌和摘要,然后给用户发送一封电子邮件,提供包含令牌的链接。用户点击这个链接后,激活自己的账户。在第 12 章,我们将通过相同的方式让忘记密码的用户重设密码。实现这两个功能都要创建新资源,借此机会我们还能再介绍一下控制器、路由和数据库迁移。在这个过程中,我们将学习如何在开发环境和生产环境中发送电子邮件。

我们要采取的实现步骤与注册用户(8.2 节)和记住用户(9.1 节)差不多,如下所示:[1]

  1. 用户一开始处于“未激活”状态;

  2. 用户注册后,生成一个激活令牌和对应的激活摘要;

  3. 把激活摘要存储在数据库中,然后给用户发送一封电子邮件,提供一个包含激活令牌和用户电子邮件地址的链接;[2]

  4. 用户点击那个链接后,使用电子邮件地址查找用户,并且对比令牌和摘要,验证用户的身份;

  5. 身份验证通过后,把状态由“未激活”改为“已激活”。

因为与密码和记忆令牌类似,实现账户激活(以及密码重设)功能时可以继续使用前面的很多方法,包括 User.digestUser.new_tokenuser.authenticated? 的修改版。这几个功能(包括第 12 章将实现的密码重设)之间的对比,如表 11.1 所示。

表 11.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)

11.1 节,我们将为账户激活功能创建一个资源和数据模型;在 11.2 节,我们将创建一个邮件程序(mailer),发送账户激活电子邮件;在 11.3 节,我们将实现激活账户的具体步骤,届时会定义通用版 authenticated? 方法。

11.1 AccountActivations 资源

与会话一样(8.1 节),我们要把“账户激活”看做一个资源(AccountActivations),不过这个资源不对应 Active Record 模型,相关的数据(包括激活令牌和激活状态)存储在 User 模型中。

我们将把“账户激活”看做一个资源,因此要使用标准的 REST URL 与之交互。激活链接要修改用户的激活状态,按照 REST 架构的规定,这种改动要向 update 动作发送 PATCH 请求(表 7.1)。可是激活链接在电子邮件中,必须经由用户点击,因此请求类型是 GET,而不是 PATCH。鉴于此,我们不能使用 update 动作;退而求其次,我们将使用 edit 动作,这个动作用于响应 GET 请求。

和之前一样,我们要在主题分支中开发新功能:

$ git checkout -b account-activation

11.1.1 AccountActivations 控制器

UsersSessions 资源类似,AccountActivations 资源相关的动作(这里只需要一个)放在 AccountActivations 控制器中。执行下述命令,生成 AccountActivations 控制器:[3]

$ rails generate controller AccountActivations

11.2.1 节将看到,我们会使用下面的方法生成一个 URL,放在激活邮件中:

edit_account_activation_url(activation_token, ...)

因此,我们要为 edit 动作设定一个具名路由——通过代码清单 11.1 中高亮显示的那行 resources 规则实现。那条规则得到的 REST 式路由见表 11.2

代码清单 11.1:为 AccountActivations 控制器的 edit 动作添加路由
config/routes.rb
Rails.application.routes.draw do
  root   'static_pages#home'
  get    '/help',    to: 'static_pages#help'
  get    '/about',   to: 'static_pages#about'
  get    '/contact', to: 'static_pages#contact'
  get    '/signup',  to: 'users#new'
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'
  resources :users
  resources :account_activations, only: [:edit]
end
表 11.2:在代码清单 11.1 中添加那条规则后得到的 REST 式路由
HTTP 请求 URL 动作 具名路由

GET

/account_activation/<token>/edit

edit

edit_account_activation_url(token)

接下来,我们先创建所需的数据模型和邮件程序,11.3.2 节再定义 edit 动作。

练习
  1. 确认测试组件仍能通过。

  2. 表 11.2 为什么列出具名路由的 _url 形式,而不是 _path 形式?提示:我们将在电子邮件中使用链接。

11.1.2 数据模型

前面说过,我们要在激活邮件中发送一个独一无二的激活令牌。为此,可以在数据库中存储一个字符串,并将其放到激活地址中。可是,这样做有安全隐患,一旦被“脱库”,将造成危害。例如,攻击者获得数据库的访问权后可以立即激活新注册的账户(将以那个用户的身份登录),然后修改密码,获得账户的控制权。[4]

为了避免这种情况发生,我们将参照密码(第 6 章)和记住我功能(第 9 章)的实现方式,公开一个虚拟属性的值,并在数据库中存储哈希摘要。这样,我们便能使用下述方法获取激活令牌:

user.activation_token

还能使用下面的代码验证用户的身份:

user.authenticated?(:activation, token)

(不过得先修改代码清单 9.6 中定义的 authenticated? 方法。)

我们还将在 User 模型中添加一个布尔值属性 activated,使用自动生成的布尔值方法检查用户是否已经激活(类似 10.4.1 节使用的方法):

if user.activated? ...

最后,还要记录激活的日期和时间,虽然本书用不到,但说不定你以后需要使用。完整的数据模型如图 11.1 所示。

user model account activation
图 11.1:添加账户激活相关属性后的 User 模型

下面的命令生成一个迁移,添加这些属性。我们在命令行中指定了要添加的三个属性:

$ rails generate migration add_activation_to_users \
> activation_digest:string activated:boolean activated_at:datetime

8.2.4 节说过,第二行开头的 > 是“行接续”符号,是 shell 自动插入的,无需输入。)与 admin 属性一样(代码清单 10.54),我们要把 activated 属性的默认值设为 false,如代码清单 11.2 所示。

代码清单 11.2:添加账户激活所需属性的迁移
db/migrate/[timestamp]_add_activation_to_users.rb
class AddActivationToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :activation_digest, :string
    add_column :users, :activated, :boolean, default: false
    add_column :users, :activated_at, :datetime
  end
end

然后像之前一样,执行迁移:

$ rails db:migrate

创建激活令牌的回调

因为每个新注册的用户都得激活,所以我们应该在创建用户对象之前为用户分配激活令牌和摘要。类似的操作在 6.2.5 节见过,那时我们要在用户存入数据库之前把电子邮件地址转换成小写形式,使用的是 before_save 回调和 downcase 方法(代码清单 6.32)。before_save 回调在保存对象之前,包括创建对象和更新对象时自动调用。不过现在我们只想在创建用户之前调用这个回调,创建激活摘要。为此,我们要使用 before_create 回调,按照下面的方式定义:

before_create :create_activation_digest

这种写法叫方法引用(method reference),Rails 会寻找一个名为 create_activation_digest 的方法,在创建用户之前调用。(在代码清单 6.32 中,我们直接把一个块传给 before_save。不过方法引用是推荐的做法。)create_activation_digest 方法只会在 User 模型内使用,所以没必要公开。如 7.3.2 节所示,在 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)

这里用到了实现“记住我”功能时用来生成令牌和摘要的方法。我们可以把这两行代码和代码清单 9.3 中的 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.19),会自动为 activation_tokenactivation_digest 属性赋值;而且,因为 activation_digest 对应数据库中的一个列(图 11.1),所以保存用户时会自动把属性的值存入数据库。

综上所述,User 模型如代码清单 11.3 所示。因为激活令牌是虚拟属性,所以我们又添加了一个 attr_accessor。注意,我们还把电子邮件地址转换成小写的回调改成了方法引用形式。

代码清单 11.3:在 User 模型中添加账户激活相关的代码 GREEN
app/models/user.rb
class User < ApplicationRecord
  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

为用户创建种子数据和固件

在继续之前,我们还要修改种子数据,把示例用户和测试用户设为已激活,如代码清单 11.4代码清单 11.5 所示。(Time.zone.now 是 Rails 提供的辅助方法,基于服务器使用的时区,返回当前时间戳。)

代码清单 11.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
代码清单 11.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 %>

为了让代码清单 11.4 中的改动生效,我们要还原数据库,然后像之前一样写入种子数据:

$ rails db:migrate:reset
$ rails db:seed
练习
  1. 确认做了本节的改动之后测试组件仍能通过。

  2. 在控制台中实例化一个 User 对象,然后调用 create_activation_digest 方法,确认会抛出 NoMethodError 异常(因为它是私有方法)。那个用户的激活摘要是什么?

  3. 我们在代码清单 6.34 中见过,把电子邮件地址转换成小写的代码可以简写成 email.downcase!(不用赋值)。像这样修改代码清单 11.3 中的 downcase_email 方法,然后运行测试组件,确认改得没错。

11.2 账户激活邮件

创建数据模型之后,我们要编写代码,发送账户激活邮件。我们要使用 Action Mailer 库创建一个邮件程序,在 Users 控制器的 create 动作中发送一封包含激活链接的邮件。邮件程序的结构和控制器动作差不多,邮件模板使用视图定义。我们要在邮件模板中写入一个链接,在链接中指定激活令牌和账户对应的电子邮件地址。

11.2.1 邮件程序模板

与模型和控制器类似,我们可以使用 rails generate 命令生成邮件程序:

代码清单 11.6:生成 User 邮件程序
$ rails generate mailer UserMailer account_activation password_reset

除了这里所需的 account_activation 方法之外,代码清单 11.6 还会生成第 12 章使用的 password_reset 方法。

生成邮件程序时,Rails 还会为每个邮件程序生成两个视图模板,一个用于纯文本邮件,一个用于 HTML 邮件。账户激活邮件程序的两个视图如代码清单 11.7代码清单 11.8 所示。(密码重设相关的模板在第 12 章分析。)

代码清单 11.7:生成的账户激活邮件视图,纯文本格式
app/views/user_mailer/account_activation.text.erb
UserMailer#account_activation

<%= @greeting %>, find me in app/views/user_mailer/account_activation.text.erb
代码清单 11.8:生成的账户激活邮件视图,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>

我们看一下生成的邮件程序,了解它是如何工作的,如代码清单 11.9代码清单 11.10所示。代码清单 11.9 设置了一个默认的发件人地址(from),整个应用中的所有邮件程序都会使用这个地址。(代码清单 11.9 还设置了各种邮件格式使用的布局。本书不会讨论邮件的布局,生成的 HTML 和纯文本格式邮件布局在 app/views/layouts 文件夹中。)在生成的代码中有一个实例变量 @greeting,这个变量可在邮件程序的视图中使用,就像控制器中的实例变量可以在普通的视图中使用一样。

代码清单 11.9:生成的 Application 邮件程序
app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "from@example.com"
  layout 'mailer'
end
代码清单 11.10:生成的 User 邮件程序
app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer

  # 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

为了发送激活邮件,我们首先要修改生成的模板,如代码清单 11.11 所示。然后要创建一个实例变量,其值是用户对象,以便在视图中使用,然后把邮件发给 user.email。如代码清单 11.12 所示,mail 方法还可以接受 subject 参数,指定邮件的主题。

代码清单 11.11:在 Application 邮件程序中设定默认的发件人地址
app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "noreply@example.com"
  layout 'mailer'
end
代码清单 11.12:发送账户激活链接
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。在邮件中我们要添加一个针对用户的欢迎消息,以及一个激活链接。我们计划使用电子邮件地址查找用户,然后使用激活令牌认证用户,所以链接中要包含电子邮件地址和令牌。因为我们把“账户激活”视作一个资源(AccountActivations),所以可以把令牌作为参数传给代码清单 11.1 中定义的具名路由:

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 方法(代码清单 9.2)生成的 base64 字符串,可放心地在 URL 中使用。这个值的作用和 /users/1/edit 中的用户 ID 一样,在 AccountActivations 控制器的 edit 动作中可以通过 params[:id] 获取。

为了加上电子邮件地址,我们要使用查询参数(query parameter)。查询参数放在 URL 中的问号后面,使用键值对形式指定:[5]

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 之后(代码清单 11.12),我们可以使用 edit 动作的具名路由和嵌入式 Ruby 创建所需的链接了,如代码清单 11.13代码清单 11.14 所示。注意,在代码清单 11.14 中,我们使用 link_to 方法创建有效的链接。

代码清单 11.13:账户激活邮件的纯文本视图
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) %>
代码清单 11.14:账户激活邮件的 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) %>
练习
  1. 打开 Rails 控制台,确认 CGI 模块中的 escape 方法能像代码清单 11.15 那样转义电子邮件地址。"Don’t panic!" 这个字符串转义后的值是什么?

代码清单 11.15:使用 CGI.escape 方法转义电子邮件地址
>> CGI.escape('foo@example.com')
=> "foo%40example.com"

11.2.2 预览邮件

若想查看这两个邮件视图的效果,可以使用邮件预览功能。Rails 提供了一些特殊的 URL,用来预览邮件。首先,我们要在应用的开发环境中添加一些设置,如代码清单 11.16 所示。

代码清单 11.16:开发环境中的邮件设置
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, protocol: 'https' }
  .
  .
  .
end

代码清单 11.16 中设置的主机地址是 'example.com',如注释所述,你应该使用你开发环境的主机地址。例如,在我的系统中,可以使用下面的地址(包括云端 IDE 和本地服务器):

# 云端 IDE
host = 'rails-tutorial-mhartl.c9users.io'
config.action_mailer.default_url_options = { host: host, protocol: 'https' }

# 本地服务器
host = 'localhost:3000'
config.action_mailer.default_url_options = { host: host, protocol: 'http' }

然后重启开发服务器,让代码清单 11.16 中的配置生效。接下来,我们要修改 User 邮件程序的预览文件。11.2 节 生成邮件程序时已经自动生成了这个文件,如代码清单 11.17 所示。

代码清单 11.17:生成的 User 邮件预览程序
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

因为代码清单 11.12 中定义的 account_activation 方法需要一个有效的用户作为参数,所以代码清单 11.17 中的代码现在还不能使用。为了解决这个问题,我们要定义 user 变量,把开发数据库中的第一个用户赋值给它,然后作为参数传给 UserMailer.account_activation 方法,如代码清单 11.18 所示。注意,在这段代码中,我们还给 user.activation_token 赋了值,因为代码清单 11.13代码清单 11.14 中的模板要使用账户激活令牌。(activation_token 是虚拟属性,所以数据库中的用户并没有激活令牌。)

代码清单 11.18:预览账户激活邮件所需的方法
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 和纯文本邮件分别如图 11.2图 11.3 所示。

account activation html preview 4th ed
图 11.2:预览 HTML 格式的账户激活邮件
account activation text preview 4th ed
图 11.3:预览纯文本格式的账户激活邮件
练习
  1. 在浏览器中预览邮件模板。你看到的发送日期是什么?

11.2.3 测试电子邮件

最后,我们要编写一些测试,再次确认邮件的内容。这并不难,因为 Rails 生成了一些有用的测试示例,如代码清单 11.19 所示。

代码清单 11.19:Rails 为 User 邮件程序生成的测试
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

代码清单 11.19 中使用了强大的 assert_match 方法。这个方法既可以匹配字符串,也可以匹配正则表达式:

assert_match 'foo', 'foobar'      # true
assert_match 'baz', 'foobar'      # false
assert_match /\w+/, 'foobar'      # true
assert_match /\w+/, '$#!*+@'      # false

代码清单 11.20 使用 assert_match 方法检查邮件正文中是否有用户的名字、激活令牌和转义后的电子邮件地址。注意,转义用户电子邮件地址使用的方法是 CGI::escape(user.email)[6]

代码清单 11.20:测试当前的电子邮件实现 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

注意,我们在代码清单 11.20 中为固件中的一个用户指定了激活令牌,否则这个值为空。(代码清单 11.20 还删除了生成的密码重设测试,我们会在 12.2.2 节添加相关的测试。)

为了让这个测试通过,我们要修改测试环境的配置,设定正确的主机地址,如代码清单 11.21 所示。

代码清单 11.21:设定测试环境的主机地址
config/environments/test.rb
Rails.application.configure do
  .
  .
  .
  config.action_mailer.delivery_method = :test
  config.action_mailer.default_url_options = { host: 'example.com' }
  .
  .
  .
end

现在,邮件程序的测试应该可以通过了:

代码清单 11.22GREEN
$ rails test:mailers
练习
  1. 确认整个测试组件仍然能通过。

  2. 代码清单 11.20 中不要调用 CGI.escape 方法,确认测试会失败。

11.2.4 更新 Users 控制器的 create 动作

若要在我们的应用中使用这个邮件程序,只需在处理用户注册的 create 动作中添加几行代码,如代码清单 11.23 所示。注意,代码清单 11.23 修改了注册后的重定向地址。之前,我们把用户重定向到资料页面(7.4 节),可是现在需要先激活,再转向这个页面就不合理了,所以把重定向地址改成了根地址。

代码清单 11.23:在注册过程中添加账户激活 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

因为现在重定向到根地址而不是资料页面,而且不会像之前那样自动登入用户,所以测试组件无法通过,不过应用能按照我们设计的方式运行。我们暂时把导致失败的测试注释掉,如代码清单 11.24 所示。我们会在 11.3.3 节去掉注释,并且为账户激活编写能通过的测试。

代码清单 11.24:临时注释掉失败的测试 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, params: { 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 users_path, params: { user: { name:  "Example User",
                                         email: "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!
    # assert_template 'users/show'
    # assert is_logged_in?
  end
end

如果现在注册,重定向后显示的页面如图 11.4 所示,而且会生成一封邮件,如代码清单 11.25 所示。注意,在开发环境中并不会真发送邮件,不过能在服务器的日志中看到(可能要往上滚动才能看到)。11.4 节讨论如何在生产环境中发送邮件。

代码清单 11.25:在服务器日志中看到的账户激活邮件
UserMailer#account_activation: processed outbound mail in 292.4ms
Sent mail to michael@michaelhartl.com (47.3ms)
Date: Mon, 06 Jun 2016 20:17:41 +0000
From: noreply@example.com
To: michael@michaelhartl.com
Message-ID: <5755da6518cb4_f2c9222494c7178e@mhartl-rails-tutorial-3045526.mail>
Subject: Account activation
Mime-Version: 1.0
Content-Type: multipart/alternative;
 boundary="--==_mimepart_5755da6513e89_f2c9222494c71639";
 charset=UTF-8
Content-Transfer-Encoding: 7bit


----==_mimepart_5755da6513e89_f2c9222494c71639
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:

https://rails-tutorial-mhartl.c9users.io/account_activations/
-L9kBsbIjmrqpJGB0TUKcA/edit?email=michael%40michaelhartl.com

----==_mimepart_5755da6513e89_f2c9222494c71639
Content-Type: text/html;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <style>
      /* Email styles need to be inline */
    </style>
  </head>

  <body>
    <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="https://rails-tutorial-mhartl.c9users.io/account_activations/
-L9kBsbIjmrqpJGB0TUKcA/edit?email=michael%40michaelhartl.com">Activate</a>
  </body>
</html>

----==_mimepart_5755da6513e89_f2c9222494c71639--
redirected not activated
图 11.4:注册后显示的首页,有一个提醒激活的消息
练习
  1. 新注册一个用户,确认能正确重定向。你在服务器的日志中会看到什么内容?激活令牌的值是什么?

  2. 在 Rails 控制台中确认创建了新用户,但是尚未激活。

11.3 激活账户

现在可以正确生成电子邮件了(代码清单 11.25),接下来我们要编写 AccountActivations 控制器的 edit 动作,激活用户。与之前一样,我们将为这个动作编写一个测试,等测试通过后再重构,把一些代码移出 AccountActivations 控制器,移到 User 模型中。

11.3.1 通用的 authenticated? 方法

11.2.1 节说过,激活令牌和电子邮件地址可以分别通过 params[:id]params[:email] 获取。参照密码(代码清单 8.7)和记忆令牌(代码清单 9.9)的实现方式,我们计划使用下面的代码查找和验证用户:

user = User.find_by(email: params[:email])
if user && user.authenticated?(:activation, params[:id])

(稍后会看到,上述代码还缺一个判断条件。看看你能否猜到缺了什么。)

上述代码使用 authenticated? 方法检查账户激活的摘要和指定的令牌是否匹配,但是现在不起作用,因为 authenticated? 方法是专门用来验证记忆令牌的(代码清单 9.6):

# 如果指定的令牌和摘要匹配,返回 true
def authenticated?(remember_token)
  return false if remember_digest.nil?
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

其中,remember_digestUser 模型的属性。在 User 模型中,我们可以将其改写成:

self.remember_digest

我们希望以某种方式把这个值变成“变量”,这样才能调用 self.activation_digest,而不用把适当的参数传给 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 节说过,插值时会把符号转换成字符串。)

基于上述对 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,以此强调这个方法的用途更广。)因为这个方法在 User 模型内,所以可以省略 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? 方法写入 User 模型,如代码清单 11.26 所示。

代码清单 11.26:通用的 authenticated? 方法 RED
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 如果指定的令牌和摘要匹配,返回 true
  def authenticated?(attribute, token)
    digest = send("#{attribute}_digest")
    return false if digest.nil?
    BCrypt::Password.new(digest).is_password?(token)
  end
  .
  .
  .
end

代码清单 11.26 的标题所示,测试组件无法通过:

代码清单 11.27RED
$ rails test

失败的原因是,current_user 方法(代码清单 9.9)和摘要为 nil 的测试(代码清单 9.17)使用的都是旧版 authenticated?,期望传入的是一个参数而不是两个。因此,我们只需修改这两个地方,换用修改后的 authenticated? 方法就能解决这个问题,如代码清单 11.28代码清单 11.29 所示。

代码清单 11.28:在 current_user 方法中使用通用版 authenticated? 方法 RED
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
代码清单 11.29:在 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

修改后,测试应该可以通过了:

代码清单 11.30GREEN
$ rails test

没有坚实的测试组件做后盾,像这样的重构很容易出错,所以我们才要在 9.1.2 节9.3 节排除万难编写测试。

练习
  1. 在 Rails 控制台中创建并记住一个用户,他的记忆令牌和激活令牌分别是什么?对应的摘要呢?

  2. 使用代码清单 11.26 中定义的通用版 authenticated? 方法,确认使用记忆令牌和激活令牌都能验证用户的身份。

11.3.2 编写激活账户的 edit 动作

有了代码清单 11.26 中定义的 authenticated? 方法,现在我们可以编写 edit 动作,验证 params 散列中电子邮件地址对应的用户了。我们要使用的判断条件如下所示:

if user && !user.activated? && user.authenticated?(:activation, params[:id])

注意,这里加入了 !user.activated?,就是前面提到的那个缺失的条件,其作用是避免激活已经激活的用户。这个条件很重要,因为激活后我们要登入用户,但是不能让获得激活链接的攻击者以这个用户的身份登录。

如果通过上述判断条件,我们要激活这个用户,并且更新 activated_at 时间戳:[7]

user.update_attribute(:activated,    true)
user.update_attribute(:activated_at, Time.zone.now)

据此,写出的 edit 动作如代码清单 11.31 所示。注意,在代码清单 11.31 中,我们还处理了激活令牌无效的情况。这种情况极少发生,但处理起来也很容易,直接重定向到根地址即可。

代码清单 11.31:在 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

然后,复制粘贴代码清单 11.25 中的地址,应该就可以激活对应的用户了。例如,在我的系统中,我访问的地址是:

https://rails-tutorial-mhartl.c9users.io/account_activations/
fFb_F94mgQtmlSvRFGsITw/edit?email=michael%40michaelhartl.com

此时会看到如图 11.5 所示的页面。

activated user 4th ed
图 11.5:成功激活后显示的资料页面

当然,现在激活用户后没有什么实际效果,因为我们还没修改用户的登录方式。为了让账户激活有实际意义,只能允许已经激活的用户登录,即 user.activated? 返回 true 时才能像之前那样登录,否则重定向到根地址,并且显示一个提醒消息(图 11.6),如代码清单 11.32 所示。

代码清单 11.32:禁止未激活的用户登录
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
图 11.6:未激活的用户试图登录后看到的提醒消息

至此,激活用户的功能基本完成了,不过还有个地方可以改进。(可以改进的是,不显示未激活的用户。这个改进留作练习。)11.3.3 节会编写一些测试,再做一些重构,完成整个功能。

练习
  1. 复制粘贴 11.2.4 节生成的电子邮件中的激活 URL,看激活令牌是什么?

  2. 在 Rails 控制台中确认,使用前一题获得的激活令牌能验证用户的身份。现在,这个用户激活了没有?

11.3.3 测试和重构

本节,我们要为账户激活功能添加一些集成测试。我们已经为提交有效信息的注册过程编写了测试,所以我们要把这个测试添加到 7.4.4 节编写的测试中(代码清单 7.33)。在测试中,我们要添加好多步,不过意图都很明确,看看你是否能理解代码清单 11.33 中的测试。(代码清单 11.33 中高亮的那几行特别重要,而且容易忘记;除此之外还新增了几行,一定要全部添加。)

代码清单 11.33:在用户注册的测试文件中添加账户激活的测试 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, params: { 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, params: { 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", email: user.email)
    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 方法中把它清空,以防其他测试发送了邮件(12.3.3 节就会这么做)。代码清单 11.33 还第一次在本书正文中使用了 assigns 方法。9.3.1 节练习说过,assigns 的作用是获取相应动作中的实例变量。例如,Users 控制器的 create 动作中定义了一个 @user 变量,那么我们可以在测试中使用 assigns(:user) 获取这个变量的值。最后,注意,代码清单 11.33代码清单 11.24 中的注释去掉了。

现在,测试组件应该可以通过:

代码清单 11.34GREEN
$ rails test

有了代码清单 11.33 中的测试做后盾,接下来我们可以稍微重构一下:把处理用户的代码从控制器中移出,放入模型。我们会定义一个 activate 方法,用来更新用户激活相关的属性;还要定义一个 send_activation_email 方法,发送激活邮件。这两个方法的定义如代码清单 11.35 所示,重构后的应用代码如代码清单 11.36代码清单 11.37 所示。

代码清单 11.35:在 User 模型中添加账户激活相关的方法
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 激活账户
  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
代码清单 11.36:通过 User 模型对象发送邮件
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
代码清单 11.37:通过 User 模型对象激活账户
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

注意,在代码清单 11.35 中没有使用 user.。如果还像之前那样写就会出错,因为 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 节说过,在模型内可以不加 self。)调用 UserMailer 时,我们还把 @user 改成了 self

-UserMailer.account_activation(@user).deliver_now
+UserMailer.account_activation(self).deliver_now

就算是简单的重构,也可能忽略这些细节,不过好的测试组件能捕获这些问题。现在,测试组件应该仍能通过:

代码清单 11.38GREEN
$ rails test
练习
  1. 代码清单 11.35 中,activate 方法调用了两次 update_attribute 方法,每一次调用都要单独执行一个数据库事务(transaction)。填写代码清单 11.39 中缺少的代码,把两个 update_attribute 调用换成一个 update_columns 调用,这样修改后只会与数据库交互一次。改完后运行测试组件,确保仍能通过。

  2. 现在,用户列表页面会显示所有用户,而且各个用户可以通过 /users/:id 查看。不过,更合理的做法是只显示已激活的用户。填写代码清单 11.40 中缺少的代码,实现这一需求。[8](这段代码中使用了 Active Record 提供的 where 方法,13.3.3 节会详细介绍。)

  3. 为 /users 和 /users/:id 编写集成测试,测试前一题编写的代码。

代码清单 11.39:使用 update_columns 的代码模板
app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token
  before_save   :downcase_email
  before_create :create_activation_digest
  .
  .
  .
  # 激活账户
  def activate
    update_columns(activated: FILL_IN, activated_at: FILL_IN)
  end

  # 发送激活邮件
  def send_activation_email
    UserMailer.account_activation(self).deliver_now
  end

  private
  .
  .
  .
end
代码清单 11.40:只显示已激活用户的代码模板
app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def index
    @users = User.where(activated: FILL_IN).paginate(page: params[:page])
  end

  def show
    @user = User.find(params[:id])
    redirect_to root_url and return unless FILL_IN
  end
  .
  .
  .
end

11.4 在生产环境中发送邮件

我们已经在开发环境实现了账户激活功能,本节要配置应用,让它在生产环境中能真正地发送邮件。我们首先将设置一个免费的邮件服务,然后配置应用,最后再部署。

我们要在生产环境中使用 SendGrid 服务发送邮件。这个服务是 Heroku 的扩展,只有通过认证的账户才能使用。(要在 Heroku 的账户中填写信用卡信息,不过认证不收费。)对我们的应用来说,入门套餐就够了(免费,写作本书时限制每天最多发送 400 封邮件)。我们可以使用下面的命令添加这个扩展:

$ heroku addons:create sendgrid:starter

(如果你使用的是 Heroku 的旧版命令行接口,这个命令可能无法执行。这个问题有两种解决方法:其一,升级到最新的 Heroku 工具包;其二,使用旧句法 heroku addons:add sendgrid:starter。)

为了让应用使用 SendGrid 发送邮件,我们要在生产环境中配置 SMTP,还要定义一个 host 变量,设置生产环境中网站的地址,如代码清单 11.41 所示。

代码清单 11.41:配置应用,在生产环境中使用 SendGrid
config/environments/production.rb
Rails.application.configure do
  .
  .
  .
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :smtp
  host = '<your heroku app>.herokuapp.com'
  config.action_mailer.default_url_options = { host: host }
  ActionMailer::Base.smtp_settings = {
    :address        => 'smtp.sendgrid.net',
    :port           => '587',
    :authentication => :plain,
    :user_name      => ENV['SENDGRID_USERNAME'],
    :password       => ENV['SENDGRID_PASSWORD'],
    :domain         => 'heroku.com',
    :enable_starttls_auto => true
  }
  .
  .
  .
end

代码清单 11.41 中设置了 SendGrid 账户的用户名(user_name)和密码(password),但是注意,这两个值是从 ENV 环境变量中获取的,而没有直接写入代码。这是生产环境应用的最佳实践,为了安全,绝不能在源码中写入敏感信息,例如原始密码。这两个值由 SendGrid 扩展自动设置,13.4.4 节会介绍如何自己定义。如果好奇,可以使用下面的命令查看这两个环境变量的值:

$ heroku config:get SENDGRID_USERNAME
$ heroku config:get SENDGRID_PASSWORD

现在,把主题分支合并到主分支中:

$ rails test
$ git add -A
$ git commit -m "Add account activation"
$ git checkout master
$ git merge account-activation

然后,推送到远程仓库,再部署到 Heroku:

$ rails test
$ git push
$ git push heroku
$ heroku run rails db:migrate

部署到 Heroku 中之后,在生产环境的演示应用中使用你的电子邮件注册试试。你应该会收到一封激活邮件,如图 11.7 所示。点击邮件中的链接后应该能激活账户,如图 11.8 所示。

activation email production 4th ed
图 11.7:生产环境中的应用发送的账户激活邮件
activated in production
图 11.8:在生产环境中成功激活账户

练习

  1. 在生产环境中注册一个账户。能收到电子邮件吗?

  2. 点击激活邮件中的链接,确认能激活账户。在服务器的日志中能看到什么?提示:在命令行中执行 heroku logs 命令。

11.5 小结

实现账户激活功能后,我们的演示应用已经基本实现了“注册-登录-退出”机制。现在还剩下密码重设功能没实现。读到第 12 章你会发现,密码重设功能的实现方式与账户激活有很多相似之处,因此能用到本章学到的很多知识。

11.5.1 本章所学

  • 与会话一样,账户激活虽然没有对应的 Active Record 对象,但也可以看做一个资源;

  • Rails 可以生成 Action Mailer 动作和视图,用于发送邮件;

  • Action Mailer 支持纯文本邮件和 HTML 邮件;

  • 与普通的动作和视图一样,在邮件程序的视图中也可以使用邮件程序动作中的实例变量;

  • 使用生成的令牌创建唯一的 URL,用于激活账户;

  • 使用哈希摘要安全识别有效的激活请求;

  • 邮件程序的测试和集成测试对确认邮件程序的行为都有用;

  • 在生产环境中可以使用 SendGrid 发送电子邮件。

  1. 除了这里所述的基本步骤之外,还可以加上重新发送账户激活邮件的功能,以防最初的确认邮件丢失了,或者被当做垃圾邮件了。你可能要等到读完本书之后才有能力添加这样一个功能。此外,也可以考虑使用自带重新发送确认邮件功能的解决方案,如 Devise
  2. 也可以使用用户的 ID,因为应用的 URL 已经暴露了 ID,但是电子邮件地址更灵活,说不定以后你想隐藏 ID 呢(例如,防止竞争对手知道你的应用中有多少用户)。
  3. 我们将使用 edit 动作,因此可以在命令行中加上 edit;但是,这样做还会生成 edit 视图和测试文件,而我们并不需要。
  4. 鉴于这个原因,我们不会使用 Rails 5 新增的 has_secure_token 方法(名字容易让人误会),因为它在数据库中存储令牌的明文
  5. 一个 URL 中可以有多个查询参数,多个键值对之间使用 & 符号连接,例如 /edit?name=Foo%20Bar&email=foo%40example.com
  6. 一开始撰写本章时,我一时想不起在 Rails 中该如何转义 URL,让我体会到了技术是复杂的。为了查明该怎么做,我在 Google 中搜索“ruby rails escape url”,找到了两种主要方法URI::encode(str)CGI.escape(str)。一一试过之后,我发现需要的是第二个方法。(其实还有第三种方法,ERB::Util 库提供的 url_encode 方法,具有同样的效果。)
  7. 这里,我们分两次调用 update_attribute 方法,而不是调用一次 update_attributes 方法,因为后者会执行数据验证。这里没有提供密码,验证会失败。
  8. 注意,代码清单 11.40 中使用的是 and 而不是 &&。二者作用基本一样,但 &&优先级较高,与 root_url 绑定得太紧。我们也可以把 redirect_to root_url 放在括号里,不过习惯写法是使用 and