RBAC 权限管理
发布时间 2023-11-13 20:40:00

  想看完整的 权限管理系统 实现,可以查看个人项目:vue3-ting-admin

一、RBAC 权限设计

1、基本概述

  RBAC (Role-Based Access Control) 是一种基于角色的访问控制策略。

  简单地说,一个用户拥有多个角色,一个角色拥有多个权限。管理员只需要管理角色的权限,而不必直接管理每个用户的权限

这样,就构造成 “用户 -> 角色 -> 权限” 的授权模型。

  从模块角度来讲,可以分为以下 RBAC 模块:

1
2
3
4
5
系统管理
- 用户管理
- 部门管理 *
- 角色管理
- 菜单管理

2、数据库设计

  在 RBAC 模型中,用户与角色之间、角色与权限之间,通常都是多对多的关系。

  上述只是一个简单的数据库设计思路(具体的话会根据不同的业务变得复杂)。

3、数据交互

  下面简单说一下流程:

方式 1(精准版):

  • 前端发起用户登录请求 ——> 后端返回该用户的 token、userID、用户信息 等
  • 前端可以通过 userID + token 获取角色信息请求 ——> 后端返回该用户的 详细信息(包含 roleID) 等
  • 前端可以通过 roleID + token 获取菜单信息请求 ——> 后端返回该用户的 菜单树(扁平、树形) 等

方式 2(暴力版):

  • 前端发起用户登录请求 ——> 后端返回该用户的 token、userID、roleID、用户信息、菜单树 等

4、动态路由方案

① 基于角色

  • 后端:只提供角色 role 信息即可
  • 前端:根据 role 匹配指定的 静态路由组合即可

  为了防止产生大量的重复数据,通常采用在路由的 mate 字段上加角色数组(例如:vue-element-admin

基于角色的动态路由方案并不能实现真正意义上的动态,只是前端的固有设置罢了。

🌟 代码实现🌟
  • 后端

根据 userId 获取用户的详细信息

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"code": 0,
"data": {
"id": 1,
"name": "admin",
"realname": null,
"cellphone": null,
"avatarUrl": "http://xxxx.com/users/1/avatar/a46eaecc31a843842c2bdf605b42ae8b",

"role": ["super-admin"], // 这里!!!
"token": "Bearer eyJhbGciOiJIUzI1…"
}
}
  • 前端

  菜单和路由的生成,由前端自己在 meta 字段定义所需数据即可:

title、icon 进行视图渲染,roles 进行路由匹配(具体实现可以参考:vue-element-admin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default {
path: '/power',
component: Layout, // 保持Layout架子渲染
alwaysShow: true, // 当children只有一个时,显示根路由
meta: { title: '权限管理', icon: 'form', roles: ['super-admin', 'root'] },
children: [
{
path: 'userControl',
component: () => import('@/views/power/userControl'),
meta: { title: '用户管理', icon: 'user', roles: ['super-admin', 'root'] }
},
{
path: 'roleControl',
component: () => import('@/views/power/roleControl'),
meta: { title: '角色管理', icon: 'nested', roles: ['super-admin', 'root'] }
},
{
path: 'menuControl',
component: () => import('@/views/power/menuControl'),
meta: { title: '菜单管理', icon: 'example', roles: ['super-admin'] }
}
]
}

② 基于菜单

  • 方案 1:后端同时提供 path 和 component,

  前端需要将数据强行转换为路由结构,但麻烦是的 component: () => import()组件的路径生成,并且组件名由后端提供,前端开发受阻。

代码实现(强制转换 ✖)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"rights": [
{
"id": 101,
"name": "商品中心",
"icon": "icon-goods",

"path": "goods",
"component": "goods",
"children": [
{
"id": 104,
"name": "商品类别",

"path": "category",
"component": "category",
"rights": ["view", "edit", "add", "delete"]
}
]
}
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 1、菜单对象转路由对象
const menuToRoute = (item, menu) => {
let route = {
name: menu.name, // 商品列表
path: `/${item.component}/${menu.component}` // "/goods/category"
component: () => import(`@/views/${item.component}/${menu.component}.vue`)
meta: {
rights: menu.rights // 利用路由元信息附加上权限信息(备用👏)
}
}
return route
}

// 2、动态绑定路由
// 根据用户权限,设置动态路由规则
export function initDynamicRoutes() {
console.log(router)
// 路由中定义的【基础路由】(数组✨)
const currentRoutes = router.options.routes

// 服务器返回的数据(json数据)
const rightList = $store.state.rightList
rightList.forEach((item) => {
// 获取其中children的【二级路由】(数组✨)
item.children.forEach((menu) => {
const temp1 = menuToRoute(item, menu) // 动态添加二级路由🚩

currentRoutes[0].children.push(temp1) // 统一放在默认的main路由下
})
})

// 正式重置路由
currentRoutes.forEach((item) => {
router.addRoutes(item)
})
}

  • 方案 2:后端只提供 path
手动映射(path 映射 Route ✔)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"rights": [
{
"id": 101,
"name": "商品中心",
"icon": "icon-goods",

"path": "goods",
// "component": "goods",
"children": [
{
"id": 104,
"name": "商品类别",

"path": "category",
// "component": "category",
"rights": ["view", "edit", "add", "delete"]
}
]
}
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 优点:组件命名自由了
// 缺点:每一个都得手动映射
const goods = {
categories: { path: '/goods/categories', component: Categories }
// ……
}
const system = {
user: { path: '/system/user', component: User }
// ……
}

// 映射
const ruleMapping = {
categories: goods.categories
user: system.user
}

// 根据用户权限,设置动态路由规则
export function initDynamicRoutes() {
console.log(router)
// 路由中定义的【基础路由】(数组✨)
const currentRoutes = router.options.routes

// 服务器返回的数据(json数据)
const rightList = $store.state.rightList
rightList.forEach((item) => {
// 获取其中children的【二级路由】(数组✨)
item.children.forEach((menu) => {
const temp1 = ruleMapping[menu.path] // 动态添加二级路由🚩
temp1.meta = menu.rights // 利用路由元信息附加上权限信息(备用👏)

currentRoutes[0].children.push(temp1) // 统一放在默认的main路由下
})
})

// 正式重置路由
currentRoutes.forEach((item) => {
router.addRoutes(item)
})
}
自动映射(path 映射 Route ✔)

  这个就是我采用的最终方案,下面的教程采用的就这个方案。

提前准备好静态路由,并提前和后端沟通好,保持前端本地的路由 path 值,和后端传过来的 path 属性值一致。

二、菜单数据

1、菜单树

  菜单数据中必须有下面几个基础属性,必须和后端沟通好:

nav-aside 视图渲染 main-contain 路由匹配
标识 id 路由地址 url
图标 icon
名称 name

  下面给出菜单数据的示例结构:

如果不是树形结构,我们自己根据 parentId 结合递归转换为树结构即可。

🌟 菜单数据示例🌟
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
{
"code": 0,
"data": [
{
"id": 4,
"icon": "setting",
"name": "系统管理",

"url": "/main/system",
"sort": 2,
"type": 1,
"createAt": "2022-08-14 19:50:32.000000",
"parentId": null,
"updateAt": "2022-08-14 19:50:32.000000",
"permission": null,
"children": [
{
"id": 5,
"icon": null,
"name": "用户管理",

"url": "/main/system/user",
"sort": 1,
"type": 2,
"createAt": "2022-08-14 19:51:41.000000",
"parentId": 4,
"updateAt": "2022-08-14 20:45:07.000000",
"permission": null,
"children": [
{
"id": 6,
"url": null,
"icon": null,
"name": "创建用户",
"sort": 1,
"type": 3,
"createAt": "2022-08-14 19:53:30.000000",
"parentId": 5,
"updateAt": "2022-08-14 20:49:34.000000",
"permission": "system:user:create"
},
{
"id": 7,
"url": null,
"icon": null,
"name": "删除用户",
"sort": 2,
"type": 3,
"createAt": "2022-08-14 19:57:35.000000",
"parentId": 5,
"updateAt": "2022-08-14 20:49:37.000000",
"permission": "system:user:delete"
},
{
"id": 8,
"url": null,
"icon": null,
"name": "修改用户",
"sort": 3,
"type": 3,
"createAt": "2022-08-14 19:58:40.000000",
"parentId": 5,
"updateAt": "2022-08-14 20:49:48.000000",
"permission": "system:user:update"
},
{
"id": 9,
"url": null,
"icon": null,
"name": "查询用户",
"sort": 4,
"type": 3,
"createAt": "2022-08-14 20:03:53.000000",
"parentId": 5,
"updateAt": "2022-08-14 20:50:13.000000",
"permission": "system:user:query"
}
]
}
]
}
]
}

2、视图渲染 ✨

  我们可以通过上面的菜单数据的 id、icon、name 属性,先渲染右侧菜单栏视图

下列的菜单栏设计:必须有子菜单,且只有一个。(可以根据项目需求进一步优化)

🌟 右侧菜单栏视图🌟
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<template>
<div class="nav-aside">
<div class="logo">
<img src="@/assets/imgs/title.jpg" alt="" />
<h3 class="title" v-if="!isCollapse">权限管理系统</h3>
</div>

<div class="menu">
<el-menu
:default-active="defaultMenuItemShow"
:collapse-transition="false"
:collapse="isCollapse"
>
<!-- 一级 -->
<template v-for="item in userRoleMenu" :key="item.id">
<el-sub-menu :index="item.id + ''">
<template #title>
<el-icon>
<!-- 动态图标组件 -->
<component :is="item.icon" />
</el-icon>
<span>{{ item.name }}</span>
</template>

<!-- 二级 -->
<template v-for="subItem in item.children" :key="subItem.id">
<el-menu-item :index="subItem.id + ''" @click="handleMenuItemClick(subItem)">
<el-icon>
<!-- 动态图标组件 -->
<component :is="subItem.icon" />
</el-icon>
<span>{{ subItem.name }}</span>
</el-menu-item>
</template>
</el-sub-menu>
</template>
</el-menu>
</div>
</div>
</template>

3、静态路由

  不同的静态路由处理方式,可能会产生不同的动态路由方案

🌟 静态路由示例🌟
  • 基本路由
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
redirect: '/main'
},
{
path: '/login',
component: () => import('../view/login/login.vue')
},
{
path: '/main',
name: 'main',
component: () => import('../view/home/home.vue')
},
// 含有通配符的路由应该放在最后 !!!
{
path: '/:pathMatch(.*)',
component: () => import('../view/404/not-found.vue')
}
]
})
  • 其他路由

放在合理的位置,然后后期根据用户的菜单树映射并注册指定路由即可

4、菜单-路由映射 ✨

    接下来我们可以利用菜单数据的 url 属性 进行 vue-router 路由的动态注册

将后端传来的 url(path)与路由的 path 进行匹配,前提是要与后端沟通好 path 路径的构成

制作 initDynamicRoutes 函数 🎈
  • utils / map-menus.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import type { RouteRecordRaw } from 'vue-router'

// 批量收集本地路由
function loadLocalRoutes() {
const localRoutes: RouteRecordRaw[] = []

// - webpack中使用 require.context()、vite中使用import.meta.glob()
const modules: Record<string, any> = import.meta.glob('@/router/**/*.ts', {
eager: true
})

for (const key in modules) {
const module = modules[key]

if (key !== '../../router/index.ts') {
// console.log(module.default)
localRoutes.push(module.default)
}
}

return localRoutes
}

// 记录🤔第一个菜单
export let firstMenuItem: any = null

export function mapMenusToRoutes(userRoleMenu: any[]) {
// 1、本地的全部路由
const localRoutes = loadLocalRoutes()

// 2、根据菜单匹配的路由
const routes: RouteRecordRaw[] = []

for (const menu of userRoleMenu) {
for (const subItem of menu.children) {
const route = localRoutes.find((item) => item.path === subItem.url)

// 存储匹配的路由
if (route) {
// 追加父级菜单路由-重定向到第一个menuItem(供面包屑🤔使用)
if (!routes.find((item) => item.path === menu.url)) {
routes.push({ path: menu.url, redirect: route.path })
}

routes.push(route)
}

// 记录第一个菜单
if (!firstMenuItem && route) firstMenuItem = subItem
}
}

return routes
}
  • utils / initDynamicRoutes.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import { mapMenusToRoutes } from './map-menus.ts'
import router from '@/router'

// ……

// 动态路由注册
export function initDynamicRoutes(userRoleMenu: any[]) {
// 根据菜单匹配的路由
const routes = mapMenusToRoutes(userRoleMenu)

// 对匹配的路由进行注册
routes.forEach((route) => router.addRoute('main', route))
}

  此时,我们可以在需要生成动态路由的代码段中调用 initDynamicRoutes 函数为项目注册动态路由。比如:

  • 用户登录时
  • 页面刷新时
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// @/store/login/login.ts

const useLoginStore = defineStore('login', {
action: {
// 1、用户登录时
async loginAccountAction(account: IAccount) {
// ……

// 根据菜单-动态生成路由
initDynamicRoutes(this.userRoleMenu)

//……
},

// 2、页面刷新时
dynamicRoutesCacheAction() {
const token = localCache.getCache(LOGIN_TOKEN)
const userInfo = localCache.getCache(LOGIN_USER_INFO)
const userRoleMenu = localCache.getCache(LOGIN_ROLE_MENU)

// 确保当前已经login
if (token && userInfo && userRoleMenu) {
// 使用缓存数据
this.token = token
this.userInfo = userInfo
this.userRoleMenu = userRoleMenu

// 根据缓存-动态复原路由
initDynamicRoutes(this.userRoleMenu)
}
}
})

5、页面刷新处理

  页面刷新时,需要在合适的地方,路由缓存的菜单树重新生成 注册动态路由:

  • main.ts
1
2
3
4
5
6
7
8
9
10
11
import { createApp } from 'vue'
import App from './App.vue'
import registerPinia from './global/register-pinia.ts'
import router from './router/index.ts'

const app = createApp(App)

app.use(registerPinia) // 重点:必须放在router注册之前
app.use(router)

app.mount('#app')
  • @/global/register-pinia.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import type { App } from 'vue'

import pinia from '@/stores/index.ts'
import useloginStore from '@/stores/login/login.ts'

const registerPinia = (app: App<Element>) => {
app.use(pinia)

const loginStore = useloginStore()
loginStore.dynamicRoutesCacheAction() // 动态路由-防刷新处理
}

export default registerPinia

三、权限操作控制

1、基本介绍

  页面级权限控制简单的说是按钮的权限控制,从根本上讲是进行增删改查请求的访问控制。

  权限操作设计也是有一定的讲究的,本教程采用的是RuoYi-Vue3的权限操作方案。

1
2
// 解析:当前用户在 /main/system/menu 路由组件页面中用户查询(query)权限
"permission": "system:menu:query"
按钮权限 数据

  其实,关于权限操作控制的 permission 字段,后端在用户的菜单树中已经提供了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"children": [
{
"id": 20,
"url": null,
"icon": null,
"name": "查询菜单",
"sort": 19,
"type": 3,
"parentId": 16,
"createAt": "2022-08-14 20:13:47.000000",
"updateAt": "2022-08-14 20:13:47.000000",

"permission": "system:menu:query" // 这里 !!!!
},
{
//
}
]
}

  也有后端是这样传的,其想法就是在进行动态路由生成时,顺便将下列信息放到路由的 meta 中备用。

1
2
3
4
5
6
{
"meta": {
"title": "角色管理",
"rights": ["view", "edit", "add", "delete"]
}
}

2、权限数组

  首先我们得根据 菜单树 映射出一个当前角色所拥有的 按钮权限数组:

  • utils / map-menus.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 将userRoleMenu树 映射为 permission数组
export function mapMenuToPermission(userRoleMenu: any) {
const permissions: string[] = []

function getPermission(menus) {
menus.forEach((item) => {
if (item.permission) {
permissions.push(item.permission)
} else {
getPermission(item.children ?? []) // 没有children时预处理
}
})
}
getPermission(userRoleMenu)

return permissions
}

3、权限控制

  获取了权限数组后,我们可以可以利用该数组实现权限控制了,具体的实现流行有以下两种方案:

  • 使用 vue 自定义指令(倾向于 按钮权限控制)
  • 使用自己封装的 hook(倾向于 请求权限控制)

① 自定义指令 方案

  • 全局 permissions 指令封装
1
// 详见个人项目:https://github.com/Lencamo/vue3-ting-admin/tree/main/src/directives
  • v-permissions 指令使用示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div class="search-box" v-permissions="{ route, action: 'query' }">
<!-- -->
</div>

<!-- …… -->

<el-button
type="primary"
size="small"
@click="handleAddBtn()"
v-permissions="{ route, action: 'create', effect: 'disabled' }"
>新增用户</el-button
>
</template>

② 封装 hook 方案 👀

  • @/hooks/usePermissions.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import useLoginStore from '@/store/login/login'

type actionType = 'create' | 'delete' | 'update' | 'query'

function usePermissions(route, action: actionType) {
// 用户操作权限 数组
const loginStore = useloginStore()
const { userRolePermission } = loginStore

// 当前路由path
const [, , parentName, currentName] = route.path.split('/')

return !!userRolePermission.find((item) =>
item.includes(`${parentName}:${currentName}:${action}`)
)
}

export default usePermissions
  • 具体使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div class="search-box" v-if="isQuery">
<!-- -->
</div>

<!-- …… -->

<el-button type="primary" size="small" @click="handleAddBtn()" :disable="isCreate"
>新增用户</el-button
>
</template>

<script setup lang="ts">
import usePermissions from '@/hooks/usePermissions'
import { useRoute } from 'vue-router'
const route = useRoute()

const isCreate = usePermissions(route, 'create')
const isQuery = usePermissions(route, 'create')
</script>

四、权限与位运算

  我们可以将&|^ 位运算符应用于权限控制中:

这种方式可以极大的简化 按钮级别 的权限控制效率(后端只需要传一个数字即可)。

1
2
3
4
const CREATE = 1 // 0001
const DELETE = 2 // 0010
const UPDATE = 4 // 0100
const QUERY = 8 // 1000
测试代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const CREATE = 1 // 0001
const DELETE = 2 // 0010
const UPDATE = 4 // 0100
const QUERY = 8 // 1000

// 权限组合 |
console.log('权限数字:', CREATE | QUERY) // 9
console.log(('二进制解读:', CREATE | QUERY).toString(2)) // 1001

console.log('-------')

// 权限解读 &
let permisssion1 = 11
console.log('当前权限:', permisssion1.toString(2)) // 查看
if (permisssion1 & UPDATE) {
console.log(' 有update权限')
} else {
console.log(' 无update权限')
}

console.log('-------')

// 权限操作 ^ (异或:有就去掉,没有就加上)
let permisssion2 = 14
console.log('当前权限:', permisssion2.toString(2)) // 查看
if (permisssion2 & DELETE) {
permisssion2 = permisssion2 ^ DELETE
console.log(' 去掉delete权限成功')
}
console.log('操作后权限:', permisssion2.toString(2)) // 查看

1、权限组合

  有 1 则 1

  • 按位或 |
1
2
3
4
5
// 权限组合 |
console.log('权限数字:', CREATE | QUERY) // 9
console.log(('二进制解读:', CREATE | QUERY).toString(2)) // 1001

console.log('-------')

2、权限解读

  同为 1 则 1

  • 按位与 &
1
2
3
4
5
6
7
8
9
10
// 权限解读 &
let permisssion1 = 11
console.log('当前权限:', permisssion1.toString(2)) // 查看
if (permisssion1 & UPDATE) {
console.log(' 有update权限')
} else {
console.log(' 无update权限')
}

console.log('-------')

3、权限操作

  同则 0,异则 1

  • 按位异或 ^
1
2
3
4
5
6
7
8
// 权限操作 ^ (异或:有就去掉,没有就加上)
let permisssion2 = 14
console.log('当前权限:', permisssion2.toString(2)) // 查看
if (permisssion2 & DELETE) {
permisssion2 = permisssion2 ^ DELETE
console.log(' 去掉delete权限成功')
}
console.log('操作后权限:', permisssion2.toString(2)) // 查看
上一页
2024-06-11 00:39:23
下一页