Ruby's Louvre

每天学习一点点算法

导航

一个带完整的RBAC授权系统的rails应用(第一部分)

这是个简单的RBAC授权系统。其实说简单也不简单,只不过在誉满全球的rails框架下,一切都是变得特别轻松,当然这还要拜我们一会要用到的两个插件所托。废话少说,让我们说说我们的项目吧。这是一个类似维基的网站,你可以说它是一个简陋版,什么也好,它的开发代号已被定为Wiki了!项目要求也很简单,用户注册后就可以发布东西。但这好像用不了什么授权系统,因此我们还得把它搞复杂一点点,顺便一提,我们的授权系统是著名的RBAC(Role Based Access Control,基于角色的访问控制)。在变化多端的授权需求中,我们只需引进一个新的角色就可以摆平它们了。因为角色(Role)在RBAC,是作为一定数量的权限的集合的存在。加之,作为迅敏开发的代名词,rails早已提供了一大打这样的插件,因此我说简单也未尝不可。

好了,现在重新审视我们的项目,我们要求用户只有注册登录后才能发表文章,咳咳,这里,说正规点吧,能发表词条(lemma)。嗯,这里我们第一个资源lemma有了。当然,如果这些词条一多,就不好办,所以我们要引进标签云这个时髦的东西。为了鼓励用户创建词条或撰写高质量的词条,我们默认每个词条都拥有一定的积分,当用户完成该词条后就得到此积分。有些词条可能不太好写,我们就要相对应提高这些词条的含金量,这个虽然是词条的一个属性,但是我们不能允许用户通过编辑该属性来作弊。也为了防止出在荒秽不堪或其他反动违法的东西出在我们的网站,我们很有必须建立类似于BBS的版主制度,我称之为peace maker(秩序守护者),当然还要有无视一切规则的存在,把不称职的peace maker剔除出管理层,我称之为providence breake(违逆天意之人)。peace maker可以改变某个词条的积分,但不能为自己或别人加分,加分是由系统自动执行。作为维基,当然少不了共笔系统,我们可以让用户经过某一步骤成为别人创建的词条的共同创作者,但光是出勤不出力是无法获得积分的,必须进行修改才让加分,并且不让重分加分,因此我们必须在这里做一个开关。无疑,词条与用户是多对多关系,它们之间需要一个中间表,我们在那里做手脚就是!嗯,大体就是这样,其他边做边想,rails不需要像java那样一开始就一大打文档滴,这样就不是迅敏开发了!

我们的开发工具是netBeans,环境是window XP,ruby 1.8.6,rails 2.3.2,由于我们一会要用到2.3的新特征,所以请务必升级到rails2.3。那么,我们开始吧。

创建一个新项目。

指定数据库。

创建数据库。

接着是安装插件,第一个是著名的restful-authentication。我们需要用到它的登录验证,作为ruby社区最著名的认论railsforum.com中最长的讨论串所争吵出来的成果,它绝对值得信赖。第二个是declarative authorization,我们的授权系统基本就靠它了。它有什么优点?提供从视图到控制器到模型的授权控制,声明式调用授权规则,让授权逻辑与业务逻辑相分离,集中在一个配置文件用人性化的DSL编写授权规则,提供图形化界面让你理顺复杂的授权系统,等等。不过现在让我们打开命令台,定位到项目的根目录吧:

ruby script/plugin install git://github.com/technoweenie/restful-authentication.git
git clone git://github.com/stffn/declarative_authorization.git vendor/plugins/declarative_authorization

注:这里需要安装Git。自己google吧!

创建User模块与Session模块,为了直奔主题,我们不需要邮箱激活注册,自动登录等时髦的功能,因此根据安装插件时的提示,只要输入:

ruby script/generate authenticated user sessions
rake db:migrate

在RBAC授权模型中,User与Session既是代表两个资源,也是两个重要元素。在一般的授权系统中,User是作为权限的拥用者或主体而存在,在RBAC授权模型中,情况微有改变,精确来说,是权限的附加体或宿主。权限被移到我们一会儿提到的Role的身上。Session在RBAC中是比较隐晦的一个元素。不用说,Session首先为我们解决Web无状态的难题,这个工作主要由restful-authentication做了。第二,我们可以用session来承载所有通过授权的用户。这个非常重要,正如前面提到的那样,在RBAC授权模型中,User仅仅是纯粹的用户,权限已被剥离掉。User是不能与Privilege 直接关联,User 要拥有对某种资源进行操作,必须通过Role去关联。如果用户注册了,我们可以分配他一个叫user(注册用户)或wikier(维客)这样的角色,但如果他没有注册或没有登录,岂不是在重重关卡的网站中寸步难行,一个网站总有一些公开资源吧,这岂不是与我们的初衷相悖。因此我们要求尽管用户没有登录也要给他一个角色,这个工作由declarative_authorization负责。

打开users_controller与sessions_controller按要求把include AuthenticatedSystem注释掉。

修改application_controller,其中,current_user,logged_in?都是来自restful-authentication插件;为了安全,我们还用了filter_parameter_logging方法,把像password这样敏感的字段从日志中过滤掉,以防黑客偷窥到!

接着下来这一步非常重要,我们要向User表添加roles字段,亦即RBAC的Role元素,是权限分配的单位与载体,是用户(Users)与权限(Permissions)的代理层,用来解耦权限和用户的关系。我们也可以把Role独立出来一个模块,让User在同一时间中拥有多个角色,但现在我们一切从简,以减少数据库的JOIN操作。

ruby script/generate migration AddRolesToUsers roles:text
class AddRolesToUsers < ActiveRecord::Migration
  def self.up
    add_column :users, :roles, :string,:default => "wikier"
  end

  def self.down
    remove_column :users, :roles
  end
end

然后:

rake db:migrate

在开始做User模块之前,我们还是做一些锁碎的工作,这会让我们今后做出来的东西看起来赏心悦目。无错,就是规划工程的全局模板。在app/views/layouts/目录下添加application.html.erb,具体代码为:

它用到许多自定义方法,我们在app/helper目录新建layout_help.rb,把它们放到里面去:

module LayoutHelper
  def title(page_title, show_title = true)
    @content_for_title = page_title.to_s
    @show_title = show_title
  end

  def show_title?
    @show_title
  end

  def stylesheet(*args)
    content_for(:head) { stylesheet_link_tag(*args.map(&:to_s)) }
  end

  def javascript(*args)
    args = args.map { |arg| arg == :defaults ? arg : arg.to_s }
    content_for(:head) { javascript_include_tag(*args) }
  end
end

修改users_controller,完善其restful功能。其中用到before_filter,不单单是为了DRY,还考虑到后面的访问控制。修改后的代码如下:

class UsersController < ApplicationController
  before_filter :load_user, :only => [:show, :edit, :update, :destroy]
  before_filter :new_user, :only => :new
 
  def index
    @users = User.all
  end

  def new;end

  def create
    logout_keeping_session!
    @user = User.new(params[:user])
    success = @user && @user.save
    if success && @user.errors.empty?
      self.current_user = @user # !! now logged in
      redirect_to users_url
      flash[:notice] = "注册成功。"
    else
      flash[:error]  = "注册失败。"
      render :action => 'new'
    end
  end

  def show;end

  def edit;end

  def update
    if @user.update_attributes(params[:user])
      flash[:notice] = "更新用户成功。"
      redirect_to @user
    else
      render :action => 'edit'
    end
  end


  def destroy
    @user.destroy
    flash[:notice] = "删除用户成功。"
    redirect_to users_url
  end

  protected
  def load_user
    @user = User.find params[:id]
  end

  def new_user
    @user = User.new
  end
end

修改_user_bar.html.erb

<div id="user_bar">
  <% if logged_in? %>
    <%= link_to "注销",logout_path, :title => "log in"  %>
    <%= link_to "欢迎,"+current_user.login+"!",current_user %>
    <%= link_to "用户中心",current_user %>
    <%= link_to "用户列表", users_path if controller_name != "users" %>
  <% else %>
    <%= link_to "登录",  login_path, :title => "log in"  %>
    <%= link_to "注册", signup_path, :title => "create an account"  %>
  <% end %>
</div> 

添加users#index视图

<%- title "用户列表" -%>
<table>
  <tbody>
    <tr>
      <th>帐号:</th>
      <th>角色:</th>
      <th>操作:</th>
    </tr>
    <%- @users.each do |user| -%>
      <tr>
        <td><b><%= link_to user.login,user %></b></td>
        <td><%=  h user.roles.map(&:to_s) * ',' if user.roles %></td>
        <td>
          <%= link_to '编辑', [:edit,user]  %>
          <%= link_to '删除', user, :confirm => 'Are you sure?', :method => :delete  %>
        </td>
      </tr>
    <%- end -%>
  </tbody>
</table>

添加users#show视图

<% title "用户中心" %>
<table>
  <tbody>
    <tr><th>帐号:</th><td><%=link_to @user.login ,[:edit,@user] %></td></tr>
    <tr><th>邮箱:</th><td><%= @user.email %></td></tr>
    <tr><th>角色</th><td><%= h @user.roles.map(&:to_s) * ',' if @user.roles  %></td></tr>
  </tbody>
</table>
<%= link_to "返回",users_url %>

删除public的index.html,在routes.rb添加新的路由规则。

ActionController::Routing::Routes.draw do |map|
  map.logout '/logout', :controller => 'sessions', :action => 'destroy'
  map.login '/login', :controller => 'sessions', :action => 'new'
  map.register '/register', :controller => 'users', :action => 'create'
  map.signup '/signup', :controller => 'users', :action => 'new'
  map.resources :users
  map.resource :session
  map.root :users
  map.connect ':controller/:action/:id'
  map.connect ':controller/:action/:id.:format'
end

启动rails:

ruby script/server

修改users#new视图

<% title "新建用户" %>
<% @user.password = @user.password_confirmation = nil %>
<fieldset>
<%= error_messages_for :user %>
<% form_for @user do |f| -%>
  <p><%= f.label :login,'账号:' %><br/>
  <%= f.text_field :login %></p>

  <p><%= f.label :email, '邮箱:' %><br/>
  <%= f.text_field :email %></p>

  <p><%= f.label :password,'密码:' %><br/>
  <%= f.password_field :password %></p>

  <p><%= f.label :password_confirmation, '确认密码:' %><br/>
  <%= f.password_field :password_confirmation %></p>

  <p><%= f.submit '注册' %></p>
<% end %>
</fieldset>

好了,让我们新建edit.html.erb,处理roles这个重要字段吧。User模块通常是应用程序最重要的地方,编辑删除等操作一般只能由管理员来执行,用户就算能抵及这种敏感的地带,也一般是一个被阉割了的编辑界面。例如User模型中的login与password用来让用户登录程序,一般是不让修改,所以我们在表单中disabled了这个域,密码更是用了SHA1 加密,不可反向破译,干脆不提供修改。为了保护User模型大部分字段的安全,我们还用了“白名单”;这个restful_authentication插件已经为了我们做了,但roles字段是我们后来添加的,我们得动手修改一下。在User模型的 attr_accessible方法的最后添加上roles便是,即:

attr_accessible :login, :email, :name, :password, :password_confirmation,:roles

再在模型中添加以下代码(超重要)

 def role_symbols
    @role_symbols ||= (roles || []).map {|r| r.to_sym}
 end

新建users#edit视图

 <% title "编辑用户" %>
<fieldset>
  <% form_for(@user) do |f| %>
    <%= f.error_messages %>
    <p>
      <%= f.label :login,"帐号" %><br />
      <%= f.text_field :login,:disabled => true  %>
    </p>
    <p>
      <%= f.label :email,"邮箱" %><br />
      <%= f.text_field :email %>
    </p>
    <p>
      <%= f.label :roles,"角色" %><br />
      <%= f.select :roles, [['维客','wikier'],['秩序守护者','peace_maker'],['违逆天意之人','providence_breaker']], {:include_blank=>"请选择",:selected => 0} %>
    </p>
    <p>
      <%= f.submit "更新" %>
    </p>
  <% end %>
</fieldset>
<%= link_to "返回",url_for(:back) %>

到此,已经没有restful authentication什么事了,接着下来我们就可以专注于授权系统的开发。

打开environment.rb,添加:

config.load_once_paths += %W( #{RAILS_ROOT}/lib )

这是用来保证declarative_authorization插件不会加载出错。

打开application_controller,添加

  before_filter :set_current_user
  protected
  def set_current_user
    Authorization.current_user = current_user
  end

这个全局的前置过滤器,会把restful authentication的current_user代入到declarative authorization的同名方法current_user中。注意,这两个current_user都是Session对象,里面装载着我们的User对象,这就保证了我们多个页面中可以保存用户的信息。如果restful authentication的current_user为空,declarative authorization就会创建一个匿名的User对象,把它放进declarative authorization的current_user中。它拥有一个role_symbols属性(这就是为什么我们自定义的User模型要添加一个role_symbols方法),值为guest。而guest实质是declarative authorization为我们预设的默认角色,当请求和任何用户都没有关联或当一个用户没有任何角色时被调用。这样就实现了User与Role的关联了。

匿名User的源码,位于{RAILS_ROOT}/vendor\plugins\declarative_authorization\lib\declarative_authorization\authorization.rb里面

  # Represents a pseudo-user to facilitate guest users in applications
  class GuestUser
    attr_reader :role_symbols
    def initialize (roles = [:guest])
      @role_symbols = roles
    end
  end

打开users_controller,开启控制器级别的访问控制

class UsersController < ApplicationController
  before_filter :load_user, :only => [:show, :edit, :update, :destroy]
  before_filter :new_user, :only => :new
  filter_access_to :all
  filter_access_to [:show, :edit, :update], :attribute_check => true
 #………………
end

这里涉及到两个名词:粗粒度与细粒度

粗粒度:表示类别级,只检查对象的类别(Class),而不追究其某一个特定的实例。

细粒度:表示实例级,即需要考虑具体对象的实例(Instance)。比如,只有本人才能查看与编辑自己的用户资料。这个我们通过比较ID或某个特定的属性可以实现,因此:arrtibute_check => true(开启属性检查)就是为这准备的。

更绝的是我们还可以开启模型级别的访问控制,系统就会根据你的权限重写查询指令,防止数据被非法操纵。

require 'digest/sha1'

class User < ActiveRecord::Base
   using_access_control
#………………
end

不过,由于restful authentication插件的原因,当我们登录时,即访问sessions#create action时,restful authentication不但改变了Session对象的状态,还试图改变Session对象里面的User对象的状态,而我们一般不允许用户在未登录时改变User对象的状态(这相当于update了!),就会报错。

Authorization::NotAuthorized (No matching rules found for update for #<Authorization::GuestUser:0x4c1b7c8 @role_symbols=[:guest]> (roles [:guest], privileges [:update, :manage], context :users).):
##它要求我们的guest角色还要拥有update与mange的特权,我们一般只让guest在users资源上拥有read_index与create的特权。
##这属性于后面定义授权规则的内容,再往下看几段就明白了!

因此,我们还是放弃对User模型进行模型级别的授权控制吧。

此外,还有视图级别的访问控制,直接封杀页面上的链接。相对应,控制器级别的访问控制是封杀地址栏的url,模型级别的访问控制是来对待黑客的。前两个可能是误操作引起的,最后一种肯定来者不善。视图级别的访问控制一会儿再说,因为没有定制好授权规则,很难为大家给出一个直观的效果。

为了实现授权规则与业务逻辑相分离,declarative authorization指定把所有的授权规则都定义到一个配置文件中,并为我们提供专门的DSL来编写授权规则。首先,我们得把\vendor\plugins\declarative_authorization目录下的样本authorization_rules.dist.rb复制到config目录下,并更名为authorization_rules.rb。打开authorization_rules.rb,去掉注释,它应该是这个样子的:

authorization do
  role :guest do
  end
end

privileges do
  privilege :manage, :includes => [:create, :read, :update, :delete]
  privilege :read, :includes => [:index, :show]
  privilege :create, :includes => :new
  privilege :update, :includes => :edit
  privilege :delete, :includes => :destroy
end

整个代码分为两大块,授权块与特权块。

先看特权块,已给定了五个特权,四个简单的特权(:read,:update,:create,:delete)和一个复合特权(:manage)。include 后面的就是controller的action的名字。看下图,在rails的应用中,我们要对某个资源进行操作,必须经过action。而对于某些action,它是以另一个action为前提。如update action,必然先经过edit action,通过edit action渲染出视图,再有edit.html上的表单提交到到达update action。因此,那四个简单特权就构建index、show、edit、update和destroy这五个restful风格的action的访问控制上就行。如果不满意的话,也可以自定义action,再对其进行访问控制。

再看授权块,它是用来分配权限。注意,是权限(Permission),不是特权(Privilege)。就在我们刚说完的特权块中,那些特权都是光指对某些资源的控制器的action进行访问控制,但并没有指定具体的资源类别,这里我们就要把这些特权转化为权限了。既然说是权限,那么“限”谁呢?嗯,不难看出,是限制到某个角色,限制到某种资源,只有特定角色才能对特定资源进行特定操作,而且还要满足一定的要求(如控制器中开启属性检查,具体的检查方法就是在这里定义的)。一个流行公式是这样的:权限 =  资源 + 操作。这里得变通一下,操作都封装在特权里,一个特权可能拥有N个操作,而且还有属性检举查这样条件,因此 权限 = 资源 * 特权 if 条件。而一个角色或许能对多种资源进行操作,因此,从某种意义上讲,角色就是一定数量的权限的集合。了解完这些,让我们编写些授权规则吧。在默认情况下,已经为我们分配了一个guest角色。它将在请求和任何用户都没有关联或当一个用户没有任何角色时被调用。因此,如果我们的应用程序有一些公共页面,可以用guest来让那些没有登录的用户实现访问。现在我们的应用还很小很小,只有两个资源(User与Session)。为了让用户自由登录,我们不应该在Session中设置授权控制。User资源,我们打算让index可以让任何人访问,show与edit只有该用户才能访问到——这当然要求登录成为wikier,要求基本不能进行属性检查以判断其是否同一个人!

authorization do
  role :guest do
    has_permission_on :users, :to => [:read_index,:create]
  end

  role :wikier do
    includes :guest
    has_permission_on :users, :to => [:read_show,:update] do
      if_attribute :id => is {user.id}
    end
  end

  role :providence_breaker do
    has_permission_on :users, :to => :manage
  end
end

privileges do
  privilege :manage, :includes => [:create, :read, :update, :delete]
  privilege :read, :includes => [:index, :show]
  privilege :read_index, :includes => :index
  privilege :read_show, :includes => :show
  privilege :create, :includes => :new
  privilege :update, :includes => :edit
  privilege :delete, :includes => :destroy
end

重启我们的应用Wiki,毕竟修改了environment.rb。当我们没有登录时,测试其授权控制。

依照授权规则,我们没有登录时,我们被赋予guest的角色,能访问users#index,依次点击上面五个链接(用红圈圈起来了)。发现能登录与注册,最下面三个则呈现一个白色的页面:“You are not allowed to access this action.”

没有足够权限则导致访问失败。

如果你对这英文不满的话,可以在当前控制器添加一个protected action

class UsersController < ApplicationController  
#…………………………  
protected  
  
def permission_denied  
    respond_to do |format|  
      flash[:error] = '对不起,您没有足够的权限访问此页!'  
      format.html { redirect_to request.referer }  
      format.xml  { head :unauthorized }  
      format.js   { head :unauthorized }  
    end  
  end  
#…………………………  
end  

当我们注册后,马上就成为维客(wiki)。维客拥有游客一切权限(includes :guest)。嗯,我想,是时候讲解一下那些授权规则的意思了。declarative authorization插件提供了强大的DSL指令,让代码解读起来非常容易。例如:

has_permission_on :users, :to => [:read_index,:create]

结合上面的权限 = 资源 * 特权 if 条件 公式,此权限让角色在资源users中不受限制使用:read_index与:create的特权,再人性化点,让角色能够自由访问users#index(视图与对应action)与users#new(视图与对应action)与users#create(仅action)。注,为了细分权限的粗粒度,我们添加了两个新的特权:read_index与:read_show。

includes :guest

刚才说过,继承角色:guest的一切权限。

 has_permission_on :users, :to => [:read_show,:update] do
      if_attribute :id => is {user.id}
 end

必须满足两者的id相等的条件,才能访问users#show与users#edit与users#update。if_attribute :id 中的id,是你要访问的资源的id,具体到本例,就是users_controller用before_filter预先取出的那个@user。而{user.id}中的user永远是指current_user,也就是我们登录前插件分配给我们的匿名User对象,或者登录后我们自己的User对象。

当我们的角色为维客时,原来不能访问的两个地方(users#show与users#index)也能访问到了!但对于游客来说,这是一件非常郁闷的事——“不能访问到的地方也不应该让我看到嘛,让我点击进去看到这么扫兴的事!”因此我们要用到视图级别的访问控制了,让没有相应权限的人看不到它们,即使是查看源码也看不到。

这是游客看到的页面。

这是维客(普通注册用户)看到的页面。

这是管理员看到的页面。
到此为止,一个完整的授权系统就完成了,此插件更高级的应用将在下一部分介绍。
CSS附件:点我下载!

posted on 2009-07-01 10:25  司徒正美  阅读(9626)  评论(13编辑  收藏  举报