9.2 权限系统
最后更新于:2022-04-01 22:29:50
# 9.2 权限系统
在 Web 应用中,认证系统的功能是识别网站的用户,权限系统是控制用户可以做什么操作。[第 8 章](chapter8.html#log-in-log-out)实现的认证机制有一个很好的作用,可以实现权限系统。
虽然 [9.1 节](#updating-users)已经完成了 `edit` 和 `update` 动作,但是却有一个荒唐的安全隐患:任何人(甚至是未登录的用户)都可以访问这两个动作,而且登录后的用户可以更新所有其他用户的资料。本节我们要实现一种安全机制,限制用户必须先登录才能更新自己的资料,而且不能更新别人的资料。
[9.2.1 节](#requiring-logged-in-users)要处理未登录用户试图访问有权访问的保护页面。因为在使用应用的过程中经常会发生这种情况,所以我们要把这些用户转向登录页面,而且会显示一个帮助消息,构思图如[图 9.6](#fig-login-page-protected-mockup) 所示。另一种情况是,用户尝试访问没有权限查看的页面(例如已登录的用户试图访问其他用户的编辑页面),此时要把用户重定向到根地址([9.2.2 节](#requiring-the-right-user))。
![login page protected mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733305ceb780.png)图 9.6:访问受保护页面时看到的页面构思图
## 9.2.1 必须先登录
为了实现[图 9.6](#fig-login-page-protected-mockup) 中的转向功能,我们要在用户控制器中使用“事前过滤器”。事前过滤器通过 `before_action` 方法设定,指定在某个动作运行前调用一个方法。[[3](#fn-3)]为了实现要求用户先登录的限制,我们要定义一个名为 `logged_in_user` 的方法,然后使用 `before_action :logged_in_user` 调用这个方法,如[代码清单 9.12](#listing-authorize-before-filter) 所示。
##### 代码清单 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` 参数,指定只应用在 `edit` 和 `update` 动作上。
退出后再访问用户编辑页面 [/users/1/edit](http://localhost:3000/users/1/edit),可以看到这个事前过滤器的效果,如[图 9.7](#fig-protected-log-in) 所示。
![protected log in 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733305d1c57b.png)图 9.7:尝试访问受保护页面后显示的登录表单
如[代码清单 9.12](#listing-authorize-before-filter) 的标题所示,现在测试组件无法通过:
##### 代码清单 9.13:**RED**
```
$ bundle exec rake test
```
这是因为现在 `edit` 和 `update` 动作都需要用户先登录,而在相应的测试中没有已登录的用户。
所以,在测试访问 `edit` 和 `update` 动作之前,要先登入用户。这个操作可以通过 [8.4.6 节](chapter8.html#remember-tests)定义的 `log_in_as` 辅助方法([代码清单 8.50](chapter8.html#listing-test-helper-log-in))轻易实现,如[代码清单 9.14](#listing-edit-tests-logged-in) 所示。
##### 代码清单 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 节](#friendly-forwarding)我们要修改其中一个测试,在登录前访问编辑页面,如果把登录操作放在 `setup` 方法中就不能先访问其他页面了。)
现在,测试组件应该可以通过了:
##### 代码清单 9.15:**GREEN**
```
$ bundle exec rake test
```
测试组件虽然通过了,但是对事前过滤器的测试还没完,因为即便把安全防护去掉,测试也能通过。你可以把事前过滤器注释掉确认一下,如[代码清单 9.16](#listing-commented-out-before-filter) 所示。这可不妙。在测试组件能捕获的所有回归中,重大安全漏洞或许是最重要的。按照[代码清单 9.16](#listing-commented-out-before-filter) 的方式修改后,测试绝对不能通过。下面我们编写测试捕获这个问题。
##### 代码清单 9.16:注释掉事前过滤器,测试安全防护措施 GREEN
app/controllers/users_controller.rb
```
class UsersController < ApplicationController
# before_action :logged_in_user, only: [:edit, :update]
.
.
.
end
```
事前过滤器应用在指定的各个动作上,因此我们要在用户控制器的测试中编写相应的测试。我们计划使用正确的请求方法访问 `edit` 和 `update` 动作,然后确认把用户重定向到了登录地址。由[表 7.1](chapter7.html#table-restful-users) 得知,正确的请求方法分别是 `GET` 和 `PATCH`,所以在测试中要使用 `get` 和 `patch`,如[代码清单 9.17](#listing-edit-update-redirect-tests) 所示。
##### 代码清单 9.17:测试 `edit` 和 `update` 动作是受保护的 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
```
注意 `get` 和 `patch` 的参数:
```
get :edit, id: @user
```
和
```
patch :update, id: @user, user: { name: @user.name, email: @user.email }
```
这里使用了一个 Rails 约定:指定 `id: @user` 时,Rails 会自动使用 `@user.id`。在 `patch` 方法中还要指定一个 `user` 哈希,这样路由才能正常运行。(如果查看[第 2 章](chapter2.html#a-toy-app)为玩具应用生成的用户控制器测试,会看到上述代码。)
测试组件现在无法通过,和我们预期的一样。为了让测试通过,我们只需把事前过滤器的注释去掉,如[代码清单 9.18](#listing-uncommented-before-filter) 所示。
##### 代码清单 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 节](#requiring-logged-in-users)得知,测试组件很容易漏掉基本的安全缺陷,所以我们要使用测试驱动开发技术确保写出的代码能正确实现安全机制。为此,我们要在用户控制器的测试中添加一些测试,完善[代码清单 9.17](#listing-edit-update-redirect-tests)。
为了确保用户不能编辑其他用户的信息,我们需要登入第二个用户。所以,在用户固件文件中要再添加一个用户,如[代码清单 9.20](#listing-fixture-second-user) 所示。
##### 代码清单 9.20:在固件文件中添加第二个用户
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') %>
```
使用[代码清单 8.50](chapter8.html#listing-test-helper-log-in) 中定义的 `log_in_as` 方法,我们可以使用[代码清单 9.21](#listing-edit-update-wrong-user-tests) 中的代码测试 `edit` 和 `update` 动作。注意,这里没有重定向到登录地址,而是根地址,因为试图编辑其他用户资料的用户已经登录了。
##### 代码清单 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](#listing-correct-user-before-filter) 所示。注意,`correct_user` 中定义了 `@user` 变量,所以可以把 `edit` 和 `update` 动作中的 `@user` 赋值语句删掉。
##### 代码清单 9.22:保护 `edit` 和 `update` 动作的 `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](#listing-current-user-p) 所示。 然后我们就可以把
```
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](#listing-correct-user-before-filter-boolean) 所示。
##### 代码清单 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](#listing-edit-tests-logged-in) 中登录和访问编辑页面两个操作调换顺序即可。如[代码清单 9.26](#listing-friendly-forwarding-test) 所示,最终写出的测试先访问编辑页面,然后登录,最后确认把用户重定向到了编辑页面,而不是资料页面。
##### 代码清单 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 = "foo@bar.com"
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](#fn-4)]要转向用户真正想访问的页面,我们要在某个地方存储这个页面的地址,登录后再转向这个页面。我们要通过两个方法来实现这个过程,`store_location` 和 `redirect_back_or`,都在会话辅助方法模块中定义,如[代码清单 9.27](#listing-friendly-forwarding-code) 所示。
##### 代码清单 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 节](chapter8.html#the-log-in-method)登入用户的方式类似。[代码清单 9.27](#listing-friendly-forwarding-code) 还用到了 `request` 对象,获取请求页面的地址(`request.url`)。
在 `store_location` 方法中,把请求的地址存储在 `session[:forwarding_url]` 中,而且只在 `GET` 请求中才存储。这么做,当未登录的用户提交表单时,不会存储转向地址(这种情况虽然罕见,但在提交表单前,如果用户手动删除了会话,还是会发生的)。如果存储了,那么本来期望接收 `POST`、`PATCH` 或 `DELETE` 请求的动作实际收到的是 `GET` 请求,会导致错误。加上 `if request.get?` 能避免发生这种错误。[[5](#fn-5)]
要使用 `store_location`,我们要把它加入 `logged_in_user` 事前过滤器中,如[代码清单 9.28](#listing-add-store-location) 所示。
##### 代码清单 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](#listing-friendly-session-create) 所示。`redirect_back_or` 方法中使用了 `||` 操作符:
```
session[:forwarding_url] || default
```
如果 `session[:forwarding_url]` 的值不为 `nil`,就返回其中存储的值,否则返回默认的地址。注意,[代码清单 9.27](#listing-friendly-forwarding-code) 处理得很谨慎,删除了转向地址。如果不删除,后续登录会不断重定向到受保护的页面,用户只能关闭浏览器。(针对这个表现的测试留作[练习](#updating-showing-and-deleting-users-exercises)。)还要注意,即便先重定向了,还是会删除会话中的转向地址,因为除非明确使用了 `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](#listing-friendly-forwarding-test) 中针对友好转向的集成测试应该可以通过了。而且,基本的用户认证和页面保护机制也完成了。和之前一样,在继续之前,最好运行测试组件,确认可以通过:
##### 代码清单 9.30:**GREEN**
```
$ bundle exec rake test
```
';