10.1 账户激活

目前,用户注册后立即就能完全控制自己的账户(第 7 章)。本节,我们要添加一步,激活用户的账户,从而确认用户拥有注册时使用的电子邮件地址。为此,我们要为用户创建激活令牌和摘要,然后给用户发送一封电子邮件,提供包含令牌的链接。用户点击这个链接后,激活这个账户。

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

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

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

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

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

  5. 如果令牌和摘要匹配,就把状态由“未激活”改为“已激活”。

因为与密码和记忆令牌类似,实现账户激活(以及密码重设)功能时可以继续使用前面的很多方法,包括 User.digestUser.new_token 和修改过的 user.authenticated?。这几个功能(包括 10.2 节要实现的密码重设)之间的对比,如表 10.1 所示。我们会在 10.1.3 节定义可用于表中所有情况的通用版 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 节会发现,账户激活和密码重设需要共用一些电子邮件设置,合并到 master 分支之前,要把这些设置应用到这两个功能上,所以在一个分支中开发这两个功能比较方便:

$ git checkout master
$ git checkout -b account-activation-password-resets

10.1.1 资源

和会话一样(8.1 节),我们要把“账户激活”看做一个资源,不过这个资源不对应模型,相关的数据(激活令牌和激活状态)存储在用户模型中。然而,我们要通过标准的 REST URL 处理账户激活操作。激活链接会改变用户的激活状态,所以我们计划在 edit 动作中处理。[3]所需的控制器使用下面的命令生成:[4]

$ rails generate controller AccountActivations --no-test-framework

我们需要使用下面的方法生成一个 URL,放在激活邮件中:

edit_account_activation_url(activation_token, ...)

因此,我们需要为 edit 动作设定一个具名路由——通过代码清单 10.1 中高亮显示的那行 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 节)需要考虑很多安全隐患,因为如果攻击者获取了这些信息就能完全控制账户。账户激活则不需要这么麻烦,但如果不哈希激活令牌,账户也有一定危险。[5]所以,参照记住登录状态的做法(8.4 节),我们会公开令牌,而在数据库中存储哈希摘要。这么做,我们可以使用下面的方式获取激活令牌:

user.activation_token

使用下面的代码认证用户:

user.authenticated?(:activation, token)

(不过得先修改代码清单 8.33 中定义的 authenticated? 方法。)我们还要定义一个布尔值属性 activated,使用自动生成的布尔值方法检查用户的激活状态(类似 9.4.1 节使用的方法):

if user.activated? ...

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

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

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

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

admin 属性一样(代码清单 9.50),我们要把 activated 属性的默认值设为 false,如代码清单 10.2 所示。

代码清单 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 节见过,那时我们要在用户存入数据库之前把电子邮件地址转换成小写形式。我们使用的是 before_save 回调和 downcase 方法(代码清单 6.31)。before_save 回调在保存对象之前,包括创建对象和更新对象,自动调用。不过现在我们只想在创建用户之前调用回调,创建激活摘要。为此,我们要使用 before_create 回调,按照下面的方式定义:

before_create :create_activation_digest

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

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

综上所述,用户模型如代码清单 10.3 所示。因为激活令牌是虚拟属性,所以我们又添加了一个 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代码清单 10.5 所示。(Time.zone.now 是 Rails 提供的辅助方法,基于服务器使用的时区,返回当前时间戳。)

代码清单 10.4:激活种子数据中的用户

db/seeds.rb

User.create!(name:  "Example  User",
             email: "[email protected]",
             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: [email protected]
  password_digest: <%= User.digest('password') %>
  admin: true
 activated: true activated_at: <%= Time.zone.now %>
archer:
  name: Sterling Archer
  email: [email protected]
  password_digest: <%= User.digest('password') %>
 activated: true activated_at: <%= Time.zone.now %>
lana:
  name: Lana Kane
  email: [email protected]
  password_digest: <%= User.digest('password') %>
 activated: true activated_at: <%= Time.zone.now %>
malory:
  name: Malory Archer
  email: [email protected]
  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 中的改动,我们要还原数据库,然后像之前一样写入数据:

$ 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 方法。

生成邮件程序时,Rails 还为每个邮件程序生成了两个视图模板,一个用于纯文本邮件,一个用于 HTML 邮件。账户激活邮件程序的两个视图如代码清单 10.6代码清单 10.7 所示。

代码清单 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代码清单 10.9所示。代码代码清单 10.8 设置了一个默认的发件人地址(from),整个应用中的全部邮件程序都会使用这个地址。(这个代码清单还设置了各种邮件格式使用的布局。本书不会讨论邮件的布局,生成的 HTML 和纯文本格式邮件布局在 app/views/layouts 文件夹中。)代码清单 10.9 中的每个方法中都设置了收件人地址。在生成的代码中还有一个实例变量 @greeting,这个变量可在邮件程序的视图中使用,就像控制器中的实例变量可以在普通的视图中使用一样。

代码清单 10.8:生成的 ApplicationMailer

app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base
  default from: "[email protected]"
  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: "[email protected]"  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: "[email protected]"  end
end

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

代码清单 10.10:在 ApplicationMailer 中设定默认的发件人地址
class ApplicationMailer < ActionMailer::Base
  default from: "[email protected]"
  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: "[email protected]"
  end
end

和普通的视图一样,在邮件程序的视图中也可以使用嵌入式 Ruby。在邮件中我们要添加一个针对用户的欢迎消息,以及一个激活链接。我们计划使用电子邮件地址查找用户,然后使用激活令牌认证用户,所以链接中要包含电子邮件地址和令牌。因为我们把“账户激活”视作一个资源,所以可以把令牌作为参数传给代码清单 10.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 方法(代码清单 8.31)生成的 base64 字符串,可安全地在 URL 中使用。这个值的作用和 /users/1/edit 中的用户 ID 一样,在 AccountActivationsControlleredit 动作中可以通过 params[:id] 获取。

为了包含电子邮件地址,我们要使用“查询参数”(query parameter)。查询参数放在 URL 中的问号后面,使用键值对形式指定:[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),我们可以使用 edit 动作的具名路由和嵌入式 Ruby 创建所需的链接了,如代码清单 10.12代码清单 10.13 所示。注意,在代码清单 10.13 中,我们使用 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 所示。

代码清单 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 中设置的主机地址是 'example.com',你应该使用你的开发环境的主机地址。例如,在我的系统中,可以使用下面的地址(包括云端 IDE 和本地服务器):

host = 'rails-tutorial-c9-mhartl.c9.io'     # 云端 IDE
host = 'localhost:3000'                     # 本地主机

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

代码清单 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 中定义的 account_activation 方法需要一个有效的用户作为参数,所以代码清单 10.15 中的代码现在还不能使用。为了解决这个问题,我们要定义 user 变量,把开发数据库中的第一个用户赋值给它,然后作为参数传给 UserMailer.account_activation,如代码清单 10.16 所示。注意,在这段代码中,我们还给 user.activation_token 赋了值,因为代码清单 10.12代码清单 10.13 中的模板要使用账户激活令牌。(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图 10.3 所示。

account activation html preview图 10.2:预览 HTML 格式的账户激活邮件account activation text preview图 10.3:预览纯文本格式的账户激活邮件

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

代码清单 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 ["[email protected]"], mail.to
    assert_equal ["[email protected]"], 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 ["[email protected]"], mail.to
    assert_equal ["[email protected]"], mail.from
    assert_match "Hi", mail.body.encoded
  end
end

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

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

代码清单 10.18 使用 assert_match 检查邮件正文中是否有用户的名字、激活令牌和转义后的电子邮件地址。注意,转义用户电子邮件地址使用的方法是 CGI::escape(user.email)。[7](其实还有第三种方法,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 ["[email protected]"], mail.from
    assert_match user.name,               mail.body.encoded
    assert_match user.activation_token,   mail.body.encoded
    assert_match CGI::escape(user.email), mail.body.encoded
  end
end

注意,我们在代码清单 10.18 中为用户固件指定了激活令牌,因为固件中没有虚拟属性。

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

代码清单 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 所示。注意,代码清单 10.21 修改了注册后的重定向地址。之前,我们把用户重定向到资料页面(7.4 节),可是现在需要先激活,再转向这个页面就不合理了,所以把重定向地址改成了根地址。

代码清单 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 所示。我们会在 10.1.4 节去掉注释,并且为账户激活编写能通过的测试。

代码清单 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: "[email protected]",
                                            password:              "password",
                                            password_confirmation: "password" }
    end
 # assert_template 'users/show' # assert is_logged_in?  end
end

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

代码清单 10.23:在服务器日志中看到的账户激活邮件
Sent mail to [email protected] (931.6ms)
Date: Wed, 03 Sep 2014 19:47:18 +0000
From: [email protected]
To: [email protected]
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图 10.4:注册后显示的首页,有一个提醒激活的消息

10.1.3 激活账户

现在可以正确生成电子邮件了(代码清单 10.23),接下来我们要编写 AccountActivationsController 中的 edit 动作,激活用户。10.1.2 节说过,激活令牌和电子邮件地址可以分别通过 params[:id]params[:email] 获取。参照密码(代码清单 8.5)和记忆令牌(代码清单 8.36)的实现方式,我们计划使用下面的代码查找和认证用户:

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

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

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

# 如果指定的令牌和摘要匹配,返回 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 节介绍过,插值时会把符号转换成字符串。)

基于上述对 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 所示。

代码清单 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 的标题所示,测试组件无法通过:

代码清单 10.25:RED
$ bundle exec rake test

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

代码清单 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: "[email protected]",
                     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 节8.4.6 节排除万难编写测试。

有了代码清单 10.24 中定义的 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 所示。注意,在代码清单 10.29 中我们还处理了激活令牌无效的情况。这种情况很少发生,但处理起来也很容易,直接重定向到根地址即可。

代码清单 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 中的地址,应该就可以激活对应的用户了。例如,在我的系统中,我访问的地址是:

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

然后会看到如图 10.5 所示的页面。

activated user图 10.5:成功激活后显示的资料页面

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

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

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

10.1.4 测试和重构

本节,我们要为账户激活功能添加一些集成测试。我们已经为提交有效信息的注册过程编写了测试,所以我们要把这个测试添加到 7.4.4 节编写的测试中(代码清单 7.26)。在测试中,我们要添加好多步,不过意图都很明确,看看你是否能理解代码清单 10.31 中的测试。

代码清单 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: "[email protected]",
                               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 节就会这么做)。代码清单 10.31 还第一次在本书正文中使用了 assigns 方法。8.6 节说过,assigns 的作用是获取相应动作中的实例变量。例如,用户控制器的 create 动作中定义了一个 @user 变量,那么我们可以在测试中使用 assigns(:user) 获取这个变量的值。最后,注意,代码清单 10.31代码清单 10.22 中的注释去掉了。

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

代码清单 10.32:GREEN
$ bundle exec rake test

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

代码清单 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 中没有使用 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

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

代码清单 10.36:GREEN
$ bundle exec rake test

账户激活功能完成了,我们取得了一定进展,可以提交了:

$ git add -A
$ git commit -m "Add account activations"