在Rails中使用Pundit进行授权 - 配方和最佳实践
涉及用户管理的Web应用有两个部分,即认证和授权。没有认证就没有授权,因为除非我们首先知道你是谁,否则我们无法确定你能做什么。
手工推出用户认证是一项繁琐的工作,大多数Rails社区已经将认证工作委托给了Devise等很酷的工具。
所以在这篇文章中,我们将讨论另一个很棒的工具,你可以利用它来委托授权。这就是Pundit。
那么什么是Pundit呢?
当需要限制某些用户对你的应用程序的访问时,基于角色的授权就会发挥作用。这就是你可以利用Pundit的地方。Pundit帮助我们定义PORC策略--Plain Old Ruby Classes--这意味着该类不继承其他类,也不包括框架中的其他模块。因此,这使得代码非常容易理解。
我们仍然需要为我们的用户定义角色。但现在的好处是,我们可以保持我们的控制器和模型的独立性。你定义的策略从模型/控制器中拿走了代码的复杂性,否则这些代码会被用来决定对某个特定页面的访问。让我们的生活变得简单,你觉得呢?
设置Pundit
在你的应用程序中设置它是非常容易的。创业板的文档有很好的解释。
尽管如此,让我在这里把它写下来:
- 将
gem 'pundit'添加到你的Gemfile。 - 在你的应用程序控制器
include Pundit。 - 运行命令
bundle install。 - 可以选择运行
rails g pundit:install,它将设置一个带有一些有用的默认值的应用程序策略。
策略将被定义在app/policies/ 目录中。不要忘记重启Rails服务器,这样Rails就可以接收你在那里定义的新类。
理解策略
就像前面提到的,策略是PORC,它包含了对某个特定页面的授权。
让我们看一下从文档中取出的一个策略类的例子:
class PostPolicy
attr_reader :user, :post
def initialize(user, post)
@user = user
@post = post
end
# CRUD actions
def update?
user.admin? or not post.published?
end
end
这是一个定义的策略,如果用户是管理员,或者帖子没有发表,则对更新帖子施加限制。
策略类的特点
- 政策名称应该以它所对应的模型名称开头,并且应该总是以
Policy为后缀。所以在上面的例子中--PostPolicy将是Post模型的策略。 - 策略的初始化方法将需要实例变量user和模型被授权。顺便说一句,如果模型只是我们想要授权的其他对象,我们也可以做到。例如,一个服务或表单对象,它有条件要检查,以便执行控制器动作。
- 方法名称应该与控制器动作相对应,后缀为
?。因此,对于控制器动作,如new,create,edit等,需要定义策略方法new?,create?,edit?等。
注意:如果控制器不能访问current_user方法,我们可以定义一个pundit_user方法,它将被使用:
def pundit_user
User.find_by_other_means
end
如果我们运行生成器rails g pundit:install ,我们可以进一步抽象这个策略,它可以创建一个带有控制器动作默认值的应用程序策略,并负责初始化部分。这可以被其他策略所继承:
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
def show?
false
end
def create?
false
end
def new?
create?
end
def update?
false
end
def edit?
update?
end
def destroy?
false
end
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
scope
end
end
end
但是等一下,在生成的ApplicationPolicy? ,一个类Scope 做什么。这就是使Pundit更加强大的原因,我们很快就会了解到。
有了这个生成的基本策略,我们可以把PostPolicy 简化为:
class PostPolicy < ApplicationPolicy
# Here we are overriding :update? inherited from ApplicationPolicy
def update?
user.admin? or not record.published?
end
end
有了这个设置,让我们看看在控制器层面有什么变化:
class PostController < ApplicationController
def update
post = current_user.posts.find(params[:id])
authorize post
if post.update(post_params)
redirect_to post
else
render :edit
end
end
# other controller actions
end
有了这段代码,控制器的更新动作就会被授权,我们在这里调用的authorize 方法将检索给定记录的策略,用记录和当前用户对其进行初始化,最后如果用户没有被授权执行给定的动作,就会出现错误。
理解作用域
作用域就像使用你为一个模型定义的作用域一样。但在我们的案例中,这些作用域是在策略中针对特定控制器动作的用户角色进行的。作用域被用来检索我们所拥有的记录的子集。例如,在一个博客应用程序中,非管理员用户应该被限制只能看到已经发布但不在草稿状态的帖子。我看到你已经想象到控制器和模型变得更薄了。
让我们重新设计一下我们的帖子策略:
class PostPolicy < ApplicationPolicy
# Inheriting from the application policy scope generated by the generator
class Scope < Scope
def resolve
if user.admin?
scope.all
else
scope.where(published: true)
end
end
end
def update?
user.admin? or not record.published?
end
end
在这里,我们已经创建了一个类,它将根据用户的角色来确定帖子的范围。为了在我们的控制器中使用它,我们只需要使用方法policy_scope 。
范围类的特点
- 它们也是PORC,将被嵌套在策略类中。
- 它需要初始化一个用户和一个范围,这个范围可以是ActiveRecord类或ActiveRecord::Relation。
- 它需要定义一个解析方法,根据用户的角色来确定范围。
所以现在,我们修改我们的Post控制器的index ,就像:
class PostController < ApplicationController
def new
# code to render new view
end
def create
# code to create
end
def edit
# code to render edit
end
def update
post = current_user.posts.find(params[:id])
authorize post
if post.update(post_params)
redirect_to post
else
render :edit
end
end
def show
# code to render show
end
def index
policies = policy_scope(Post)
# code to render index
end
end
除非用户是管理员,否则索引动作将只显示已发布的帖子。
可以利用pundit的良好做法
保持授权的明确性
与其让授权或范围隐性化,不如让它明确化。我们可以在ApplicationController ,这样如果我们忘记在控制器中添加authorize 或policy_scope ,就会产生异常:
class ApplicationController < ActionController::Base
include Pundit
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
end
但是,我们仍然可以使用skip_authorization 或skip_policy_scope ,在这种情况下,你不想禁用整个动作的验证。
保持一个封闭的系统
如果我们正在使用一个基础策略,如ApplicationPolicy 。如果有未经认证的用户通过,我们可以优雅地失败:
class ApplicationPolicy
def initialize(user, record)
raise Pundit::NotAuthorizedError, "must be logged in" unless user
@user = user
@record = record
end
end
处理授权中的错误
由于Pundit::NotAuthorizedError ,如果没有授权,我们就需要优雅地处理它。这可以通过使用rescue_from 指令来实现,Pundit::NotAuthorizedError ,然后传入一个方法来处理这个异常。
我们还可以更进一步,根据哪个策略的动作没有被授权来定制错误信息:
class ApplicationController < ActionController::Base
protect_from_forgery
include Pundit
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def user_not_authorized
policy_name = exception.policy.class.to_s.underscore
flash[:error] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default
redirect_to root_path
end
end
而且你可以让你的locale文件变成这样:
en:
pundit:
default: 'You cannot perform this action.'
post_policy:
update?: 'You cannot edit this post!'
create?: 'You cannot create posts!'
这是一种为授权设置错误信息的方法,因为这里我们利用了NotAuthorizedError 提供的信息,即什么查询(如:create?),什么记录(如Post的实例),以及什么策略(如PostPolicy的实例)导致错误发生。最终,这取决于你如何组织你的locale文件。另外,我们也可以通过配置在application.rb
config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :forbidden
用多个角色扩展策略
经常会有这样的要求:一个特定的CRUD动作的授权在多个角色上有所不同。在我们的例子中,例如,还有一个角色 "高级"。现在有一些帖子只能由高级用户和管理员查看。不用担心,只需创建一个新的'高级'角色并更新我们的PostPolicy,如下所示:
class PostPolicy < ApplicationPolicy
# Inheriting from the application policy scope generated by the generator
class Scope < Scope
def resolve
if user.admin?
scope.all
elsif user.premium?
scope.where(published: true)
else
scope.where(published: true, premium: false)
end
end
end
def update?
user.admin? || !record.published?
end
def show?
return user.premium? || user.admin? if record.premium?
true
end
end
有了上述变化,现在普通用户不能在索引视图列表中查看高级帖子,因为我们正在对其进行扫描,而且我们正在授权展示页面,不允许非高级用户查看高级帖子内容。很整洁,不是吗?我们不再需要将应用程序的执行流程委托给模型或控制器,而是让Pundit完成所有的重任。
这让我们在控制基于角色的访问方面有了精细的粒度,现在我们了解了Pundit的结构和我们需要遵循的惯例,编写授权代码就变得直观了。瘦小的控制器和瘦小的模型太棒了