ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
# 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" ```