不使用第三方的 gem 实现一个权限系统
抽象
首先我们看一张图:
在代码中赋予用户权限的过程可以抽象为对某个 class 实例化的过程。这个 class 怎么构造,我们 在 编码 中讲解。
编码
建立 permit-demo 项目,
$ rails new permit-demo
创建相关的模型和表,
User
$ bundle exe rails g model User
# db/migrate/xxx_create_users.rb
class CreateUsers < ActiveRecord::Migration
def change
create_table :users do |t|
+ t.string :name, null: false
+ t.timestamps null: false
end
end
end
PermissionsUser
$ bundle exe rails g model PermissionsUser
注意: 因为 P 排在 U 前面,所以建立 PermissionsUser, 而不是 UsersPermission 作为 User 和 Permission 的关联 model
# db/migrate/xxx_create_permissions_users.rb
class CreatePermissionsUsers < ActiveRecord::Migration
def change
create_table :permissions_users do |t|
t.integer :user_id
t.integer :permission_id
t.timestamps null: false
end
end
end
Permission
$ bundle exe rails g model Permission
# db/migrate/xxx_create_permissions.rb
class CreatePermissions < ActiveRecord::Migration
def change
create_table :permissions do |t|
+ t.string :name, null: false
+ t.timestamps null: false
end
end
end
Post
$ bundle exe rails g model Post
# db/migrate/xxx_create_posts.rb
class CreatePosts < ActiveRecord::Migration
def change
create_table :posts do |t|
+ t.string :title
+ t.text :content
+ t.integer :user_id
t.timestamps null: false
end
end
end
不要忘记运行 migration,
$ bundle exe rake db:migrate
User, PermissionsUser 和 Permission 三者的关系
为了文章的简单起见,没有引入 Role 模型。
User,
# app/models/user.rb
class User < ActiveRecord::Base
+ has_and_belongs_to_many :permissions
+ validate :name, uniqueness: true
end
Permission,
# app/models/permission.rb
class Permission < ActiveRecord::Base
+ has_and_belongs_to_many :users
+ validates :name, uniqueness: true
end
现在我们开始实现前面提到的某个 class,
某个 class: Permit
# app/models/permit.rb
class Permit < BasicObject
def self.permission_mapper
# 默认情况下如果实例能够响应与权限同名的方法,就表示用户拥有此权限
default_m = ::Hash.new(->{true})
# 有些权限需要增加特定的参数和逻辑
custome_m = {
edit_post: ->(post){ post.user_id == @user.id}
}
default_m.merge(custome_m)
end
def initialize(user)
# 权限的实际拥有者
@user = user
# 这里没有使用常量去存储权限映射,是为了防止权限映射在程序员不知情的情况下被修改
@permission_mapper = ::Permit.permission_mapper
# 用于存储和权限同名方法的容器
@permission_mod = ::Module.new
load_permissions
end
def can?(name, *args)
__send__(name, *args)
end
private
# 继承于 BasicObject 的 Permit 没有 extend 方法,我们需要自己实现 extend 方法
def extend(mod)
mod.__send__(:extend_object, self)
end
def load_permissions
# 从数据库加载权限纪录, 并把权限纪录定义成方法
@user.permissions.each {|p|
k = p.name.to_sym
v = @permission_mapper[k]
@permission_mod.send(:define_method, k, &v)
}
# 为本实例扩展和权限同名的方法,不会影响到 Permit 的其他实例
extend @permission_mod
end
# 用户没有某个权限,调用与此权限同名的方法时,会返回 false,表示此权限不可访问
def method_missing(permission_name, *args)
false
end
end
创建一些种子数据
# db/seeds.rb
users = User.create([{name: 'user0'},
{name: 'user1'},
{name: 'admin0'},
{name: 'admin1'}
])
permissions = Permission.create([{name: 'login_admin'},
{name: 'create_post'},
{name: 'edit_post'},
{name: 'crud_users'},
{name: 'crud_posts'}
])
$ bundle exe rake db:seed
场景1: 用户没有 create_post 权限, 所以用户不能 create post
创建相关的 controller 和 view
首先配置路由,
# config/routes.rb
Rails.application.routes.draw do
+ resources :posts
end
建立 posts_controller,
$ bundle exe rails g controller posts
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
+ def index
+ @posts = Post.order('created_at desc').includes(:users)
+ end
+ def new
+ @post = Post.new
+ end
+ def create
+ end
end
# app/views/posts/new.html.erb
<h2>Create Post</h2>
<%= form_for @post do |f| %>
Title: <%= f.text_field :title %> <br/>
<br/>
Content: <%= f.text_area :content %> <br/>
<br/>
<%= f.submit 'Create' %>
<% end %>
在 application_controller.rb 里增加一些辅助方法,
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# Prevent CSRF attacks by raising an exception.
# For APIs, you may want to use :null_session instead.
protect_from_forgery with: :exception
# 可以在 view 里使用 :current_user 和 :permit? 方法
+ helper_method :current_user, :permit?
# 为了简单起见,我们没有实现一个完整的注册登录功能
# 通过在 params 里传递 user name 来识别当前用户
# 比如: http://localhost:3000/posts/new?current_user=user0
+ def current_user
+ @current_user ||= User.find_by(name: params[:current_user])
+ end
# 将 current_user 的 permit 缓存起来
# 并且如果当前用户为空则建立一个游客用户(User.new)
+ def current_permit
+ @current_permit ||= Permit.new(current_user || User.new)
+ end
# 判断当前登录用户是否具有名字为 name 的权限
+ def permit?(name, *args)
+ current_permit.can?(name, *args)
+ end
# fence 是篱笆的意思,我们可以通过篱笆保护我们的代码不被不认可的用户访问
# 如果代码里有多个 render 或者 redirect_to 我们需要使用
# fence(name, *args) {...} 的形式
+ def fence(name, *args, &block)
+ if !permit?(name, *args)
+ render status: 403, json: {code: 403, msg: 'forbidden'}.to_json
+ else
+ block.call if block_given?
+ end
+ end
end
启动 rails 服务,
$ bundle exe rails s
用浏览器访问 http://localhost:3000/posts/new?current_user=user0,
这说明我们的权限机制起作用了。
场景2: 用户有 create_post 权限, 所以用户可以创建 post
首先我们给用户 ‘user0’ 赋予 ‘create_post’ 的权限,
u = User.find_by(name: 'user0')
p = Permission.find_by(name: 'create_post')
u.permissions << p
u.save
用浏览器访问 http://localhost:3000/posts/new?current_user=user0,
我们再给 PostsController#create 方法增加 fence 代码,
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def create
+ fence :create_post do
+ @post = current_user.posts.create(post_params)
+ if @post.valid?
+ redirect_to action: 'index'
+ else
+ render :new
+ end
+ end
end
+ private
+ def post_params
+ params.require(:post).permit(:title, :content)
+ end
end
同时我们在 app/views/posts/new.html.erb 的表单里增加一个隐藏字段,用于提交当前用户,
# app/views/posts/new.html.erb
<%= form_for @post do |f| %>
+ <%= hidden_field_tag :current_user, params[:current_user] %>
<% end %>
最后我们增加 app/views/posts/index.html.erb 视图,
# app/views/posts/index.html.erb
<style>
table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
th, td {
padding: 5px;
}
</style>
<h2>Posts Index</h2>
<table>
<tr>
<th>标题</th>
<th>内容</th>
<th>作者</th>
<th>创建时间</th>
<th>编辑</th>
</tr>
<% @posts.each do |post| %>
<tr>
<td><%= post.title %></td>
<td><%= post.content %></td>
<td><%= post.user.name %></td>
<td><%= post.created_at.strftime('%Y%m%d %H:%M:%S')%></td>
<td></td>
</tr>
<% end %>
</table>
此时 Post 模型需要做一些改动,
# app/models/user.rb
class Post < ActiveRecord::Base
validates :title, :content, presence: true
+ belongs_to :user
end
点击按钮提交后,
场景3: 用户有 edit_post 权限, 但是 post 不属于用户,所以用户不可以编辑 post
首先我们修改下 app/views/posts/index.html.erb, 增加编辑链接,
<h2>Posts Index</h2>
<table>
<tr>
+ <th>ID</th>
<th>标题</th>
<th>内容</th>
<th>作者</th>
<th>创建时间</th>
+ <th>编辑</th>
</tr>
<% @posts.each do |post| %>
<tr>
+ <td><%= post.id %></td>
<td><%= post.title %></td>
<td><%= post.content %></td>
<td><%= post.user.name %></td>
<td><%= post.created_at.strftime('%Y%m%d %H:%M:%S')%></td>
<% # 只有 post 的创建者才可以看到编辑链接 %>
+ <% if permit?(:edit_post, post) %>
+ <td><%= link_to 'edit', "#{edit_post_url(post)}?current_user=#{params[:current_user]}" %></td>
+ <% end %>
</tr>
<% end %>
</table>
然后我们给 ‘user1’ 增加 ‘edit_post’ 权限,
u = User.find_by(name: 'user1')
p = Permission.find_by(name: 'edit_post')
u.permissions << p
u.save
我们访问 http://localhost:3000/posts?current_user=user1,
由于 index 页面所有的 post 都不是 user1 创建的,所以 user1 看不到编辑链接。edit_post 权限的逻辑是,
{edit_post: ->(post){ post.user_id == @user.id }}
我们修改下 posts_controller.rb 文件,并增加一个 app/views/posts/edit.html.erb 文件,
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
+ def edit
+ @post = Post.find(params[:id])
+ fence(:edit_post, @post) do
+ render :edit
+ end
+ end
end
# app/views/posts/edit.html.erb
<h2>Edit Post</h2>
<%= form_for @post do |f| %>
Title: <%= f.text_field :title %> <br/>
<br/>
Content: <%= f.text_area :content %> <br/>
<br/>
<%= hidden_field_tag :current_user, params[:current_user] %>
<%= f.submit 'Update' %>
<% end %>
我们访问 http://localhost:3000/posts/:id/edit?current_user=user1
请注意, :id 用实际存在的 post id 代替.
我们看到 user1 虽然有 edit_post 权限,但是 user1 不是其欲编辑的 post 的创建者, 其 仍然不能编辑 post.
场景4: 用户有 edit_post 权限, 并且 post 属于用户,所以用户可以编辑 post
我们给 user0 增加 edit_post 权限,
u = User.find_by(name: 'user0')
p = Permission.find_by(name: 'edit_post')
u.permissions << p
u.save
然后我们访问 http://localhost:3000/posts?current_user=user0,
我们看到 user0 创建的 post 其编辑链接对 user0 可见。
我们随意点击其中的一个编辑链接, 比如 http://localhost:3000/posts/6/edit?current_user=user0,
访问没有被拒绝。
小结
我们看到核心的代码即 Permit 类的代码去掉注释不超过 50 行代码, 我们就实现了一个灵活优雅的 权限系统,这确实是 Ruby 之美的一种体现。
代码在 permit-demo