9.2 权限系统

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

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

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

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

9.2.1 必须先登录

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

代码清单 9.12:添加 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,可以看到这个事前过滤器的效果,如图 9.7 所示。

protected log in 3rd edition图 9.7:尝试访问受保护页面后显示的登录表单

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

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

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

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

代码清单 9.14:登入测试用户 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 方法中,去除一些重复。但是,在 9.2.3 节我们要修改其中一个测试,在登录前访问编辑页面,如果把登录操作放在 setup 方法中就不能先访问其他页面了。)

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

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

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

代码清单 9.16:注释掉事前过滤器,测试安全防护措施 GREEN

app/controllers/users_controller.rb

class UsersController < ApplicationController
  # before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .
end

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

代码清单 9.17:测试 editupdate 动作是受保护的 RED

test/controllers/users_controller_test.rb

require 'test_helper'

class UsersControllerTest < ActionController::TestCase

  def setup
 @user = users(:michael)  end

  test "should get new" do
    get :new
    assert_response :success
  end

 test "should redirect edit when not logged in" do get :edit, id: @user assert_redirected_to login_url end 
 test "should redirect update when not logged in" do patch :update, id: @user, user: { name: @user.name, email: @user.email } assert_redirected_to login_url end end

注意 getpatch 的参数:

get :edit, id: @user

patch :update, id: @user, user: { name: @user.name, email: @user.email }

这里使用了一个 Rails 约定:指定 id: @user 时,Rails 会自动使用 @user.id。在 patch 方法中还要指定一个 user 哈希,这样路由才能正常运行。(如果查看第 2 章为玩具应用生成的用户控制器测试,会看到上述代码。)

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

代码清单 9.18:去掉事前过滤器的注释 GREEN

app/controllers/users_controller.rb

class UsersController < ApplicationController
 before_action :logged_in_user, only: [:edit, :update]  .
  .
  .
end

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

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

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

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

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

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

代码清单 9.20:在固件文件中添加第二个用户

test/fixtures/users.yml

michael:
  name: Michael Example
  email: [email protected]
  password_digest: <%= User.digest('password') %>

archer:
 name: Sterling Archer email: [email protected] password_digest: <%= User.digest('password') %>

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

代码清单 9.21:尝试编辑其他用户资料的测试 RED

test/controllers/users_controller_test.rb

require 'test_helper'

class UsersControllerTest < ActionController::TestCase

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

  test "should get new" do
    get :new
    assert_response :success
  end

  test "should redirect edit when not logged in" do
    get :edit, id: @user
    assert_redirected_to login_url
  end

  test "should redirect update when not logged in" do
    patch :update, id: @user, user: { name: @user.name, email: @user.email }
    assert_redirected_to login_url
  end

 test "should redirect edit when logged in as wrong user" do log_in_as(@other_user) get :edit, id: @user assert_redirected_to root_url end 
 test "should redirect update when logged in as wrong user" do log_in_as(@other_user) patch :update, id: @user, user: { name: @user.name, email: @user.email } assert_redirected_to root_url end end

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

代码清单 9.22:保护 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

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

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

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

unless @user == current_user

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

unless current_user?(@user)
代码清单 9.24:current_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

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

代码清单 9.25: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 current_user?(@user)    end
end

9.2.3 友好的转向

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

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

代码清单 9.26:测试友好的转向 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_path(@user)    name  = "Foo Bar"
    email = "[email protected]"
    patch user_path(@user), user: { name:  name,
                                    email: email,
                                    password:              "",
                                    password_confirmation: "" }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal @user.name,  name
    assert_equal @user.email, email
  end
end

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

代码清单 9.27:实现友好的转向

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.url if request.get?  end
end

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

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

要使用 store_location,我们要把它加入 logged_in_user 事前过滤器中,如代码清单 9.28 所示。

代码清单 9.28:把 store_location 添加到 logged_in_user 事前过滤器中

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

实现转向操作,要在会话控制器的 create 动作中调用 redirect_back_or 方法,如果存储了之前请求的地址,就重定向这个地址,否则重定向到一个默认的地址,如代码清单 9.29 所示。redirect_back_or 方法中使用了 || 操作符:

session[:forwarding_url] || default

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

代码清单 9.29:加入友好转向后的 create 动作

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

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

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