RBAC权限系统实战踩坑记:从设计到落地的完整方案
这次终于搞定了权限控制的那些破事
最近做后台管理系统,权限控制这块儿又把我折腾得够呛。之前做过几个项目,每次都是临时抱佛脚,这次想着彻底把RBAC这套东西摸透,免得以后还要花时间重新搞。
最开始想当然地设计了简单权限
刚开始我觉得用户角色权限嘛,不就是 user -> role -> permission 这样的关系吗?结果设计了一个超级简单的版本,每个用户对应一个角色,然后角色直接绑定菜单权限。看起来挺好的,直到需求变更:
- 某个用户需要多个角色
- 临时给某些用户开某个功能的权限
- 权限粒度要细化到按钮级别
这里我踩了个坑,原本的设计完全扛不住这些变化,改起来各种重构,前后花了快一周才整理清楚整个权限体系。
折腾了半天才明白RBAC到底该怎么玩
后来查了不少资料,看了很多开源项目的权限设计,终于明白了标准的RBAC模型长什么样。说白了就是:
用户可以有多个角色,角色可以有多个权限,权限可以分配给角色也可以直接分配给用户。这样既保证了通用性,又有足够的灵活性来处理特殊情况。
核心表结构大概是这样的:
- users 表:存储用户信息
- roles 表:存储角色信息
- permissions 表:存储权限信息
- user_roles 表:用户和角色的关联(多对多)
- role_permissions 表:角色和权限的关联(多对多)
- user_permissions 表:用户和权限的直接关联(用来处理临时权限)
前端权限控制的核心代码
后端权限验证相对简单,主要是前端这边要处理用户登录后权限的初始化和动态渲染。这是我在vuex里写的权限模块:
// store/modules/permission.js
const state = {
permissions: [],
routes: [],
addRoutes: []
}
const mutations = {
SET_PERMISSIONS: (state, permissions) => {
state.permissions = permissions
},
SET_ROUTES: (state, routes) => {
state.routes = routes
state.addRoutes = routes.filter(route => route.path !== '*')
}
}
const actions = {
async getPermissions({ commit }) {
try {
const { data } = await fetch('/api/user/permissions').then(res => res.json())
commit('SET_PERMISSIONS', data)
return data
} catch (error) {
console.error('获取权限失败:', error)
return []
}
},
generateRoutes({ commit }, permissions) {
const accessedRoutes = filterAsyncRoutes(asyncRoutes, permissions)
commit('SET_ROUTES', accessedRoutes)
return accessedRoutes
}
}
function filterAsyncRoutes(routes, permissions) {
const res = []
routes.forEach(route => {
const tmp = { ...route }
if (hasPermission(permissions, tmp)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, permissions)
}
res.push(tmp)
}
})
return res
}
function hasPermission(permissions, route) {
if (route.meta && route.meta.permission) {
return permissions.some(permission => permission === route.meta.permission)
} else {
return true
}
}
export default {
namespaced: true,
state,
mutations,
actions
}
路由守卫里的权限检查也很重要:
// router/index.js
import store from '@/store'
router.beforeEach(async (to, from, next) => {
const token = getToken()
if (token) {
if (!store.getters.hasUserInfo) {
try {
// 获取用户信息和权限
const permissions = await store.dispatch('permission/getPermissions')
// 根据权限生成路由
const accessRoutes = await store.dispatch('permission/generateRoutes', permissions)
// 动态添加路由
accessRoutes.forEach(route => {
router.addRoute(route)
})
next({ ...to, replace: true })
} catch (error) {
console.error('路由生成失败:', error)
next('/login')
}
} else {
// 已登录且已获取权限,正常跳转
next()
}
} else {
// 未登录,跳转登录页
if (to.path !== '/login') {
next('/login')
} else {
next()
}
}
})
按钮级别的权限判断组件
按钮级别的权限控制用指令比较方便,写了个v-permission指令:
// directives/permission.js
export default {
inserted(el, binding, vnode) {
checkPermission(el, binding, vnode)
},
update(el, binding, vnode) {
checkPermission(el, binding, vnode)
}
}
function checkPermission(el, binding, vnode) {
const { value } = binding
const permissions = vnode.context.$store.getters.permissions || []
if (!value) {
console.warn('need permissions! Like v-permission="'user:add'"')
return
}
const hasPermission = permissions.includes(value)
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el)
}
}
使用的时候就这样:
<template>
<el-button v-permission="'user:add'" @click="handleAdd">新增用户</el-button>
<el-button v-permission="'user:edit'" @click="handleEdit">编辑</el-button>
<el-button v-permission="'user:delete'" @click="handleDelete">删除</el-button>
</template>
这里注意我踩过好几次坑
权限数据的同步问题特别容易出错。比如用户登录成功后获取权限列表,然后动态生成路由,但有时候页面加载比权限获取还快,导致某些路由访问不了。
后来我在main.js里加了一层等待机制:
// main.js
async function startApp() {
try {
// 先获取用户权限
await store.dispatch('permission/getPermissions')
// 生成路由
const permissions = store.getters.permissions
const accessRoutes = await store.dispatch('permission/generateRoutes', permissions)
// 添加路由后启动应用
new Vue({
router,
store,
render: h => h(App),
}).$mount('#app')
} catch (error) {
console.error('应用启动失败:', error)
}
}
startApp()
还有个问题就是权限更新的实时性。有时候管理员给用户开了新权限,用户这边还得刷新页面才能看到新的功能。这个我暂时没做websocket推送,因为感觉太重了,目前是用户每次打开系统都重新请求一次权限列表。
另外就是权限数据缓存的问题。为了减少接口调用次数,我把权限列表存在localStorage里,但问题是如果权限变了,用户可能不会立即感知到。我现在设置的是每天凌晨1点强制清空权限缓存,让第二天登录时重新获取。
后端配合的权限验证
前端权限控制只是体验层面的,真正的安全还是得靠后端验证。后端我也按标准RBAC实现:
<?php
// 示例PHP代码,实际项目用框架
class PermissionService {
public function checkUserPermission($userId, $permissionCode) {
// 检查用户是否直接拥有该权限
$directPermission = $this->checkDirectPermission($userId, $permissionCode);
if ($directPermission) {
return true;
}
// 检查用户所属角色是否有该权限
$roles = $this->getUserRoles($userId);
foreach ($roles as $roleId) {
if ($this->checkRolePermission($roleId, $permissionCode)) {
return true;
}
}
return false;
}
private function checkDirectPermission($userId, $permissionCode) {
$sql = "SELECT id FROM user_permissions
WHERE user_id = ? AND permission_code = ? AND status = 1";
return $this->db->query($sql, [$userId, $permissionCode])->rowCount() > 0;
}
private function checkRolePermission($roleId, $permissionCode) {
$sql = "SELECT id FROM role_permissions
WHERE role_id = ? AND permission_code = ? AND status = 1";
return $this->db->query($sql, [$roleId, $permissionCode])->rowCount() > 0;
}
}
?>
前端接口调用时携带token,后端验证权限后返回数据,这样双重保障。
目前还遗留的小问题
虽然整体架构跑起来了,但还有几个小问题没完美解决:
- 权限缓存策略还需要优化,现在的时间控制太粗暴
- 当用户权限发生变化时,前端路由不会自动更新,需要刷新页面
- 权限错误提示不够友好,有时候就是空白页面
不过这些问题都不影响主要功能,后续慢慢优化吧。这个RBAC权限系统算是基本能用了,至少能满足大部分业务场景的需求。
总结一下
这次把权限控制重新梳理了一遍,感觉比以前那种临时凑合的做法靠谱多了。标准的RBAC模型确实有它的道理,虽然一开始觉得复杂,但真正用起来才发现扩展性很好。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

暂无评论