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

第 10 章 更新、显示和删除用户

本章我们要完成 Users 资源的 REST 动作(表 7.1),添加 editupdateindexdestroy 四个动作。首先我们要实现更新用户个人资料的功能,并借此实现权限机制(基于第 8 章实现的身份验证系统)。然后创建一个页面,列出所有用户(也需要验证身份),期间会介绍如何使用示例数据和分页。最后,我们要实现删除用户的功能,从数据库中删除用户记录。我们不会为所有用户都提供这种强大的功能,而是创建管理员,授权他们来删除其他用户。

10.1 更新用户

编辑用户信息的方法和创建新用户差不多(参见第 7 章),创建新用户的页面在 new 动作中渲染,而编辑用户的页面在 edit 动作中渲染;创建用户的过程在 create 动作中处理 POST 请求,编辑用户要在 update 动作中处理 PATCH 请求(旁注 3.2)。二者之间最大的区别是,任何人都可以注册,但只有当前用户才能更新自己的信息。我们可以使用第 8 章实现的身份验证机制,通过前置过滤器(before filter)实现访问限制。

开始实现之前,我们先切换到 updating-users 主题分支:

$ git checkout -b updating-users

10.1.1 编辑表单

我们先来创建编辑表单,构思图如图 10.1 所示。[1]为了把这个构思图转换成可以使用的页面,我们既要编写 Users 控制器的 edit 动作,还要创建编辑用户的视图。我们先来编写 edit 动作。在 edit 动作中我们要从数据库中读取相应的用户。由表 7.1 得知,用户的编辑页面地址是 /users/1/edit(假设用户的 ID 是 1)。我们知道用户的 ID 可以通过 params[:id] 获取,因此可以使用代码清单 10.1 中的代码查找用户。

代码清单 10.1Users 控制器的 edit 动作
app/controllers/users_controller.rb
class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  def edit
    @user = User.find(params[:id])
  end

  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end
end
edit user mockup bootstrap
图 10.1:用户编辑页面的构思图

用户编辑页面的视图(要手动创建这个文件)如代码清单 10.2 所示。注意,这个视图和代码清单 7.15 中新建用户的视图相似,有很多重复的代码,所以可以重构,把共用的代码放到局部视图中,这个任务留作练习

代码清单 10.2:用户编辑页面的视图
app/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= render 'shared/error_messages' %>

      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Save changes", class: "btn btn-primary" %>
    <% end %>

    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="http://gravatar.com/emails" target="_blank">change</a>
    </div>
  </div>
</div>

这里再次用到了 7.3.3 节创建的 error_messages 局部视图。顺便说一下,修改 Gravatar 头像的链接用到了 target="_blank",目的是在新窗口或新标签页中打开网页。链接到第三方网站时有时会这么做。(这样打开网页有个安全隐患,对这个问题的处理留作练习。)

代码清单 10.1 中定义了 @user 实例变量,所以编辑页面可以正确渲染,如图 10.2 所示。从“Name”和“Email”字段可以看出,Rails 会自动使用 @user 变量的属性值填写相应的字段。

edit page 3rd edition
图 10.2:编辑页面的初始版本,名字和电子邮件地址自动填入了值

查看用户编辑页面的 HTML 源码,会看到预期的表单标签,如代码清单 10.3 所示(某些细节可能不同)。

代码清单 10.3代码清单 10.2 定义的编辑表单生成的 HTML
<form accept-charset="UTF-8" action="/users/1" class="edit_user"
      id="edit_user_1" method="post">
  <input name="_method" type="hidden" value="patch" />
  .
  .
  .
</form>

留意一下这个隐藏字段:

<input name="_method" type="hidden" value="patch" />

因为 Web 浏览器不支持发送 PATCH 请求(表 7.1 中的 REST 动作要用),所以 Rails 在 POST 请求中使用这个隐藏字段把它伪装成 PATCH 请求。[2]

还有一个细节需要注意一下:代码清单 10.2代码清单 7.15 都使用了相同的 form_for(@user) 来构建表单,那么 Rails 是怎么知道创建新用户要发送 POST 请求,而编辑用户时要发送 PATCH 请求的呢?这个问题的答案是,通过 Active Record 提供的 new_record? 布尔值方法检测用户是新创建的还是已经存在于数据库中:

$ rails console
>> User.new.new_record?
=> true
>> User.first.new_record?
=> false

所以使用 form_for(@user) 构建表单时,如果 @user.new_record? 返回 true,Rails 发送 POST 请求,否则发送 PATCH 请求。

最后,我们要把导航栏中指向编辑用户页面的链接换成真实的地址。很简单,我们直接使用表 7.1 中列出的 edit_user_path 具名路由,并把参数设为代码清单 9.9 中定义的 current_user 辅助方法:

<%= link_to "Settings", edit_user_path(current_user) %>

完整的视图如代码清单 10.4 所示。

练习
  1. 前面说过,使用 target="_blank" 打开 URL 有个小安全隐患:打开的窗口获得了 HTML 文档对应的 window 对象。这样在新窗口中可以插入恶意内容,发起钓鱼攻击。链接著名的网站(如 Gravatar)基本上不存在这种隐患,不过我们可以在链接标签中把 rel(“relationship”)属性设为 "noopener",完全避免这个问题。在代码清单 10.2 中的 Gravatar 头像编辑链接中添加这个属性。

  2. 重构 new.html.erbedit.html.erb 视图,使用代码清单 10.5 中的局部视图去除表单中的重复代码。重构后的视图如代码清单 10.6代码清单 10.7 所示。注意,我们使用 provide 方法(3.4.3 节用过)把布局中的重复去除了。[3](如果你做了代码清单 7.27 对应的练习,无法像题中所说的那样做,请自己设法解决。我建议你使用代码清单 10.5 中传递变量的方式,把代码清单 10.6代码清单 10.7 所需的 URL 传给代码清单 10.5 中的表单。)

代码清单 10.5:供注册用户和编辑用户表单使用的局部视图
app/views/users/_form.html.erb
<%= form_for(@user) do |f| %>
  <%= render 'shared/error_messages', object: @user %>

  <%= f.label :name %>
  <%= f.text_field :name, class: 'form-control' %>

  <%= f.label :email %>
  <%= f.email_field :email, class: 'form-control' %>

  <%= f.label :password %>
  <%= f.password_field :password, class: 'form-control' %>

  <%= f.label :password_confirmation %>
  <%= f.password_field :password_confirmation, class: 'form-control' %>

  <%= f.submit yield(:button_text), class: "btn btn-primary" %>
<% end %>
代码清单 10.6:在注册视图中使用局部视图
app/views/users/new.html.erb
<% provide(:title, 'Sign up') %>
<% provide(:button_text, 'Create my account') %>
<h1>Sign up</h1>
<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= render 'form' %>
  </div>
</div>
代码清单 10.7:在编辑视图中使用局部视图
app/views/users/edit.html.erb
<% provide(:title, 'Edit user') %>
<% provide(:button_text, 'Save changes') %>
<h1>Update your profile</h1>
<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= render 'form' %>
    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="http://gravatar.com/emails" target="_blank">Change</a>
    </div>
  </div>
</div>

10.1.2 编辑失败

本节我们要处理编辑失败的情况,过程与处理注册失败差不多(7.3 节)。我们要先定义 update 动作,把提交的 params 散列传给 update_attributes 方法(6.1.5 节),更新用户,如代码清单 10.8 所示。如果提交的数据无效,更新操作会返回 false,由 else 分支处理,重新渲染编辑页面。我们之前用过类似的处理方式,代码结构和第一个版 create 动作类似(代码清单 7.18)。

代码清单 10.8update 动作的初始版本
app/controllers/users_controller.rb
class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  def edit
    @user = User.find(params[:id])
  end

  def update
    @user = User.find(params[:id])
    if @user.update_attributes(user_params)
      # 处理更新成功的情况
    else
      render 'edit'
    end
  end

  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end
end

注意调用 update_attributes 方法时指定的 user_params 参数,这是健壮参数(strong parameter),可以避免批量赋值带来的安全隐患(参见 7.3.2 节)。

因为 User 模型中定义了验证规则,而且代码清单 10.2 渲染了错误消息局部视图,所以提交无效信息后会显示一些有用的错误消息,如图 10.3 所示。

edit with invalid information 3rd edition
图 10.3:提交编辑表单后显示的错误消息
练习
  1. 提交一些无效的用户名、电子邮件地址和密码,确认编辑表单不会接受这些信息。

10.1.3 编辑失败的测试

10.1.2 节结束时编辑表单已经可以使用,按照旁注 3.3 中的测试指导方针,现在我们要编写集成测试捕获回归。和之前一样,首先要生成一个集成测试文件:

$ rails generate integration_test users_edit
      invoke  test_unit
      create    test/integration/users_edit_test.rb

然后为编辑失败编写一个简单的测试,如代码清单 10.9 所示。在这段测试中,我们检查提交无效信息后是否重新渲染编辑模板,以此确认行为是否正确。注意,这里使用 patch 方法发送 PATCH 请求,它的用法与 getpostdelete 类似。

代码清单 10.9:编辑失败的测试 GREEN
test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    patch user_path(@user), params: { user: { name:  "",
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }

    assert_template 'users/edit'
  end
end

此时,测试组件应该可以通过:

代码清单 10.10GREEN
$ rails test
练习
  1. 代码清单 10.9 中添加一行代码,确认错误消息的数量正确。提示:使用 assert_select 方法(表 5.2)确认 CSS 类为 alertdiv 标签中有没有文本“The form contains 4 errors”。

10.1.4 编辑成功(使用 TDD)

现在我们要让编辑表单能正常使用。编辑头像的功能已经有了,因为我们把上传头像的操作交由 Gravatar 处理,如需更换头像,点击图 10.2 中的“change”链接就可以了,如图 10.4 所示。下面我们来实现编辑其他信息的功能。

gravatar cropper
图 10.4:Gravatar 的图像裁切界面,上传了一位帅哥的图片

上手测试后,你可能会发现,编写应用代码之前编写测试比之后再写更有用。针对现在这种情况,我们要编写的是验收测试(acceptance test),由测试的结果决定某个功能是否完成。为了演示如何编写验收测试,我们将使用测试驱动开发技术完成用户编辑功能。

我们要编写与代码清单 10.9 类似的测试,确认更新用户的操作行为正确,只不过这一次我们会提交有效的信息。然后检查显示了闪现消息,而且成功重定向到了用户的资料页面,同时还要确认数据库中保存的用户信息也正确更新了。这个测试如代码清单 10.11 所示。注意,在代码清单 10.11 中,密码和密码确认都为空值,因为修改用户名和电子邮件地址时并不想修改密码。还要注意,我们使用 @user.reload6.1.5 节首次用到)重新加载数据库中存储的值,以此确认成功更新了信息。(新手很容易忘记这个操作,这就是为什么必须要有一定的经验才能编写有效的验收测试(以及 TDD)的原因。)

代码清单 10.11:编辑成功的测试 RED
test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "successful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name:  name,
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end
end

要让代码清单 10.11 中的测试通过,我们可以参照最终版 create 动作(代码清单 8.25)编写 update 动作,如代码清单 10.12 所示。

代码清单 10.12Users 控制器的 update 动作 RED
app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def update
    @user = User.find(params[:id])
    if @user.update_attributes(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end
  .
  .
  .
end

代码清单 10.12 的标题所示,测试组件无法通过,因为密码长度验证(代码清单 6.42)失败了,这是因为代码清单 10.11 中密码和密码确认都是空值。为了让测试通过,我们要在密码为空值时特殊处理最短长度验证,方法是把 allow_nil: true 选项传给 validates 方法,如代码清单 10.13 所示。

代码清单 10.13:更新时允许密码为空 GREEN
app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
  .
  .
  .
end

你可能担心这么改用户注册时可以把密码设为空值,其实不然,6.3.3 节说过,创建对象时,has_secure_password 会执行存在性验证,捕获密码为 nil 的情况。(密码为 nil 时能通过存在性验证,可是会被 has_secure_password 方法的验证捕获,因此修正了 7.3.3 节提到的错误消息重复问题。)

至此,用户编辑页面应该可以正常使用了,如图 10.5 所示。你也可以运行测试组件确认一下,应该可以通过。

代码清单 10.14GREEN
$ rails test
edit form working new
图 10.5:编辑成功后显示的页面
练习
  1. 在开发环境中确认可以编辑用户资料。

  2. 如果把电子邮件地址改成没有关联 Gravatar 头像的地址会怎样?

10.2 权限系统

在 Web 应用中,身份验证系统的功能是识别网站的用户,而权限系统是控制用户可以做什么操作。第 8 章实现的身份验证机制有一个很好的作用——可以实现权限系统。

虽然 10.1 节已经完成了 editupdate 动作,但是却有一个荒唐的安全隐患:任何人(甚至是未登录的用户)都可以访问这两个动作,更新任何用户的资料。本节我们要实现一种安全机制,限制用户必须先登录才能更新自己的资料,而且不能更新别人的资料。

10.2.1 节将处理未登录用户试图访问有权访问的保护页面。因为在使用应用的过程中经常会发生这种情况,所以我们将把这些用户转向登录页面,而且会显示一个帮助消息,构思图如图 10.6 所示。另一种情况是,用户尝试访问没有权限查看的页面(例如已登录的用户试图访问其他用户的编辑页面),此时将把用户重定向到根地址(10.2.2 节)。

login page protected mockup
图 10.6:访问受保护页面时看到的页面构思图

10.2.1 必须先登录

为了实现图 10.6 中的转向行为,我们要在 Users 控制器中使用前置过滤器。前置过滤器通过 before_action 方法设定,指定在某个动作运行前调用某个方法。[4]为了实现要求用户先登录的限制,我们将定义一个名为 logged_in_user 的方法,然后使用 before_action :logged_in_user 调用这个方法,如代码清单 10.15 所示。

代码清单 10.15:添加 logged_in_user 前置过滤器 RED
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # 前置过滤器

    # 确保用户已登录
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end

默认情况下,前置过滤器会应用于控制器中的所有动作,所以在上述代码中我们传入了 :only 选项,指定只应用在 editupdate 两个动作上。

退出后再访问用户编辑页面 /users/1/edit,可以看到这个前置过滤器的效果,如图 10.7 所示。

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

代码清单 10.16RED
$ rails test

这是因为现在 editupdate 动作都需要用户先登录,而在相应的测试中没有已登录的用户。

所以,在测试中访问 editupdate 动作之前,要先登入用户。这个操作可以通过 9.3 节定义的 log_in_as 辅助方法(代码清单 9.24)轻易实现,如代码清单 10.17 所示。

protected log in 3rd edition
图 10.7:尝试访问受保护页面后显示的登录表单
代码清单 10.17:登入测试用户 GREEN
test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    log_in_as(@user)
    get edit_user_path(@user)
    .
    .
    .
  end

  test "successful edit" do
    log_in_as(@user)
    get edit_user_path(@user)
    .
    .
    .
  end
end

(可以把登入测试用户的代码放在 setup 方法中,去除一些重复。但是,在 10.2.3 节我们要修改其中一个测试,在登录前访问编辑页面,如果把登录操作放在 setup 方法中就不能先访问其他页面了。)

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

代码清单 10.18GREEN
$ rails test

测试组件虽然通过了,但是对前置过滤器的测试还没完,因为即便把安全防护去掉,测试也能通过。你可以把前置过滤器注释掉确认一下,如代码清单 10.19 所示。这可不妙!在测试组件能捕获的所有回归中,重大安全漏洞或许是最重要的。按照代码清单 10.19 的方式修改后,绝对不能让测试通过。下面我们编写测试捕获这个问题。

代码清单 10.19:注释掉前置过滤器,测试安全防护措施 GREEN
app/controllers/users_controller.rb
class UsersController < ApplicationController
  # before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .
end

前置过滤器应用在指定的各个动作上,因此我们要在 Users 控制器的测试中编写相应的测试。我们计划使用正确的请求方法访问 editupdate 动作,然后确认把用户重定向到了登录路径。由表 7.1 得知,正确的请求方法分别是 GETPATCH,所以在测试中要使用 getpatch 方法,如代码清单 10.20 所示。

代码清单 10.20:测试 editupdate 动作是受保护的 RED
test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "should redirect edit when not logged in" do
    get edit_user_path(@user)
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "should redirect update when not logged in" do
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
    assert_not flash.empty?
    assert_redirected_to login_url
  end
end

注意,代码清单 10.20 中的第二个测试使用 patch 方法向 user_path(@user) 发送 PATCH 请求。根据表 7.1,这个请求由 Users 控制器的 update 动作处理。

测试组件现在无法通过,和我们预期的一样。为了让测试通过,我们只需把前置过滤器的注释去掉,如代码清单 10.21 所示。

代码清单 10.21:去掉前置过滤器的注释 GREEN
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .
end

这样修改之后,测试组件应该可以通过了:

代码清单 10.22GREEN
$ rails test

如果不小心让未授权的用户能访问 edit 动作,现在测试组件能立即捕获。

练习
  1. 前面说过,前置过滤器默认应用到控制器中的全部动作上。如果这样,会导致我们的应用出错(例如登录后才能访问注册页面,这显然是不对的)。把代码清单 10.15 中的 only: 散列注释掉,确认测试组件能捕获这个问题。

10.2.2 用户只能编辑自己的资料

当然,要求用户必须先登录还不够,用户必须只能编辑自己的资料。由 10.2.1 节得知,测试组件很容易漏掉基本的安全缺陷,所以我们将使用测试驱动开发技术确保写出的代码能正确实现安全机制。为此,我们要在 Users 控制器的测试中添加一些测试,完善代码清单 10.20

为了确保用户不能编辑其他用户的信息,我们需要登入第二个用户。为此,要在用户固件文件中再添加一个用户,如代码清单 10.23 所示。

代码清单 10.23:在固件文件中添加第二个用户
test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>

使用代码清单 9.24 中定义的 log_in_as 方法,我们可以使用代码清单 10.24 中的代码测试 editupdate 动作。注意,这里没有重定向到登录路径,而是根地址,因为试图编辑其他用户资料的用户已经登录了。

代码清单 10.24:尝试编辑其他用户资料的测试 RED
test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect edit when logged in as wrong user" do
    log_in_as(@other_user)
    get edit_user_path(@user)
    assert flash.empty?
    assert_redirected_to root_url
  end

  test "should redirect update when logged in as wrong user" do
    log_in_as(@other_user)
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
    assert flash.empty?
    assert_redirected_to root_url
  end
end

为了重定向试图编辑其他用户资料的用户,我们要定义一个名为 correct_user 的方法,然后设定一个前置过滤器调用这个方法,如代码清单 10.25 所示。注意,correct_user 中定义了 @user 变量,所以可以把 editupdate 动作中的 @user 赋值语句删掉。

代码清单 10.25:保护 editupdate 动作的 correct_user 前置过滤器 GREEN
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def edit
  end

  def update
    if @user.update_attributes(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # 前置过滤器

    # 确保用户已登录
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # 确保是正确的用户
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless @user == current_user
    end
end

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

代码清单 10.26GREEN
$ rails test

最后,我们还要重构一下。我们要遵守一般的约定,定义 current_user? 方法,返回布尔值,然后在 correct_user 中调用。我们要在 Sessions 辅助模块中定义这个方法,如代码清单 10.27 所示。 然后我们就可以把

unless @user == current_user

改成意义稍微明确一点儿的

unless current_user?(@user)
代码清单 10.27current_user? 方法
app/helpers/sessions_helper.rb
module SessionsHelper

  # 登入指定的用户
  def log_in(user)
    session[:user_id] = user.id
  end

  # 在持久会话中记住用户
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # 如果指定用户是当前用户,返回 true
  def current_user?(user)
    user == current_user
  end
  .
  .
  .
end

把直接比较的代码换成返回布尔值的方法后,得到的代码如代码清单 10.28 所示。

代码清单 10.28correct_user 前置过滤器的最终版本 GREEN
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def edit
  end

  def update
    if @user.update_attributes(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # 前置过滤器

    # 确保用户已登录
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # 确保是正确的用户
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end
end
练习
  1. 为什么要保护 editupdate 两个动作?

  2. 哪个动作在浏览器中容易测试?

10.2.3 友好的转向

网站的权限系统完成了,但是还有一个小瑕疵:不管用户尝试访问的是哪个受保护的页面,登录后都会重定向到资料页面。也就是说,如果未登录的用户访问了编辑资料页面,网站要求先登录,登录后会重定向到 /users/1,而不是 /users/1/edit。如果登录后能重定向到用户之前想访问的页面就更好了。

实现这种需求所需的应用代码有点儿复杂,不过测试很简单,我们只需把代码清单 10.17 中登录和访问编辑页面两个操作调换顺序即可。如代码清单 10.29 所示,最终写出的测试先访问编辑页面,然后登录,最后确认把用户重定向到了编辑页面,而不是默认的资料页面。

代码清单 10.29:测试友好的转向 RED
test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)
    log_in_as(@user)
    assert_redirected_to edit_user_url(@user)
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name:  name,
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end
end

有了一个失败测试,现在可以实现友好的转向了。[5]要转向用户真正想访问的页面,我们要在某个地方存储页面的地址,登录后再重定向到那个页面。我们将通过两个方法来实现这个过程,store_locationredirect_back_or,它们都在 Sessions 辅助模块中定义,如代码清单 10.30 所示。

代码清单 10.30:实现友好的转向 RED
app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 重定向到存储的地址或者默认地址
  def redirect_back_or(default)
    redirect_to(session[:forwarding_url] || default)
    session.delete(:forwarding_url)
  end

  # 存储后面需要使用的地址
  def store_location
    session[:forwarding_url] = request.original_url if request.get?
  end
end

我们使用 session 存储转向地址,这与 8.2.1 节登入用户的方式类似。代码清单 10.30 还用到了 request 对象,获取所请求页面的地址(request.original_url)。

store_location 方法中,把请求的地址存储在 session[:forwarding_url] 中,而且只当请求是 GET 请求时才存储。这么做,当未登录的用户提交表单时,不会存储转向地址(这种情况虽然罕见,但在提交表单前,如果用户手动删除了会话,还是会发生的)。如果存储了,那么本来期望接收 POSTPATCHDELETE 请求的动作实际收到的是 GET 请求,从而导致错误。加上 if request.get? 能避免这种问题。[6]

若想使用 store_location 方法,我们要把它加入 logged_in_user 前置过滤器中,如代码清单 10.31 所示。

代码清单 10.31:把 store_location 方法添加到 logged_in_user 前置过滤器中 RED
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def edit
  end
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # 前置过滤器

    # 确保用户已登录
    def logged_in_user
      unless logged_in?
        store_location
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # 确保是正确的用户
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end
end

为了实现转向操作,要在 Sessions 控制器的 create 动作中调用 redirect_back_or 方法,如果存储了之前请求的地址,就重定向到那个地址,否则重定向到一个默认的地址,如代码清单 10.32 所示。redirect_back_or 方法用到了或运算符 ||

session[:forwarding_url] || default

如果 session[:forwarding_url] 的值不为 nil,就返回其中存储的值,否则返回默认的地址。注意,代码清单 10.30 处理得很谨慎,删除了转向地址。如果不删除,后续登录会不断重定向到受保护的页面,用户只能关闭浏览器。(针对这个行为的测试留作练习。)还要注意,即便先重定向了,还是会删除会话中的转向地址,因为除非明确使用了 return 或者到了方法的末尾,否则重定向之后的代码仍然会执行。

代码清单 10.32:加入友好转向后的 create 动作 GREEN
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)
      redirect_back_or user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end
  .
  .
  .
end

现在,代码清单 10.29 中针对友好转向的集成测试应该可以通过了。而且,基本的用户认证和页面保护机制也完成了。和之前一样,在继续之前,最好运行测试组件,确认可以通过:

代码清单 10.33GREEN
$ rails test
练习
  1. 编写一个测试,确保友好转向只会在首次登录后转向指定的地址,以后再尝试登录都会转向默认地址(即资料页面)。提示:把这个测试添加到代码清单 10.29 中,检查 session[:forwarding_url] 中是否保存了正确的值。

  2. debugger 方法(7.1.3 节)添加到 Sessions 控制器的 new 动作中,然后退出,再访问 /users/1/edit。在调试器中确认 session[:forwarding_url] 的值是正确的。new 动作中 request.get? 的值是什么?(使用调试器时,终端有时会假死,或者行为怪异,请自行设法解决。)

10.3 列出所有用户

本节,我们要添加 Users 控制器中的倒数第二个动作,indexindex 动作不是显示某一个用户,而是显示所有用户。在这个过程中,我们将学习如何在数据库中生成示例用户数据,以及如何分页显示用户列表,让用户列表以灵活的方式显示大量用户。用户列表、分页链接和导航栏中的“Users”链接的构思图如图 10.8 所示。[7]10.4 节将添加管理功能,用来删除用户。

user index mockup bootstrap
图 10.8:用户列表页面的构思图

10.3.1 用户列表

创建用户列表之前,我们先要实现一个安全机制。单个用户的资料页面对网站的所有访问者开放,但要限制用户列表页面,只让已登录的用户查看,减少未注册用户能看到的信息量。[8]

为了限制访问 index 动作,我们先编写一个简短的测试,确认访问 index 动作时能正确地重定向,如代码清单 10.34 所示。

代码清单 10.34:测试 index 动作的重定向 RED
test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end

  test "should redirect index when not logged in" do
    get users_path
    assert_redirected_to login_url
  end
  .
  .
  .
end

然后我们要定义 index 动作,并把它加入被 logged_in_user 前置过滤器保护的动作列表中,如代码清单 10.35 所示。

代码清单 10.35:访问 index 动作要先登录 GREEN
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  before_action :correct_user,   only: [:edit, :update]

  def index
  end

  def show
    @user = User.find(params[:id])
  end
  .
  .
  .
end

为了显示用户列表,我们要定义一个变量,存储网站中的所有用户,然后在 index 动作的视图中遍历,显示各个用户。你可能还记得玩具应用中相应的动作(代码清单 2.8),我们可以使用 User.all 从数据库中读取所有用户,然后把这些用户赋值给实例变量 @users,以便在视图中使用,如代码清单 10.36 所示。(你可能会觉得一次列出所有用户不太好,你是对的,我们会在 10.3.3 节改进。)

代码清单 10.36Users 控制器的 index 动作
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  .
  .
  .
  def index
    @users = User.all
  end
  .
  .
  .
end

为了显示用户列表页面,我们要创建一个视图(要自己动手创建视图文件),遍历所有用户,把每个用户包含在一个 li 标签中。我们要使用 each 方法遍历所有用户,显示用户的 Gravatar 头像和名字,然后把所有用户包含在一个无序列表 ul 标签中,如代码清单 10.37 所示。

代码清单 10.37:用户列表视图
app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<ul class="users">
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

代码清单 10.37 中,我们用到了 7.1.4 节练习代码清单 7.12 的成果,向 Gravatar 辅助方法传入第二个参数,指定头像的大小。如果你之前没有做这个练习,在继续阅读之前请参照代码清单 10.38,更新 Users 辅助模块文件。(也可以使用代码清单 7.13 中使用关键字参数那个版本。)

代码清单 10.38:为 gravatar_for 辅助方法添加一个散列选项
app/helpers/users_helper.rb
module UsersHelper

  # 返回指定用户的 Gravatar
  def gravatar_for(user, options = { size: 80 })
    gravatar_id = Digest::MD5::hexdigest(user.email.downcase)
    size = options[:size]
    gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
    image_tag(gravatar_url, alt: user.name, class: "gravatar")
  end
end

然后再添加一些 CSS 样式(确切地说是 SCSS),如代码清单 10.39 所示。

代码清单 10.39:用户列表页面的 CSS
app/assets/stylesheets/custom.scss
.
.
.
/* Users index */

.users {
  list-style: none;
  margin: 0;
  li {
    overflow: auto;
    padding: 10px 0;
    border-bottom: 1px solid $gray-lighter;
  }
}

最后,我们还要把页头导航栏中用户列表页面的链接地址换成 users_path,这是表 7.1 中还没用到的最后一个具名路由,如代码清单 10.40 所示。

至此,用户列表页面完成了,所有的测试也都可以通过了:

代码清单 10.41GREEN
$ rails test

不过,如图 10.9 所示,页面中只显示了一个用户,有点孤单。下面,我们来改变这种悲惨状况。

user index only one 3rd edition
图 10.9:用户列表页面,只显示了一个用户
练习
  1. 至此,网站布局中的链接都设定好了。为这些链接编写一个集成测试,包括登录前后用户看到的链接。提示:使用 log_in_as 辅助方法,把测试添加到代码清单 5.32 中。

10.3.2 示例用户

本节,我们要为应用添加更多的用户。为了让用户列表看上去像个“列表”,我们可以在浏览器中访问注册页面,一个一个地注册用户。不过还有更好的方法,我们可以使用 Ruby 代码创建用户。

首先,我们要在 Gemfile 文件中加入 faker gem,如代码清单 10.42 所示。这个 gem 会使用半真实的名字和电子邮件地址创建示例用户。(通常,可能只需在开发环境中安装 faker gem,但是对这个演示应用来说,生产环境也要使用 faker,参见 10.5 节。)

代码清单 10.42:在 Gemfile 文件中加入 faker gem
source 'https://rubygems.org'

gem 'rails',                   '5.1.6'
gem 'bcrypt',                  '3.1.12'
gem 'faker',                   '1.7.3'
.
.
.

然后和之前一样,运行下面的命令安装:

$ bundle install

接下来,我们要添加一些 Ruby 代码,向数据库中添加示例用户。Rails 使用一个标准文件 db/seeds.rb 完成这种操作,如代码清单 10.43 所示。(这段代码涉及一些高级知识,现在不必太关注细节。)

代码清单 10.43:向数据库中添加示例用户的 Ruby 代码
db/seeds.rb
User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar")

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)
end

代码清单 10.43 中,我们首先使用现有用户的名字和电子邮件地址创建一个示例用户,然后创建 99 个示例用户。其中,create! 方法和 create 方法的作用类似,只不过遇到无效数据时会抛出异常,而不是返回 false。这么做是防止出错时不报错,有利于调试。

然后,我们可以执行下述命令,还原数据库,再执行 db:seed 命令:

$ rails db:migrate:reset
$ rails db:seed

向数据库中添加数据的操作可能很慢,在某些系统中可能要花上几分钟。此外,有些读者反馈说,Rails 服务器运行的过程中无法执行 reset 命令,因此,可能要先停止服务器,然后再执行上述命令。

执行完 db:seed 命令后,我们的应用中就有 100 个用户了,如图 10.10 所示。我牺牲了一点个人时间,为前几个用户上传了头像,这样就不会都显示默认的 Gravatar 头像了。

user index all 3rd edition
图 10.10:用户列表页面,显示了 100 个示例用户
练习
  1. 访问另一个用户的个人资料编辑页面,确认会像 10.2.2 节所说的那样重定向。

10.3.3 分页

现在,最初的那个用户不再孤单了,但是又出现了新问题:用户太多,全在一个页面中显示。现在的用户数量是 100 个,算是少的了,在真实的网站中,这个数量可能是以千计的。为了避免在一页中显示过多的用户,我们可以分页,一页只显示 30 个用户(只是举个例子)。

在 Rails 中有很多实现分页的方法,我们将使用其中一个最简单也最完善的,叫 will_paginate。为此,我们要使用 will_paginatebootstrap-will_paginate 两个 gem。其中,bootstrap-will_paginate 的作用是让 will_paginate 使用 Bootstrap 提供的分页样式。修改后的 Gemfile 文件如代码清单 10.44 所示。

代码清单 10.44:在 Gemfile 文件中加入 will_paginate gem
source 'https://rubygems.org'

gem 'rails',                   '5.1.6'
gem 'bcrypt',                  '3.1.12'
gem 'faker',                   '1.7.3'
gem 'bootstrap-will_paginate', '1.0.0'
gem 'bootstrap-sass',          '3.3.7'
.
.
.

然后执行下面的命令安装:

$ bundle install

安装后还要重启 Web 服务器,确保正确加载这两个新 gem。

为了实现分页,我们要在 index 视图中加入一些代码,让 Rails 分页显示用户,而且要把 index 动作中的 User.all 换成知道如何分页的方法。我们先在视图中加入特殊的 will_paginate 方法,如代码清单 10.45 所示。稍后我们会看到为什么要在用户列表的前后都加入这个方法。

代码清单 10.45:在 index 视图中加入分页
app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

<%= will_paginate %>

will_paginate 方法有点小神奇,在 Users 控制器的视图中,它会自动寻找名为 @users 的对象,然后显示一个分页导航链接。代码清单 10.45 中的视图现在还不能正确显示分页,因为 @users 的值是通过 User.all 方法获取的(代码清单 10.36),而 will_paginate 要求调用 paginate 方法才能分页:

$ rails console
>> User.paginate(page: 1)
  User Load (1.5ms)  SELECT "users".* FROM "users" LIMIT 30 OFFSET 0
   (1.7ms)  SELECT COUNT(*) FROM "users"
=> #<ActiveRecord::Relation [#<User id: 1,...

注意,paginate 方法可以接受一个散列参数,其中 :page 键的值指定显示第几页。User.paginate 方法根据 :page 的值,一次取回一组用户(默认为 30 个)。所以,第一页显示的是第 1-30 个用户,第二页显示的是第 31-60 个用户,以此类推。如果 :page 的值为 nilpaginate 会显示第一页。

我们可以把 index 动作中的 all 方法换成 paginate 方法,如代码清单 10.46 所示,这样就能分页显示用户了。paginate 方法所需的 :page 参数由 params[:page] 指定,params 中的这个键由 will_pagenate 自动生成。

代码清单 10.46:在 index 动作中分页取回用户
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  .
  .
  .
  def index
    @users = User.paginate(page: params[:page])
  end
  .
  .
  .
end

现在,用户列表页面应该可以显示分页了,如图 10.11 所示。(在某些系统中,可能需要重启 Rails 服务器。)因为我们在用户列表前后都加入了 will_paginate 方法,所以这两个地方都会显示分页链接。

user index pagination 3rd edition
图 10.11:分页显示的用户列表页面

如果点击链接“2”,或者“Next”,会显示第二页,如图 10.12 所示。

user index page two 3rd edition
图 10.12:用户列表的第二页
练习
  1. 在 Rails 控制台中确认,把 page 参数设为 nil 时获取的是第一页。

  2. 分页对象属于哪个 Ruby 类?与 User.all 所属的类有什么不同?

10.3.4 用户列表页面的测试

现在用户列表页面可以正常使用了,接下来要为这个页面编写一些简单的测试,其中一个测试前一节实现的分页。测试的步骤是,先登录,然后访问用户列表页面,确认第一页显示了一些用户,而且还显示了分页链接。为此,测试数据库中要有足够数量的用户,足以分页才行,即超过 30 个。

我们在代码清单 10.23 中创建了第二个用户固件,但手动创建 30 多个用户,工作量有点大。不过,由固件中的 password_digest 属性得知,固件文件支持嵌入式 Ruby,所以我们可以使用代码清单 10.47 中的代码,再创建 30 个用户。(代码清单 10.47 还多创建了几个用户,以备后用。)

代码清单 10.47:在固件中再创建 30 个用户
test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>

lana:
  name: Lana Kane
  email: hands@example.gov
  password_digest: <%= User.digest('password') %>

malory:
  name: Malory Archer
  email: boss@example.gov
  password_digest: <%= User.digest('password') %>

<% 30.times do |n| %>
user_<%= n %>:
  name:  <%= "User #{n}" %>
  email: <%= "user-#{n}@example.com" %>
  password_digest: <%= User.digest('password') %>
<% end %>

然后,我们可以编写用户列表页面的测试了。首先,生成所需的测试文件:

$ rails generate integration_test users_index
      invoke  test_unit
      create    test/integration/users_index_test.rb

在测试中,我们要检查是否有一个类为 paginationdiv 标签,以及第一页中是否显示了相应的用户,如代码清单 10.48 所示。

代码清单 10.48:用户列表及分页的测试 GREEN
test/integration/users_index_test.rb
require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "index including pagination" do
    log_in_as(@user)
    get users_path
    assert_template 'users/index'
    assert_select 'div.pagination'
    User.paginate(page: 1).each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
    end
  end
end

测试组件应该可以通过:

代码清单 10.49GREEN
$ rails test
练习
  1. 代码清单 10.45 中的分页链接注释掉,确认代码清单 10.48 中的测试会失败。

  2. 确认只注释掉一个 will_paginate 调用时测试可以通过。那么,如何测试有两个分页链接呢?提示:使用 count 参数(表 5.2)。

10.3.5 使用局部视图重构

用户列表页面现在已经可以显示分页了,但是有个地方可以改进,我不得不讲一下。Rails 提供了一些很巧妙的方法,可以精简视图的结构。本节我们要利用这些方法重构一下用户列表页面。因为我们已经做了很好的测试,所以可以放心重构,不必担心会破坏网站的功能。

重构的第一步,把代码清单 10.45 中的 li 换成 render 方法调用,如代码清单 10.50 所示。

代码清单 10.50:重构 index 视图的第一步
app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
    <%= render user %>
  <% end %>
</ul>

<%= will_paginate %>

在上述代码中,render 的参数不再是指定局部视图的字符串,而是代表 User 类的变量 user[9]此时,Rails 会自动寻找一个名为 _user.html.erb 的局部视图。我们要手动创建这个视图,然后写入代码清单 10.51 中的内容。

代码清单 10.51:渲染单个用户的局部视图
app/views/users/_user.html.erb
<li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>

这个改进不错,不过我们还可以做得更好。我们可以直接把 @users 变量传给 render 方法,如代码清单 10.52 所示。

代码清单 10.52:完全重构后的 index 视图 GREEN
app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <%= render @users %>
</ul>

<%= will_paginate %>

Rails 会把 @users 当作一个 User 对象列表,传给 render 方法后,Rails 会自动遍历这个列表,然后使用局部视图 _user.html.erb 渲染每个对象(Rails 从类名中推断出局部视图的名称)。重构后,我们得到了如代码清单 10.52 这样简洁的代码。

每次重构修改应用代码后,都要运行测试组件确认仍能通过:

代码清单 10.53GREEN
$ rails test
练习
  1. 代码清单 10.52 中的 render 那行注释掉,确认测试无法通过。

10.4 删除用户

至此,用户列表页面完成了。符合 REST 架构的 Users 资源只剩下最后一个动作了,即 destroy 动作。本节,我们会先添加删除用户的链接(构思图如图 10.13 所示),然后再编写 destroy 动作,完成删除操作。不过,在此之前我们要先创建管理员级别的用户,并授权这些用户执行删除操作。

10.4.1 管理员

我们要通过 User 模型中一个名为 admin 的属性来判断用户是否具有管理员权限。admin 属性的类型为布尔值,Active Record 会自动生成一个 admin? 布尔值方法,判断用户是否为管理员。添加 admin 属性后,User 数据模型如图 10.14 所示。

user model admin 3rd edition
图 10.14:添加 admin 布尔值属性后的 User 模型

和之前一样,我们要使用迁移添加 admin 属性,并且在命令行中指定其类型为 boolean

$ rails generate migration add_admin_to_users admin:boolean

这个迁移会在 users 表中添加 admin 列,如代码清单 10.54 所示。注意,在代码清单 10.54 中,我们在 add_column 方法中指定了 default: false 参数,意思是默认情况下用户不是管理员。(如果不指定 default: false 参数,admin 的默认值是 nil,也是假值,所以这个参数并不是必须的。不过,指定这个参数,可以更明确地向 Rails 以及代码的阅读者表明这段代码的意图。)

代码清单 10.54:向 User 模型中添加 admin 属性的迁移
db/migrate/[timestamp]_add_admin_to_users.rb
class AddAdminToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :admin, :boolean, default: false
  end
end

然后,像往常一样,执行迁移:

$ rails db:migrate

和预想的一样,Rails 能自动识别 admin 属性的类型为布尔值,自动生成 admin? 方法:

$ rails console --sandbox
>> user = User.first
>> user.admin?
=> false
>> user.toggle!(:admin)
=> true
>> user.admin?
=> true

这里,我们使用 toggle! 方法把 admin 属性的值由 false 改为 true

最后,我们要修改种子数据,把第一个用户设为管理员,如代码清单 10.55 所示。

代码清单 10.55:在种子数据中把第一个用户设为管理员
db/seeds.rb
User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin: true)

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)
end

然后还原数据库:

$ rails db:migrate:reset
$ rails db:seed

健壮参数再探

你可能注意到了,在代码清单 10.55 中,我们在初始化散列参数中指定了 admin: true,把用户设为管理员。这么做的后果是,用户对象暴露在网络中了,如果在请求中提供初始化参数,恶意用户就可以发送如下的 PATCH 请求:[10]

patch /users/17?admin=1

这个请求会把 17 号用户设为管理员——这是个严重的潜在安全隐患。

鉴于此,必须只允许通过请求传入可安全编辑的属性。我们在 7.3.2 节说过,可以使用健壮参数实现这一限制,即在 params 散列上调用 requirepermit 方法:

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

注意,admin 并不在允许的属性列表中。这样就可以避免用户取得网站的管理权。因为这一步很重要,最好再为不可编辑的属性编写一个测试。针对 admin 属性的测试留作练习

练习
  1. 参照代码清单 10.56,直接向 update 动作发送 PATCH 请求,确认无法修改 admin 属性。为了确保测试写得正确,首先应该把 admin 添加到允许修改的参数列表 user_params 中,确认在此之前测试组件无法通过。

代码清单 10.56:测试禁止修改 admin 属性
test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect update when not logged in" do
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "should not allow the admin attribute to be edited via the web" do
    log_in_as(@other_user)
    assert_not @other_user.admin?
    patch user_path(@other_user), params: {
                                    user: { password:              FILL_IN,
                                            password_confirmation: FILL_IN,
                                            admin: FILL_IN } }
    assert_not @other_user.FILL_IN.admin?
  end
  .
  .
  .
end

10.4.2 destroy 动作

完成 Users 资源的最后一步是,添加删除链接和 destroy 动作。我们先在用户列表页面中的每个用户后面加入一个删除链接,而且限制只有管理员才能执行删除操作。只有当前用户是管理员才能看到删除链接。视图如代码清单 10.57 所示。

注意 method: :delete 参数,它指明点击链接后发送的是 DELETE 请求。我们还把链接放在 if 语句中,这样就只有管理员才能看到删除用户的链接。管理员看到的页面如图 10.15 所示。

Web 浏览器不能发送 DELETE 请求,Rails 是通过 JavaScript 模拟的。也就是说,如果用户禁用了 JavaScript,那么删除用户的链接就不可用了。如果必须要支持没启用 JavaScript 的浏览器,可以使用一个发送 POST 请求的表单来模拟 DELETE 请求,这样即使禁用了 JavaScript,删除用户的链接仍能使用。[11]

为了让删除链接起作用,我们要定义 destroy 动作(表 7.1)。在 destroy 动作中,先找到要删除的用户,然后使用 Active Record 提供的 destroy 方法将其删除,最后再重定向到用户列表页面,如代码清单 10.58 所示。因为登录后才能删除用户,所以代码清单 10.58 还在 logged_in_user 前置过滤器中添加了 :destroy

代码清单 10.58:添加 destroy 动作
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def destroy
    User.find(params[:id]).destroy
    flash[:success] = "User deleted"
    redirect_to users_url
  end

  private
  .
  .
  .
end

注意,在 destroy 动作中,我们把 find 方法和 destroy 方法连在一起调用,只占了一行:

User.find(params[:id]).destroy

理论上,只有管理员才能看到删除用户的链接,所以只有管理员才能删除用户。但实际上还是存在一个严重的安全漏洞:只要攻击者有足够的经验,就可以在命令行中发送 DELETE 请求,删除网站中的任何用户。为了保障网站的安全,我们还要限制对 destroy 动作的访问,只允许管理员删除用户。

10.2.1 节10.2.2 节的做法一样,我们要使用前置过滤器限制访问。这一次,我们要限制只有管理员才能访问 destroy 动作。我们要定义一个名为 admin_user 的前置过滤器,如代码清单 10.59 所示。

代码清单 10.59:限制只有管理员才能访问 destroy 动作的前置过滤器
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  before_action :admin_user,     only: :destroy
  .
  .
  .
  private
    .
    .
    .
    # 确保是管理员
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end
end
练习
  1. 在浏览器中以管理员的身份删除几个示例用户。在服务器的日志中会看到什么?

10.4.3 删除用户的测试

像删除用户这种危险的操作,一定要编写测试,确保行为与预期一致。首先,我们把一个用户固件设为管理员,如代码清单 10.60 所示。

代码清单 10.60:把固件中的一个用户设为管理员
test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true
.
.
.

按照 10.2.1 节的做法,我们会把限制访问动作的测试放在 Users 控制器的测试文件中。和代码清单 8.31 一样,我们要使用 delete 方法直接向 destroy 动作发送 DELETE 请求。我们要检查两种情况:其一,没登录的用户会重定向到登录页面;其二,已经登录的用户,但不是管理员,会重定向到首页。测试如代码清单 10.61 所示。

代码清单 10.61:测试只有管理员能访问的动作 GREEN
test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect destroy when not logged in" do
    assert_no_difference 'User.count' do
      delete user_path(@user)
    end
    assert_redirected_to login_url
  end

  test "should redirect destroy when logged in as a non-admin" do
    log_in_as(@other_user)
    assert_no_difference 'User.count' do
      delete user_path(@user)
    end
    assert_redirected_to root_url
  end
end

注意,在代码清单 10.61 中,我们使用 assert_no_difference 方法(代码清单 7.23 中用过)确认用户的数量没有变化。

代码清单 10.61 中的测试确认了未授权的用户(非管理员)不能删除用户,不过我们还要确认管理员点击删除链接后能成功删除用户。因为删除链接在用户列表页面,所以我们要把这个测试添加到用户列表页面的测试中(代码清单 10.48)。这个测试唯一需要一点技巧的代码是,管理员点击删除链接后如何确认用户被删除了。我们可以使用下面的代码实现:

assert_difference 'User.count', -1 do
  delete user_path(@other_user)
end

我们使用代码清单 7.33 中检查创建了一个用户的 assert_difference 方法,不过这一次要确认向相应的地址发送 DELETE 请求后,User.count 的变化量是 -1,从而确认用户被删除了。

综上所述,针对分页和删除操作的测试如代码清单 10.62 所示,这段代码既测试了管理员执行的删除操作,也测试了非管理员执行的删除操作。

注意,代码清单 10.62 检查了每个用户旁都有删除链接,而且如果用户是管理员,就不做这个检查(因为管理员旁不会显示删除链接,参见代码清单 10.57)。

现在,删除用户的代码有了良好的测试,而且测试组件应该能通过:

代码清单 10.63GREEN
$ rails test
练习
  1. 代码清单 10.59 中的 admin_user 前置过滤器注释掉,确认测试会失败。

10.5 小结

我们用了好几章介绍如何实现 Users 控制器,在 5.4 节用户还不能注册,而现在不仅可以注册,还可以登录、退出、查看个人信息、修改信息,还能浏览网站中所有用户的列表,某些用户甚至可以删除其他用户。

现阶段实现的演示应用建立了坚实的基础,完全可以用于任何需要验证用户身份和权限系统的网站。第 11 章第 12 章将实现两个附加功能:向新注册的用户发送账户激活链接(同时验证电子邮件地址有效),以及密码重设功能,为忘记密码的用户提供帮助。

在继续阅读之前,先把本章所做的改动合并到主分支:

$ git add -A
$ git commit -m "Finish user edit, update, index, and destroy actions"
$ git checkout master
$ git merge updating-users
$ git push

你还可以部署这个应用,甚至使用示例用户填充生产数据库(pg:reset 用于还原生产数据库):

$ rails test
$ git push heroku
$ heroku pg:reset DATABASE
$ heroku run rails db:migrate
$ heroku run rails db:seed
$ heroku restart

当然,在真实的网站中你或许并不想向数据库中添加示例数据,我加入这个操作只是为了查看生产环境中的效果(图 10.16)。生产环境显示的示例用户顺序各异,我的网站显示的顺序就和本地不同(图 10.11)。这是因为,我们没有指定从数据库中取回用户的顺序,所以目前的顺序由数据库决定。这对用户而言没什么问题,但微博就不同了,我们会在 13.1.4 节解决这个问题。

heroku sample users
图 10.16:生产环境显示的示例用户

10.5.1 本章所学

  • 可以使用编辑表单修改用户的资料,这个表单向 update 动作发送 PATCH 请求;

  • 为了提升通过 Web 修改信息的安全性,必须使用健壮参数;

  • 前置过滤器是在控制器动作之前执行方法的标准方式;

  • 我们使用前置过滤器实现了权限系统;

  • 针对权限系统的测试既使用了低层命令直接向控制器动作发送适当的 HTTP 请求,也使用了高层的集成测试;

  • 友好转向会在用户登录后重定向到之前想访问的页面;

  • 用户列表页面列出了所有用户,而且一页只显示一部分用户;

  • Rails 使用标准的文件 db/seeds.rb 向数据库中添加示例数据,这个操作使用 rails db:seed 命令完成;

  • render @users 会自动调用 _user.html.erb 局部视图,渲染集合中的各个用户;

  • User 模型中添加 admin 布尔值属性后,会自动创建 user.admin? 布尔值方法;

  • 管理员点击删除链接可以删除用户,点击删除链接后会向 Users 控制器的 destroy 动作发起 DELETE 请求;

  • 在固件中可以使用嵌入式 Ruby 创建大量测试用户。

  1. 原图地址:http://www.flickr.com/photos/sashawolff/4598355045/,发布于 2014 年 8 月 25 日。Copyright © 2010 by Sasha Wolff。未经改动,基于“知识共享 署名 2.0 通用”许可证使用。
  2. 不要担心实现的细节。具体的实现方式是 Rails 框架的开发者需要关注的,Rails 应用开发者无需关心。
  3. 感谢 Jose Carlos Montero Gómez 建议使用这个方法进一步去除重复。
  4. 设定前置过滤器的方法以前是 before_filter,但为了强调过滤器在控制器的动作之前运行,Rails 核心开发团队决定使用这个新名称。
  5. 本节使用的代码摘自 thoughtbot 开发的 Clearance gem。
  6. 感谢读者 Yoel Adler 指出这个小问题,并和我讨论解决办法。
  7. 婴儿的图片出自 http://www.flickr.com/photos/glasgows/338937124/,发布于 2014 年 8 月 25 日。Copyright © 2008 by M&R Glasgow。未经改动,基于“知识共享 署名 2.0 通用”许可证使用。
  8. Twitter 也是这么做的。
  9. 变量名并不是一定要使用 user,遍历时如果用的是 @users.each do |foobar|,那么就要用 render foobar。关键是要知道对象的类,也就是 User
  10. curl 等命令行工具可以发送这种 PATCH 请求。
  11. 详情请观看 RailsCasts 中的“Destroy Without JavaScript”。