简介
RBAC:role base access control,基于角色的用户访问权限,就是权限分配给角色,角色又分配给用户
即:
一个用户对应一个角色,一个角色对应多个权限/一个用户对应用户组,一个用户组对应多个权限
数据表设计
注意:
这里只说一些关键的字段,其余字段自己扩展
用户表
id
账号
密码
角色id
角色表
id
角色名称
角色与权限中间表
角色id
权限id
权限表(节点表)
id
权限名称
权限规则
一般是4个表
5张表的多了一个用户与角色中间表
用户与角色中间表
用户id
角色id
Laravel创建表
创建迁移文件和模型
注意:
中间表不需要创建模型
php artisan make:model Models/Role -m
php artisan make:model Models/Node -m
php artisan make:migration role_node
修改用户表迁移文件,添加角色id
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('role_id')->default(0)->comment('角色id');
$table->string('username', 50)->comment('账号');
$table->string('truename', 20)->default('未知')->comment('账号');
$table->string('password', 255)->comment('密码');
$table->string('email', 50)->default('')->comment('邮箱');
$table->string('phone', 15)->default('')->comment('手机号码');
$table->enum('sex', ['男', '女', '保密'])->default('保密')->comment('性别');
$table->char('last_ip', 15)->default('')->comment('登录IP');
$table->timestamps();
// 软删除 生成字段 deleted_at
$table->softDeletes();
});
}
角色表
public function up()
{
Schema::create('roles', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('name', 20)->comment('角色名称');
$table->timestamps();
// 软删除
$table->softDeletes();
});
}
节点表、权限表
public function up()
{
Schema::create('nodes', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('name', 50)->comment('节点名称');
$table->string('route_name', 100)->default('')->comment('路由别名、权限认证标识');
$table->unsignedInteger('pid')->default(0)->comment('上级id号');
$table->enum('is_menu', ['0', '1'])->default(0)->comment('是否为菜单.0:否 1:是');
$table->timestamps();
// 软删除
$table->softDeletes();
});
}
角色和节点中间表
public function up()
{
Schema::create('role_node', function (Blueprint $table) {
// 角色id
$table->unsignedBigInteger('role_id')->default(0)->comment('角色id');
// 节点id
$table->unsignedBigInteger('node_id')->default(0)->comment('节点id');
});
}
这里因为修改了用户表的结构,所以使用以下命令进行执行数据迁移
php artisan make:migrate:refresh --seed
权限管理
权限列表
创建一个关联模型的资源控制器和资源路由
php artisan make:controller Admin/NodeController -r -m Models/Node
或
php artisan make:controller Admin/NodeController --resource
定义资源路由
可以使用查看路由列表的命令去修改对应的前缀是否正确
php artisan route:list
Route::resource('规则前缀', '控制器名称不要方法');
// 定义路由前缀,一般在路由分组中统一定义路由前缀
['as' => 'admin.']
Route::group(['middleware' => ['ckadmin'], 'as' => 'admin.'], function () {
// 后台别的路由 ...
}
添加节点
使用vue来进行表单数据绑定
使用v-mode.lazy
来进行延迟数据绑定
<div class="row cl">
<label class="form-label col-xs-4 col-sm-3"><span class="c-red">*</span>节点名称:</label>
<div class="formControls col-xs-8 col-sm-9">
<input type="text" class="input-text" id="name" name="name" autocomplete="off"
v-model.lazy="info.name">
</div>
</div>
表单提交数据,使用
vue
的事件来实现:@submit
为了阻止默认的提交事件,
@submit.prevent=dopost
dopost
不加括号和加括号是有区别的,不加括号,会自动加上事件参数,加上了,需要手动自己写
<form action="{{ route('admin.node.store') }}" method="post" class="form form-horizontal" @submit.prevent="dopost">
<div class="row cl">
<label class="form-label col-xs-4 col-sm-3">
<span class="c-red">*</span>顶级节点:
</label>
<div class="formControls col-xs-8 col-sm-9">
<span class="select-box">
<select name="pid" id="pid" class="select">
<option value="0">==顶级==</option>
@foreach ($data as $item)
<option value="{{ $item->id }}">{{ $item->name }}</option>
@endforeach
</select>
</span>
</div>
</div>
<div class="row cl">
<label class="form-label col-xs-4 col-sm-3"><span class="c-red">*</span>节点名称:</label>
<div class="formControls col-xs-8 col-sm-9">
<input type="text" class="input-text" id="name" name="name" autocomplete="off"
v-model.lazy="info.name">
</div>
</div>
<div class="row cl">
<label class="form-label col-xs-4 col-sm-3">路由别名:</label>
<div class="formControls col-xs-8 col-sm-9">
<input type="text" class="input-text" id="route_name" name="route_name" autocomplete="off"
v-model="info.route_name">
</div>
</div>
<div class="row cl">
<label class="form-label col-xs-4 col-sm-3"><span class="c-red">*</span>是否为菜单:</label>
<div class="formControls col-xs-8 col-sm-9 skin-minimal">
<div class="radio-box">
<input name="is_menu" type="radio" value="1" v-model.lazy="info.is_menu" id="sex-1" checked>
<label for="sex-1">是</label>
</div>
<div class="radio-box">
<input type="radio" id="sex-2" value="0" v-model="info.is_menu" name="is_menu">
<label for="sex-2">否</label>
</div>
</div>
</div>
<div class="row cl">
<div class="col-xs-8 col-sm-9 col-xs-offset-4 col-sm-offset-3">
<input class="btn btn-primary radius" type="submit" value="添加节点">
</div>
</div>
</form>
vue处理表单数据代码
new Vue({
el: '.page-container',
data() {
return {
info: {
_token: "{{ csrf_token() }}",
name: '',
route_name: '',
is_menu: 0,
pid: 0
}
}
},
methods: {
// dopost(event) {
// // 获取表单的action
// // console.log(event.target.action);
// // console.log(event.target.getAttribute('action')) // 标准写法
// let url = event.target.action;
// let json = $.post(url, this.info).then(res => {
// console.log(res);
// })
// }
// 把异步变同步 es7 async await
async dopost(event) {
let url = event.target.action;
let {status, msg} = await $.post(url, this.info);
if (status === 0) {
location.href = "{{ route('admin.node.index') }}";
} else {
layer.msg(msg, {icon: 2});
}
}
}
});
这里有个插曲,因为数据表设计的路由别名没有设置可以为空,添加的时候会报错,所以我们在不改变数据表的时候,使用模型的修改其来给路由别名添加一个空值
修改器在修改和添加数据的时候生效
修改器命名原则:set字段名Attribute 字段名首字母大写 ,遇下划线后字母大写
// 修改器 route_name => RouteName
public function setRouteNameAttribute($value)
{
// 字段 route_name 没有设置可以为空,这里使用修改器给加个空值
$this->attributes['route_name'] = empty($value) ? '' : $value;
}
还有其他的别的方法
- 改数据表
- 控制器中手动组装一个空值数据给它
添加之后,顶级的节点就出现了,所以要给下拉列表添加点击事件
<div class="row cl">
<label class="form-label col-xs-4 col-sm-3">
<span class="c-red">*</span>顶级节点:
</label>
<div class="formControls col-xs-8 col-sm-9">
<span class="select-box">
<select name="pid" id="pid" class="select" @change="changePid">
<option value="0">==顶级==</option>
@foreach ($data as $item)
<option value="{{ $item->id }}">{{ $item->name }}</option>
@endforeach
</select>
</span>
</div>
</div>
对应的js方法的实现
changePid(evt) {
// 下拉的值
this.info.pid = evt.target.value || 0; // || 0 防止没有数据的情况就赋值0
}
角色列表
资源路由和控制器同上
显示角色列表
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$data = Role::withTrashed()->paginate($this->page_size);
return view('admin.role.index', compact('data'));
}
添加角色
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
return view('admin.role.create');
}
添加角色处理
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function store(Request $request)
{
// 异常处理
try {
$this->validate($request, ['name' => 'required|unique:roles,name']);
} catch (\Exception $e) {
return ['status' => 1000, 'msg' => '验证不通过'];
}
// 接收值
$data = $request->only('name');
Role::create($data);
return ['status' => 0, 'msg' => '添加角色成功'];
}
添加角色页面部分代码
html部分
<form action="{{ route('admin.role.store') }}" method="post" class="form form-horizontal" id="form-member-add">
@csrf
<div class="row cl">
<label class="form-label col-xs-4 col-sm-3"><span class="c-red">*</span>角色名称:</label>
<div class="formControls col-xs-8 col-sm-9">
<input type="text" class="input-text" id="name" name="name" autocomplete="off">
</div>
</div>
<div class="row cl">
<div class="col-xs-8 col-sm-9 col-xs-offset-4 col-sm-offset-3">
<input class="btn btn-primary radius" type="submit" value=" 添加角色 ">
</div>
</div>
</form>
js部分
前端验证方法需要引入相应的资源
<script type="text/javascript" src="/admin/lib/jquery.validation/1.14.0/jquery.validate.js"></script> <script type="text/javascript" src="/admin/lib/jquery.validation/1.14.0/validate-methods.js"></script> <script type="text/javascript" src="/admin/lib/jquery.validation/1.14.0/messages_zh.js"></script>
$("#form-member-add").validate({
rules: {
// 表单元素名称
name: {
// 验证规则
required: true,
}
},
// 消息提示
messages: {
name: {
required: '角色名称不能为空'
}
},
// 取消键盘事件
onkeyup: false,
// 验证成功后的样式
success: "valid",
// 验证通过后,处理的方法
submitHandler: function (form) {
// 表单提交的地址
let url = $(form).attr('action');
// 表单序列化 => _token=XXXXX&name=XXX
let data = $(form).serialize();
// jquery post提交
$.post(url, data).then(({status, msg}) => {
if (status == 0) {
layer.msg(msg, {icon: 1, time: 2000}, () => {
location.href = "{{ route('admin.role.index') }}";
})
} else {
layer.msg(msg, {icon: 2});
}
}, 'json');
}
});
搜索角色名称
搜索角色名称
使用模型查询的
when
方法
public function index(Request $request)
{
// 获取搜索框
$kw = $request->get('kw');
// 分页 搜索
// 参数1,变量值存在,则执行匿名函数
$data = Role::when($kw, function ($query) use ($kw) {
$query->where('name', 'like', "%{$kw}%");
})->withTrashed()->paginate($this->page_size);
return view('admin.role.index', compact('data', 'kw'));
}
为了用户体验,将搜索的关键词保留在搜索框中
<form method="get">
<div class="text-c"> 角色名称:
<input type="text" class="input-text" style="width:250px" value="{{ $kw }}" placeholder="角色名称" id="" name="kw">
<button type="submit" class="btn btn-success radius" id="" name=""><i class="Hui-iconfont"></i>
搜用角色
</button>
</div>
</form>
编辑角色
重要的就是验证唯一性的操作
忽略当前验证行的操作
其他的编辑页面的操作就不多说
public function update(Request $request, int $id)
{
// 异常处理
try {
// unique:表名,唯一的字段,[排除行的值, 以哪个字段来排除]
$this->validate($request, ['name' => 'required|unique:roles,name,'.$id.',id']);
} catch (\Exception $e) {
return ['status' => 1000, 'msg' => '验证不通过'];
}
// 修改入库
Role::find($id)->update($request->only('name'));
return ['status' => 0, 'msg' => '修改角色成功'];
}
unique:表名,唯一的字段,[排除行的值, 以哪个字段来排除]
访问器显示菜单
// 访问器
public function getMenuAttribute()
{
return $this->is_menu == '1' ? '<span class="label label-success radius">是</span>' : '<span class="label label-danger radius">否</span>';
}
前端显示
<td>{!! $item->menu !!}</td>
特别提示!
{!! !!}
是html解析输出@{{ }} 解决
vue
符号冲突问题
节点层级列表显示
定义递归方法
/**
* @desc 数组的合并,并加上html标识前缀
* @param array $data
* @param int $pid
* @param string $html
* @param int $level
* @return array
*/
public function treeLevel(array $data, int $pid = 0, string $html = '--', int $level = 0)
{
static $arr = [];
foreach ($data as $val) {
if ($pid == $val['pid']) {
// 重复一个字符多少次
$val['html'] = str_repeat($html, $level * 2);
$val['level'] = $level + 1;
$arr[] = $val;
$this->treeLevel($data, $val['id'], $html, $val['level']);
}
}
return $arr;
}
更改列表查询之后为数组
Node模型
// 获取全部数据
public function getAllList()
{
$data = self::get()->toArray();
return $this->treeLevel($data);
}
NodeController
/**
* 节点列表
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
// 获取所有节点的数据,返回的是数组
$data = (new Node())->getAllList();
return view('admin.node.index', compact('data'));
}
对应的前台显示的就不能使用->
来调用了,因为现在是数组返回
<tbody>
@foreach($data as $item)
<tr class="text-l">
<td>{{ $item['id'] }}</td>
<td>
{{ $item['html'] }}
{{ $item['name'] }}
</td>
<td>{{ $item['route_name'] }}</td>
<td>{!! $item['menu'] !!}</td>
<td>{{ $item['created_at'] }}</td>
<td class="td-manage">
<a title="编辑" href="{{ route('admin.node.edit', ['id' => $item['id']]) }}"
class="ml-5 btn btn-primary btn-sm">编辑</a>
{{-- <a title="删除" href="{{ route('admin.node.restore', ['id' => $item->id]) }}"--}}
{{-- class="ml-5 btn btn-success btn-sm">还原</a>--}}
<a title="删除" href="{{ route('admin.node.destroy', ['id' => $item['id']]) }}"
class="ml-5 delbtn btn btn-danger btn-sm">删除</a>
</td>
</tr>
@endforeach
</tbody>
这里有一个
menu
,前面是用的对象调用的方式,可以进行输出,现在换成数组了,需要在模型中添加追加参数menu
protected $appends = ['menu'];
这样页面就可以正常的显示了。
后台权限控制
给角色分配权限
角色与权限之间的关系:多对多关系,在Laravel
中使用belongsToMany
来进行模型间的关联
在角色模型中设置关联函数
// 角色与权限多对多
public function nodes()
{
return $this->belongsToMany(Node::class, 'role_node', 'role_id', 'node_id');
}
belongsToMany()
参数解析// 参1: 关联模型 // 参2: 中间表的表名,没有前缀 // 参3: 本模型的对应的外键ID // 参4: 关联模型对应的外键ID
路由设置:
// 分配权限
Route::get('role/node/{role}', 'RoleController@node')->name('role.node');
// 分配处理
Route::post('role/node/{role}', 'RoleController@nodeSave')->name('role.node');
角色控制器里实现:
public function node(Role $role)
{
// 读取所有的权限
$nodeAll = (new Node())->getAllList();
// 读取当前角色所拥有的节点
$nodes = $role->nodes()->pluck('id')->toArray();
return view('admin.role.node', compact('nodeAll', 'nodes', 'role'));
}
public function nodeSave(Request $request, Role $role)
{
// 关联模型的数据同步
$role->nodes()->sync($request->get('node'));
return back()->with('success', '权限分配成功');
}
前端页面
<form action="{{ route('admin.role.node', $role) }}" method="post" class="form form-horizontal"
id="form-member-add">
@csrf
@foreach($nodeAll as $item)
<div>
<input type="checkbox" name="node[]" value="{{ $item['id'] }}"
@if (in_array($item['id'], $nodes))
checked
@endif
>
{{ $item['html'] }} {{ $item['name'] }}
</div>
@endforeach
<div class="row cl">
<div class="col-xs-8 col-sm-9 col-xs-offset-4 col-sm-offset-3">
<input class="btn btn-primary radius" type="submit" value=" 分配权限 ">
</div>
</div>
</form>
给用户分配角色
路由配置:
// 给用户分配角色
Route::match(['get', 'post'], 'user/role/{user}', 'UserController@role')->name('user.role');
用户模型中设置于角色的关联函数:
// 角色 => 属于关系
public function role()
{
return $this->belongsTo(Role::class, 'role_id');
}
控制器中实现:
/**
* 分配角色
* @param Request $request
* @param User $user
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @throws \Illuminate\Validation\ValidationException
*/
public function role(Request $request, User $user)
{
if ($request->isMethod('post')) {
$post = $this->validate(
$request,
[
'role_id' => 'required|min:1',
],
['role_id.required' => '角色必须选择']
);
$user->update($post);
return redirect(route('admin.user.index'));
}
// 获取所有角色
$roleAll = Role::all();
return view('admin.user.role', compact('user', 'roleAll'));
}
前端页面显示:
@include('admin.common.validate') {{--验证消息提示--}
<form action="{{ route('admin.user.role', $user) }}" method="post">
@csrf
@foreach($roleAll as $item)
<div>
<lable>{{ $item->name }}
<input type="radio" name="role_id" value="{{ $item->id }}"
@if ($item->id == $user->role_id)
checked
@endif
>
</lable>
</div>
@endforeach
<button type="submit">给用户指定角色</button>
</form>
实时渲染菜单
思路:
- 登录的时候通过
auth()->user()
,获取当前登录用户对象,根据用户模型与角色模型的关联关系,通过关联方法role
,获取角色对象,再根据角色与权限的关联关系,通过关联方法nodes()
获取字段为route_name
,以id
为键的对象,再进行数组转换toArray()
,进而在登录的时候获取该用户的所有权限- 后台中有无需验证的用户和无需验证的几个方法,比如超级管理员,与生俱来拥有全部权限;登录、退出和首页以及欢迎页面是每个用户都需要经过的,所以也不需要验证。上面原先是使用的
.env
里设置超级管理员的名称,这里改为使用配置文件来进行配置无需验证的用户和无序验证的路由。- 做好上面的判断之后,就是如何将用户获取的权限进行保存 ,这里暂时使用
session
来保存权限,优化的话可以使用缓存、redis
等进行保存该数据- 是超级管理员的情况下,权限需要重新查取所有权限即可,这里不建议在使用数据库查询一遍,可以直接赋值
true
给保存的权限的session
的键值;数据库查询的时候,判断是否是数组来进行岔开获取对应的权限。- 需要在菜单进行渲染,数据的获取就不能使用上面的
treeLevel
递归方法,这里需要额外定义一个可以获取子级数组的函数
登录控制器实现部分代码
这里对象调用关联方法时,不加括号的寓意 => 会自动通过
__get()
魔术方法获取相关对象属性,而加了括号之后会获取该关联对象
pluck()
这个查看手册吧。
// 判断是否是超级管理员
if (config('rbac.super') != $post['username']) {
$userModel = auth()->user();
$roleModel = $userModel->role;
$nodesArr = $roleModel->nodes()->pluck('route_name', 'id')->toArray();
// 权限保存到session中
session(['admin.auth' => $nodesArr]);
} else {
session(['admin.auth' => true]);
}
rbac.php配置
<?php
/**
* Created By www.zfw.com
* Author: Virus
* Date: 2020/4/30
* Time: 17:41
* Desc: RBAC权限配置
*/
return [
// 超级管理员
'super' => 'admin',
// 不需要验证的路由 => 路由别名
'allow_route' => [
'admin.index',
'admin.welcome',
'admin.logout',
],
];
首页控制器里进行获取该用户的权限并渲染到前端页面里
/**
* @desc 后台首页
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function index()
{
$auth = session('admin.auth');
// 读取菜单
$menuData = (new Node())->treeData($auth);
return view('admin.index.index', compact('menuData'));
}
treeData()
函数:
session('admin.auth')
的数据结构为:array( 1 => 'admin.index', 2 => 'admin.welcome' // ... 其他的不举例 );
这里使用
array_keys()
只获取它的id
来组装成数组判断
id
是否在这个组装的数组里
/**
* 获取有层级的数据
* @param array $allow_node
* @return array
*/
public function treeData($allow_node)
{
$query = self::where('is_menu', '1');
if (is_array($allow_node)) {
$query->whereIn('id', array_keys($allow_node));
}
$menuData = $query->get()->toArray();
return $this->subTree($menuData);
}
subTree()
函数实现
/**
* 数据多层级
* @param array $data
* @param int $pid
* @return array
*/
public function subTree(array $data, int $pid = 0)
{
// 返回的结果
$arr = [];
foreach ($data as $val) {
// 给定的pid是当前记录的上级id
if ($pid == $val['pid']) {
// 进行递归
$val['sub'] = $this->subTree($data, $val['id']);
$arr[] = $val;
}
}
return $arr;
}
前端页面渲染
<div class="menu_dropdown bk_2">
@foreach($menuData as $item)
<dl id="menu-admin">
<dt><i class="Hui-iconfont"></i> {{ $item['name'] }}<i class="Hui-iconfont menu_dropdown-arrow"></i>
</dt>
<dd>
<ul>
@foreach($item['sub'] as $subitem)
<li><a data-href="{{ route($subitem['route_name']) }}" data-title="{{ $subitem['name'] }}"
href="javascript:void(0)">{{ $subitem['name'] }}</a></li>
@endforeach
</ul>
</dd>
</dl>
@endforeach
</div>
使用中间件进行鉴权
public function handle($request, Closure $next)
{
// 用户是否登录检查
if (!auth()->check()) {
return redirect(route('admin.login'))->withErrors(['errors' => '请登录']);
}
// 访问的权限
$auths = is_array(session('admin.auth')) ? array_filter(session('admin.auth')) : [];
// 合并数组
$auths = array_merge($auths, config('rbac.allow_route'));
// 当前访问的路由
$currentRoute = $request->route()->getName();
if (auth()->user()->username != config('rbac.super') && !in_array($currentRoute, $auths)) {
exit('您没有权限!');
}
// 使用request传到下级去
$request->auths = $auths;
// 如果没有停止则向后执行
return $next($request);
}
上面还只是简单的实现了鉴权的操作,并不能实现粒度级权限显示
这里使用
trait
来实现按钮的渲染,并在渲染之前进行后端鉴权这里的方法可以渲染i到页面里,间接的实现了按钮级别的权限的验证
route($route, $this)
这里的$this
解读:当某个模型类使用这个trait
的时候,$this
就代表当前的模型对象进行代入。
request()->auths
就是上面中间件的进行传递的权限数组模型类使用
trait
,直接在类内部和软删除一样使用use Btn
即可,不过上面得进行引入!
<?php
/**
* Created By www.zfw.com
* Author: Virus
* Date: 2020/4/30
* Time: 20:34
* 按钮
*/
namespace App\Models\Traits;
trait Btn
{
/**
* @desc 编辑按钮
* @param string $route 路由名
* @return string
*/
public function editBtn(string $route)
{
if (auth()->user()->username != config('rbac.super') && !in_array(
$route,
request()->auths
)) {
return '';
}
return '<a title="编辑" href="'.route($route, $this).'" class="ml-5 btn btn-primary btn-sm">编辑</a>';
}
public function deleteBtn(string $route)
{
if (auth()->user()->username != config('rbac.super') && !in_array(
$route,
request()->auths
)) {
return '';
}
return '<a title="编辑" href="'.route($route, $this).'" class="ml-5 btn btn-danger delbtn btn-sm">删除</a>';
}
}
前端调用显示页面
使用
{!! !!}
来解析html的输出,这里只搞了编辑和删除的部分
<td class="td-manage">
{!! $item->editBtn('admin.user.edit') !!}
<a title="分配角色" href="{{ route('admin.user.role', $item) }}"
class="ml-5 btn btn-primary btn-sm">分配角色</a>
@if (auth()->id() != $item->id)
@if ($item->deleted_at != null)
<a title="还原" href="{{ route('admin.user.restore', ['id' => $item->id]) }}"
class="ml-5 btn btn-success btn-sm">还原</a>
@else
{!! $item->deleteBtn('admin.user.del') !!}
@endif
@endif
</td>