RBAC(Role-Based Access Controll)基于角色的访问控制
在 ThinkPHP3.2.3 中 RBAC 类位于 /ThinkPHP/Library/Org/Util/Rbac.class.php
一、基本原理和数据库设计
在后台管理模块中,每个用户都属于相应的角色组,例如用户 admin 属于超级管理员角色组,用户 dee 属于普通管理员角色组,用户 jane 属于销售角色组,用户 nicole 属于财务角色组,每个角色组拥有的权限都不同。用户和角色组属于多对多的关系,即一个用户可能属于多个角色组,一个角色组有多个用户。
所有模块(例如 Home、Admin)、控制器(Controller)、方法(Action)都是节点,角色组是否能够访问这些节点的信息即是该角色组的权限信息。角色组和节点也是多对多的关系,即一个角色组可以访问多个节点,多个角色组都有可以访问同一个节点。
即 Rbac 功能需要 5 张数据表:用户表、角色表、用户-角色中间表、节点表、角色-节点中间表(权限表)。在 Rbac.class.php 中系统已经给出了其中的 4 张表:角色表(role)、用户-角色中间表(role_user)、节点表(node)、权限表(access):
4张表信息
需要自己创建一张用户表:
代码语言:javascript复制CREATE TABLE `crm_user` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`username` char(20) NOT NULL DEFAULT '',
`password` char(32) NOT NULL DEFAULT '',
`logintime` int(10) unsigned NOT NULL,
`loginip` varchar(30) NOT NULL,
`lock` tinyint(1) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8
数据模型如下:
基本的原理是,在配置文件中配置用户登录的识别号,这个识别号是用户的 id,在用户进行登陆的时候把 id 存储在 Session 中,同时根据 Session 保存的识别号通过连表查询获取该用户所属角色所能访问的节点信息并做判断。
二、配置选项
在 Rbac.class.php 中给出了需要配置的信息:
代码语言:javascript复制// 配置文件增加设置
// USER_AUTH_ON 是否需要认证
// USER_AUTH_TYPE 认证类型
// USER_AUTH_KEY 认证识别号
// REQUIRE_AUTH_MODULE 需要认证模块
// NOT_AUTH_MODULE 无需认证模块
// USER_AUTH_GATEWAY 认证网关
// RBAC_DB_DSN 数据库连接DSN
// RBAC_ROLE_TABLE 角色表名称
// RBAC_USER_TABLE 用户表名称
// RBAC_ACCESS_TABLE 权限表名称
// RBAC_NODE_TABLE 节点表名称
在模块配置文件 ./Application/Admin/Conf/config.php 中添加:
代码语言:javascript复制//Rbac配置
'RBAC_SUPERADMIN'=>'admin', //超级管理员名称
'ADMIN_AUTH_KEY'=>'superadmin', //超级管理员识别,存放在Session中
'USER_AUTH_ON'=>true, //是否开启权限认证
'USER_AUTH_TYPE'=>1, //验证类型 1-登陆时验证 2-实时验证
'USER_AUTH_KEY'=>'uid', //存储在session中的识别号
'NOT_AUTH_MODULE'=>'Index', //无需验证的控制器
'NOT_AUTH_ACTION'=>'add_role_handle', //无需验证的方法
'RBAC_ROLE_TABLE'=>'crm_role', //角色表名称
'RBAC_USER_TABLE'=>'crm_role_user', //角色与用户的中间表名称(注意)
'RBAC_ACCESS_TABLE'=>'crm_access', //权限表名称
'RBAC_NODE_TABLE'=>'crm_node', //节点表名称
三、RBAC 类源码分析
saveAccessList 方法:用于检测用户权限的方法,并保存到 Session 中
代码语言:javascript复制//用于检测用户权限的方法,并保存到Session中
static function saveAccessList($authId=null) {
if(null===$authId) $authId = $_SESSION[C('USER_AUTH_KEY')];
// 如果使用普通权限模式,保存当前用户的访问权限列表
// 对管理员开发所有权限
if(C('USER_AUTH_TYPE') !=2 && !$_SESSION[C('ADMIN_AUTH_KEY')] )
$_SESSION['_ACCESS_LIST'] = self::getAccessList($authId);
return ;
}
在登陆时调用,首先判断是否传递了用户识别号的参数,如果没有传递,就从 Session 中读取(配置文件中配置的用户识别号)对应的值;
如果配置的验证类型是登陆时验证(不是实时验证)同时该用户不是配置的超级管理员(Session 中不包含超级管理员识别号)时,就将调用 getAccessList 方法获取角色的权限。
getAccessList 方法
根据传递的用户识别号参数,通过连表查询(role、role_user、access、node)获得并返回该用户所属的角色组拥有的所有节点的权限 。
AccessDecision 方法
在 Common 控制器的 _iniatialize 方法中调用该方法。
如果当前访问的控制器和方法都不在不需要验证的节点信息(需要配置)中,那么调用该方法。
该方法首先调用 checkAccess 方法通过判断配置中是否开启 USER_AUTH_ON 来检查是否需要认证,如果开启了 USER_AUTH_ON ,则根据配置中需要验证和无需验证的模块的配置检查当前操作是否需要认证。
如果通过了 checkAccess 方法,则判断 Session 中由 saveAccessList 方法创建的_ACCESS_LIST 数组是否包含当前访问的模块、控制器和方法。超级管理员不由该方法进行认证。
四、开发实例
需要开发以下功能,顺序是:
①【添加角色 → 角色列表】 →
②【添加节点 → 节点列表】 →
③【权限列表 → 分配权限】 →
④【添加用户 → 用户列表 】 →
⑤【Rbac 配置】→
⑥【登陆】
在后台模块新建 Rbac 控制器:./Application/Admin/Controller/Rbac.class.php
① 角色
方法:
代码语言:javascript复制//角色列表
public function role_list() {
$this->role = M('role')->select();
$this->display();
}
//添加角色
public function add_role() {
$this->display();
}
//添加角色表单处理
public function add_role_handle() {
if(M('role')->add($_POST)) {
$this->success('添加成功',U('role_list','',''));
} else {
$this->error('添加失败');
}
}
视图:
添加角色(展示)./Application/Admin/View/Rbac_add_role.html
代码语言:javascript复制<!DOCTYPE html>
<html>
<head>
<title>添加角色</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="__PUBLIC__/Css/public.css"/>
</head>
<body>
<form action="{:U('add_role_handle','','')}" method="post">
<table class="table">
<tr>
<th colspan="2">添加角色:</th>
</tr>
<tr>
<td align="right">角色名称:</td>
<td>
<input type="text" name="name" />
</td>
</tr>
<tr>
<td align="right">角色描述:</td>
<td>
<input type="text" name="remark" />
</td>
</tr>
<tr>
<td align="right">是否开启:</td>
<td>
<input type="radio" name="status" value="1" checked = "checked" />开启
<input type="radio" name="status" value="0" />关闭
</td>
</tr>
<tr>
<td colspan="2" align="center">
<input type="submit" value="保存添加">
</td>
</tr>
</table>
</form>
</body>
</html>
角色列表 ./Application/Admin/View/Rbac_role_list.html
代码语言:javascript复制<!DOCTYPE html>
<html>
<head>
<title>TODO supply a title</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="__PUBLIC__/Css/Public.css">
</head>
<body>
<table class="table">
<tr>
<th>ID</th>
<th>角色名称</th>
<th>角色描述</th>
<th>开启状态</th>
<th>操作</th>
</tr>
<foreach name="role" item="v">
<tr>
<td>{$v.id}</td>
<td>{$v.name}</td>
<td>{$v.remark}</td>
<td>
<if condition="$v['status'] eq 1">开启<else />关闭</if>
</td>
<td>
<a href="{:U('access',array('rid'=>$v['id']),'')}">配置权限</a>
</td>
</tr>
</foreach>
</table>
</body>
</html>
② 节点
方法:
代码语言:javascript复制//节点列表
public function node_list() {
$field = array('id', 'name', 'title', 'pid');
$node = M('node')->field($field)->order('sort asc')->select();
$this->node = node_regroup($node);//p($this->node);die;
$this->display();
}
//添加节点
public function add_node() {
$this->pid = I('get.pid', 0, 'int');//如果没有传递的pid参数,则默认为0
$this->level = I('get.level', 1, 'int');//如果没有传递的level参数,则level是1,代表顶级(模块)
switch($this->level) {
case 1:
$this->type = '模块';
break;
case 2:
$this->type = '控制器';
break;
case 3:
$this->type = '方法';
break;
}
$this->display();
}
//添加节点表单处理
public function add_node_handle() {
if(M('node')->add($_POST)) {
$this->success('添加成功',U('node_list','',''));
} else {
$this->error('添加失败');
}
}
视图:
添加节点 ./Application/Admin/View/Rbac_add_node.html
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<link rel="stylesheet" href="__PUBLIC__/Css/public.css">
</head>
<body>
<form action="{:U('add_node_handle','','')}" method="post">
<table class="table">
<tr><th colspan="2">添加{$type}</th></tr>
<tr>
<td align="right">{$type}名称:</td>
<td>
<input type="text" name="name" />
</td>
</tr>
<tr>
<td align="right">节点描述:</td>
<td>
<input type="text" name="title">
</td>
</tr>
<tr>
<td align="right">是否开启:</td>
<td>
<input type="radio" name="status" value="1" checked="checked" />开启
<input type="radio" name="status" value="0" />关闭
</td>
</tr>
<tr>
<td align="right">排序:</td>
<td>
<input type="text" name="sort" />
</td>
</tr>
<tr>
<td colspan="2" align="center">
<input type="hidden" name="pid" value="{$pid}" />
<input type="hidden" name="level" value="{$level}" />
<input type="submit" value="添加{$type}" />
</td>
</tr>
</table>
</form>
</body>
</html>
默认情况下从后台左侧栏目进行节点添加,添加的是模块(例如 Home 模块,Admin 模块)
节点列表 ./Application/Admin/View/Rbac_node_list.html
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<link rel="stylesheet" href="__PUBLIC__/Css/public.css">
</head>
<body>
<div id="wrap">
<a href="{:U('add_node','','')}">添加模块</a>
<table class="table">
<foreach name="node" item="app">
<div class="app">
<p>
<strong>{$app.title}</strong>
<a href="{:U('add_node',array('pid'=>$app['id'],'level'=>2),'')}">
[添加控制器]
</a>
<a href="">[修改]</a>
<a href="">[删除]</a>
</p>
<foreach name="app.child" item="controller">
<dl>
<dt>
-
<strong>{$controller.title}</strong>
<a href="{:U('add_node',array('pid'=>$controller['id'],'level'=>3),'')}">
[添加方法]
</a>
<a href="">[修改]</a>
<a href="">[删除]</a>
</dt>
<foreach name="controller.child" item="method">
<div>
-
<strong>{$method.title}</strong>
<a href="">[修改]</a>
<a href="">[删除]</a>
</div>
</foreach>
</dl>
</foreach>
</div>
</foreach>
</table>
</div>
</body>
</html>
此时可以通过 GET 传递 pid 和 level 添加控制器节点和方法节点,例如
在节点列表的方法中,需要用到递归重组节点信息,把在数据库 node 表中存储的节点信息按照层级(模块-控制器-方法的的层级)重新组合,结构类似于:
代码语言:javascript复制Array
(
[0] => Array
(
[id] => 6
[name] => Home
[title] => 前台应用
[pid] => 0
[child] => Array
(
)
)
[1] => Array
(
[id] => 1
[name] => Admin
[title] => 后台应用
[pid] => 0
[child] => Array
(
[0] => Array
(
[id] => 3
[name] => Index
[title] => 后台首页
[pid] => 1
[child] => Array
(
[0] => Array
(
[id] => 17
[name] => index
[title] => 后台首页
[pid] => 3
[child] => Array
(
)
)
)
)
[1] => Array
(
[id] => 4
[name] => ArticleManage
[title] => 文章管理
[pid] => 1
[child] => Array
(
[0] => Array
(
[id] => 8
[name] => index
[title] => 文章列表
[pid] => 4
[child] => Array
(
)
)
)
)
[2] => Array
(
[id] => 5
[name] => Rbac
[title] => 权限管理
[pid] => 1
[child] => Array
(
[0] => Array
(
[id] => 15
[name] => user_list
[title] => 用户列表
[pid] => 5
[child] => Array
(
)
)
[1] => Array
(
[id] => 14
[name] => add_node_handle
[title] => 添加节点表单处理
[pid] => 5
[child] => Array
(
)
)
[2] => Array
(
[id] => 13
[name] => add_node
[title] => 添加节点
[pid] => 5
[child] => Array
(
)
)
[3] => Array
(
[id] => 12
[name] => node_list
[title] => 节点列表
[pid] => 5
[child] => Array
(
)
)
[4] => Array
(
[id] => 11
[name] => add_role_handle
[title] => 添加角色表单处理
[pid] => 5
[child] => Array
(
)
)
[5] => Array
(
[id] => 10
[name] => add_role
[title] => 添加角色
[pid] => 5
[child] => Array
(
)
)
[6] => Array
(
[id] => 9
[name] => role_list
[title] => 角色列表
[pid] => 5
[child] => Array
(
)
)
)
)
)
)
)
在 ./Application/Admin/Common/function.php 中创建方法 node_group
代码语言:javascript复制<?php
/*
* 递归重组节点信息
* @param $node 要重组的节点数组
* @param $pid 父级ID
* @return
*/
function node_regroup($node, $pid = 0, $access = null) {
$arr = array();
foreach($node as $v) {
if(is_array($access)) {
$v['access'] = in_array($v['id'], $access) ? 1 : 0;//判断是否已经拥有权限
}
if($v['pid'] == $pid) {
$v['child'] = node_regroup($node, $v['id'], $access);
$arr[] = $v;
}
}p($arr);
return $arr;
}
③ 权限
方法:
代码语言:javascript复制//配置权限
public function access() {
$rid = I('get.rid', 0, 'int');//角色id
$field = array('id', 'name', 'title', 'pid');
$node = M('node')->field($field)->order('sort asc')->select();
$access = M('access')->where('role_id = '.$rid)->getField('node_id', true);//已经拥有的权限
$node = node_regroup($node, 0, $access); //递归节点
$this->rid = $rid;
$this->node = $node;
$this->display();
}
//权限配置的表单提交处理
public function access_handle() {
$rid = I('rid', 0, 'int');
$db = M('access');
$db->where('role_id = '.$rid)->delete();//删除原有权限
$data = array();
if(!empty($_POST['access'])) {
foreach($_POST['access'] as $v) {
$tmp = explode('_', $v);
$data[] = array(
'role_id'=>$rid,
'node_id'=>$tmp[0],
'level'=>$tmp[1]
);
}
if($db->addAll($data)) { //写入新权限
$this->success('分配权限成功', U('role_list','',''));
} else {
$this->error('分配权限失败');
}
}
}
视图:
从角色列表每一栏后面的“配置权限”点击进入
./Application/Admin/View/Rbac_access
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<link rel="stylesheet" href="__PUBLIC__/Css/public.css">
<link rel="stylesheet" href="__PUBLIC__/Css/node.css">
<script src="__PUBLIC__/Js/jquery-1.7.2.min.js"></script>
</head>
<body>
<div id="wrap">
<a id="return" href="{:U('role_list','','')}">返回</a>
<form action="{:U('access_handle')}" method="post">
<table class="table">
<foreach name="node" item="app">
<div class="app">
<p>
<strong>{$app.title}</strong>
<input type="checkbox" name="access[]" value="{$app.id}_1" level="1" <if condition="$app['access'] eq 1">checked="checked"</if>>
</p>
<foreach name="app.child" item="controller">
<div class="app_child">
<dl class="controller">
<dt>
<strong>{$controller.title}</strong>
<input type="checkbox" name="access[]" value="{$controller.id}_2" level="2" <if condition="$controller['access'] eq 1">checked="checked"</if>>
</dt>
</dl>
<foreach name="controller.child" item="method">
<span class="method">
<strong>{$method.title}</strong>
<input type="checkbox" name="access[]" value="{$method.id}_3" level="3" <if condition="$method['access'] eq 1">checked="checked"</if>>
</span>
</foreach>
<div style="clear:both"></div>
</div>
</foreach>
</div>
</foreach>
</table>
<input type="submit" value="提交" style="display: block; margin:0 auto; cursor:pointer">
<input type="hidden" name="rid" value="{$rid}">
</form>
</div>
</body>
<script>
$(function(){
$('input[level=1]').click(function(){
var inputs = $(this).parents('.app').find('input');
$(this).prop('checked') == true ? inputs.prop('checked', true) : inputs.prop('checked', false);
});
$('input[level=2]').click(function(){
var inputs = $(this).parents('.app_child').find('input');
$(this).prop('checked') == true ? inputs.prop('checked', true) : inputs.prop('checked', false);
});
});
</script>
</html>
④ 用户
方法:
代码语言:javascript复制//添加用户
function add_user() {
$this->role = M('role')->select();
$this->display();
}
//添加用户的表单提交处理
public function add_user_handle() {
$user = array(
'username'=>I('post.username', ''),
'password'=>I('post.password','','md5'),
);
$uid = M('user')->add($user);
$rold = array();
if($uid) {
foreach($_POST['role_id'] as $v) {
$role[] = array(
'role_id'=>$v,
'user_id'=>$uid
);
}
M('role_user')->addAll($role);
$this->success('添加成功', U('user_list','',''));
} else {
$this->error('添加失败');
}
}
//用户列表
public function user_list() {
$this->user = D('UserRelation')->field('password', true)->relation(true)->select();
//P(D('UserRelation')->getLastSql());
//p($this->user);
//die;
$this->display();
}
关联模型
./Application/Admin/Model/UserRelationModel.class.php
代码语言:javascript复制<?php
/*
* 用户与角色关联模型
*/
namespace AdminModel;
use ThinkModelRelationModel;
class UserRelationModel extends RelationModel{
//定义主表名称
protected $tableName = 'user';
//定义关联关系
protected $_link = array(
'role'=>array(
'mapping_type'=>self::MANY_TO_MANY,
'foreign_key'=>'user_id',//指定主表外键
'relation_key'=>'role_id',//指定关联表外键
'relation_table'=>'crm_role_user',//指定中间表名称
'mapping_fields'=>'id,name,remark'//读取的字段
),
);
}
通过使用关联模型,使需要输出至 user_list 页面的数组以如下方式输出:
代码语言:javascript复制Array
(
[0] => Array
(
[id] => 1
[username] => admin
[logintime] => 1454222361
[loginip] => 127.0.0.1
[lock] => 0
[role] => Array
(
)
)
[1] => Array
(
[id] => 2
[username] => dee
[logintime] => 1454140261
[loginip] => 127.0.0.1
[lock] => 0
[role] => Array
(
[0] => Array
(
[id] => 2
[name] => Editor
[remark] => 网站编辑
)
)
)
)
模型查询的 SQL 语句类似于:
代码语言:javascript复制SELECT b.id,name,remark FROM crm_role_user AS a, crm_role AS b WHERE a.role_id = b.id AND a. user_id='2'
视图:
添加用户 ./Application/Admin/View/Rbac_add_user.html
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<css file="__PUBLIC__/Css/public.css" />
<js file="__PUBLIC__/Js/jquery-1.7.2.min.js" />
<style>
.add-role{
display:inline-block;
width:100px;
height:26px;
line-height: 26px;
text-align: center;
border: 1px solid #ccc;
border-radius: 4px;
margin-left: 20px;
cursor:pointer;
}
</style>
</head>
<body>
<form action="{:U('add_user_handle','','')}" method="post">
<table class="table">
<tr>
<th colspan="2">添加用户</th>
</tr>
<tr>
<td align="right" width="40%">用户账号</td>
<td>
<input type="text" name="username">
</td>
</tr>
<tr>
<td align="right">密码:</td>
<td>
<input type="password" name="password">
</td>
</tr>
<tr>
<td align="right">所属角色:</td>
<td>
<select name="role_id[]" id="">
<option value="">请选择角色</option>
<foreach name="role" item="v">
<option value="{$v.id}">{$v.name}({$v.remark})</option>
</foreach>
</select>
<span class="add-role">添加一个角色</span>
</td>
</tr>
<tr id="last">
<td colspan="2" align="center">
<input type="submit" value="保存">
</td>
</tr>
</table>
</form>
</body>
<script>
$(function(){
$(".add-role").click(function(){
var obj = $(this).parents("tr").clone();
obj.find(".add-role").remove();
$("#last").before(obj);
});
});
</script>
</html>
用户列表 ./Application/Admin/View/Rbac_user_list.html
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<link rel="stylesheet" href="__PUBLIC__/Css/public.css">
</head>
<body>
<table class="table">
<tr>
<th>ID</th>
<th>用户名</th>
<th>上一次登陆时间</th>
<th>上一次登陆IP</th>
<th>用户所属组</th>
<th>是否锁定</th>
<th>操作</th>
</tr>
<foreach name="user" item="v">
<tr>
<td>{$v.id}</td>
<td>{$v.username}</td>
<td>{$v.logintime|date="Y-m-d",###}</td>
<td>{$v.loginip}</td>
<td>
<if condition="$v['username'] eq C('RBAC_SUPERADMIN')">
超级管理员
<else/>
<ul>
<foreach name="v.role" item="value">
<li>{$value.name}({$value.remark})</li>
</foreach>
</ul>
</if>
</td>
<td>
<if condition="$v['lock'] eq 1">锁定<else/>正常</if>
</td>
<td><a href="">锁定</a></td>
</tr>
</foreach>
</table>
</body>
</html>
④ 登陆和验证
登陆:
在 ./Application/Admin/Controller/LoginController.class.php 的 login 方法中添加:
代码语言:javascript复制//判断是否是超级管理员
if($_SESSION['user']['username'] == C('RBAC_SUPERADMIN')) {
session(c('ADMIN_AUTH_KEY'),true);
}
//读取用户权限
RBAC::saveAccessList();
在 Common 控制器 (./Application/Admin/Controller/Common.class.php)的 _initialize 方法中添加验证:
代码语言:javascript复制$notAuth = in_array(CONTROLLER_NAME,explode(',',C('NOT_AUTH_MODULE')))
|| in_array(ACTION_NAME,C('NOT_AUTH_ACTION'));
if(C('USER_AUTH_TYPE') && !$notAuth) {
//var_dump(Rbac::AccessDecision());
Rbac::AccessDecision() || $this->error('没有访问权限',U('Admin/Index/index'));
}
Index 控制器中的登陆 longin、退出 loginout 等方法不需要权限认证,可以把 Index 控制器加入到无需认证的控制器中,一些表单提交处理的方法可以加入到无需认证的方法中。