多级别权限设计思考及实战
本篇文章主要结合我最近的一个真实项目的具体业务场景的思考总结,以及相关的实战经验分享,希望能够帮助大家对权限设计有一个更深入的了解。
技术栈方面使用的是vue3 + ts + vue-router + pinia
,不过,权限设计的整体思路和技术并无很深的关联,不影响整体思路。
# 业务背景
首先,按照我这个项目的真实业务场景,会在项目部署的时候即确定一个账号的属性,暂定字段为 role
,主要分为两类,为了方便理解,我拿银行和银保监的关系举例:
- 银保监:主要就是监管银行机构的,因此,包括一些规则的定义,该权限是其独有的,但对于银行内部的一些模块功能,其并无权限查看。
- 银行:可以向银保监发起一些申请相关的功能,以及银行独有的部分功能模块,因此,银保监的权限部分是其不可见的,不过,它也有自己独有的访问一些模块的权限。
然后,在系统内部,会有一个角色权限管理的体系,这个体系内用户可以自定义新增和配置账号的权限,当然他们是在各自独立的分类权限下,在满足上面role
权限的同时,并满足自定义的权限。(有可能存在admin
用户,可跳过权限验证)
- 菜单路由权限:以
module
划分菜单路由的访问权限控制 - 按钮级别权限:以
auth
划分按钮操作的访问权限控制 - 接口访问权限:以
api
划分接口的访问权限控制
目标要求效果如下:
- 根据账号属性,以
role
划分第一类顶级权限,会同时影响部分菜单路由和操作 - 根据角色配置权限,以
module
划分第二类菜单路由权限 - 根据角色配置权限,精确到按钮操作级别,以
auth
划分第三类操作权限 - 根据角色配置权限,精确到接口权限,以
api
划分第四类接口权限
不过,权限3和权限4可以优化后共用一个逻辑。
# 设计思路
对于权限一的设计,对于前端来说,账号的属性在用户登录后,配合后端同学可以获取到账户信息详情。需要注意的是,该权限是需要和角色权限共存,并共同作用的,因此对于该权限的设计最好是作为一个独立功能处理,做到职责单一。
涉及的部分包括:
- 菜单路由:一些路由是受控制的,有/没有
- 按钮操作/接口操作:一些按钮操作是受控制的,相关的接口,首先是关联到按钮操作的,通过视图层处理优化掉,并在接口层同步处理。
需要考虑的功能实现:
- 路由:我这里为
vue-router
,需要做成动态路由,根据账号信息,动态生成,确保通过URL地址栏等其他手段访问的可能性,在路由层面直接优化掉。 - 指令:为了处理按钮操作,这里通过自定义指令来实现
- 接口:这里需要把按钮操作中的权限筛选逻辑进行抽象,封装成
hooks
,做到公用,并在接口层的请求拦截里做处理(以axios
的interceptors.request
为例)
对于按钮操作权限,这里有些细节要考虑:
我在开发过程中,对于DOM
的实现,有vue的template
模板语法,也有JSX
的语法,其中精细化的部分也涉及到自定义slot
插槽等。
这里主要是针对核心代码,把权限筛选逻辑进行hooks
封装,再结合其他不同的场景做到定制化使用。
# 代码实现
字段形状定义如下:
interface PermissioState {
auths: string[]; // 当前用户权限:按钮操作、接口控制
modules: string[]; // 模块权限:菜单与路由控制
role: 0 | 1; // 账号角色
isAdmin: 0 | 1; // 是否为管理员,当为角色为管理员时,跳过权限筛选
isGetUserInfo: boolean; // 是否获取过用户信息:控制路由只进行一次构建
}
2
3
4
5
6
7
8
# 路由构建
这里涉及到路由的构建,看下路由配置:
看下路由表,示例如下:
- 系统应该基本路由
- 权限路由(主要针对这里处理,我们通过
role
和auth
字段区分)
创建路由实例:
import { createRouter, createWebHistory } from 'vue-router';
import routes from './router.config';
// app router
export const router = createRouter({
// history: createWebHashHistory(),
history: createWebHistory(import.meta.env.BASE_URL),
routes,
strict: true,
scrollBehavior: () => ({ left: 0, top: 0 }),
});
2
3
4
5
6
7
8
9
10
路由表:
// 系统路由
const constantRoutes: RouteRecordRaw[] = [
{
path: '/login',
component: () => import('/@/views/login/index.vue'),
name: 'login',
meta: { title: '登录' },
},
{
path: '/',
name: 'Root',
redirect: '/app',
meta: {
title: 'Root',
},
},
// ...accessRoutes,
];
export const publicRoutes = [
{
path: '/redirect',
component: BlankLayout,
children: [
{
path: '/redirect/:path(.*)',
component: () => import('/@/views/redirect/index'),
},
],
},
{
path: '/:pathMatch(.*)',
redirect: '/404',
},
{
path: '/404',
component: () => import('/@/views/404.vue'),
},
];
// 权限路由
export const accessRoutes = [
{
path: '/app',
name: 'app',
component: BasicLayout,
redirect: '/app/chainBrowser',
meta: { title: '管理平台' },
children: [
{
path: '/app/chainBrowser',
component: () => import('/@/views/explorer/index.vue'),
name: 'explorer',
meta: {
title: '菜单1',
auth: ['explorer'], // 菜单权限
},
}
{
path: '/app//access',
name: 'access',
component: () => import('/@/views/access/index.vue')
meta: {
title: '应用1',
auth: ['access'], // 菜单权限
role: 0, // 账户权限 0
},
},
{
path: '/sys/organization',
name: 'organization',
component: () => import('/@/views/organization/index.vue'),
meta: {
title: '菜单2',
role: 1, // 账户权限 1
auth: ['organization'], // 菜单权限
},
},
]
}
]
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
系统基础路由不用管,权限路由通过auth
和role
控制,在路由筛选的时候使用。
路由筛选函数buildRoutesAction
如下:
对于数据 this.role、this.modules,可以简单理解为用户信息中返回的结果
/**
* @name buildRoutesAction
* @description: 构建路由
*/
buildRoutesAction(): RouteRecordRaw[] {
// this.isGetUserInfo = true;
this.setIsGetUserInfo(true);
// 404 路由一定要放在 权限路由后面
let routes: RouteRecordRaw[] = [...constantRoutes, ...accessRoutes, ...publicRoutes];
// 1. 角色权限过滤:0-银行 1-银保监
let filterRoutes = filterRouteByRole(cloneDeep(accessRoutes), this.role);
// 2. 菜单权限过滤:
// 管理员直接跳过
if (this.getIsAdmin === 0) {
const filterRoutesByAuth = filterAsyncRoutes(cloneDeep(filterRoutes), this.modules);
filterRoutes = filterRoutesByAuth;
}
routes = [...constantRoutes, ...filterRoutes, ...publicRoutes];
return routes;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
角色权限过滤函数filterRouteByRole
与菜单权限过滤函数filterAsyncRoutes
,主要做的事情就是递归调用筛选route.meta
中定义的role
及auth
属性值,如下:
未定义的路由不验证,直接跳过
/**
* @name filterAsyncRoutes
* @description: 角色路由过滤 - auth
*/
export const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]): RouteRecordRaw[] => {
const res: RouteRecordRaw[] = [];
routes.forEach((route) => {
// auth
const { auth } = (route.meta as IAuth) || {};
if (!auth) {
if (route.children) {
route.children = filterAsyncRoutes(route.children, roles);
}
res.push(route);
} else {
if (intersection(roles, auth).length > 0) {
if (route.children) {
route.children = filterAsyncRoutes(route.children, roles);
}
res.push(route);
}
}
});
return res;
};
/**
* @name filterRouteByRole
* @description: 账号角色过滤 - role: 0-银行 1-银保监
*/
export const filterRouteByRole = (routes: RouteRecordRaw[], ROLE: number) => {
const filterChildrenByRole = (currentRoutes: RouteRecordRaw[]): RouteRecordRaw[] => {
const result: RouteRecordRaw[] = [];
currentRoutes.forEach((route) => {
// role
const { role } = (route.meta as IAuth) || {};
if (role == undefined || role == ROLE) {
if (route.children) {
route.children = filterChildrenByRole(route.children);
}
result.push(route);
}
});
return result;
};
return filterChildrenByRole(routes);
};
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
新建permission.ts
路由拦截,并在 main.ts
中引入
import { createApp } from 'vue';
import App from './App.vue';
import './router/permission'; //
const app = createApp(App);
app.use(router);
app.mount('#app');
2
3
4
5
6
7
8
9
在router.beforeEach
中进行拦截处理
// permission.ts
import { router } from '.'; // 同目录下引用,已创建的路由实例
const whiteList = ['/login']; // no redirect whitelist
router.beforeEach(async (to: any, _, next) => {
const hasToken = getToken();
if (hasToken) {
// 已登录
if (to.path === '/login') {
next({ path: '/' });
} else {
//是否获取过用户信息
const permissioStore = usePermissioStoreWithOut();
const isGetUserInfo = permissioStore.getIsGetUserInfo;
console.log('isGetUserInfo', isGetUserInfo);
if (isGetUserInfo) {
next();
} else {
// 没有获取,请求数据
await permissioStore.fetchAuths();
// 过滤权限路由
const routes = permissioStore.buildRoutesAction();
// 404 路由一定要放在 权限路由后面
routes.forEach((route) => {
router.addRoute(route);
});
// console.log('routes', routes);
// hack 方法
// 不使用 next() 是因为,在执行完 router.addRoute 后,
// 原本的路由表内还没有添加进去的路由,会 No match
// replace 使路由从新进入一遍,进行匹配即可
next({ ...to, replace: true });
}
}
} else {
// 未登录
if (whiteList.indexOf(to.path) !== -1) {
next();
} else {
next('/login');
}
}
});
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
分析代码逻辑,如下:
hasToken
:判断是否已登录,如果已登录,则进行权限筛选,如果未登录,则进行跳转到登录页面isGetUserInfo
:判断是否获取过用户信息,如果获取过,则进行权限筛选,如果没有获取过,则请求用户信息fetchAuths
(注意这里需要异步await
)- 获取到账户信息后,进行路由筛选
buildRoutesAction
,然后通过router.addRoute(route)
动态构建,需要留意的是next
的使用方式
按照登录流程查看梳理逻辑:
login -> router.beforeEach -> getUserInfo -> buildRoutesAction
# 指令实现
- 对于账号角色权限我们暂时只有
0-银行 1-银保监
,所以我们直接通过指令v-role="0"
使用即可 - 操作权限指令的使用是这样的
v-auth="AuthEnum.user_create"
,需要我们定义对应权限的参数,这里可以结合用户信息返回的权限信息,在和后端协商好之后,同时在前端维护一个权列表,这里我们采用ts的枚举写法。
/**
* @name AuthEnum
* @description 权限,配合指令 v-auth 使用
* @Example v-auth="AuthEnum.user_create"
*/
// 需要说明的是:
// 1. 这里只把有操作权限的接口单独处理
// 2. 对于列表等直接通过接口处拦截,不需要在这里处理
export enum AuthEnum {
/**
* 用户
*/
user_create = '/v1/user/create', // 新增用户
user_update = '/v1/user/update', // 编辑用户
user_delete = '/v1/user/delete', // 删除用户
/**
* 角色
*/
role_create = '/v1/role/create', // 新增角色
role_update = '/v1/role/update', // 修改角色
role_delete = '/v1/role/delete', // 删除角色
// ...
}
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
按照我们的规划,对于权限筛选的核心逻辑我们抽离为hooks
进行使用
function isAuth(el: Element, binding: any) {
const { hasRole } = useRole();
const value = binding.value;
// 过滤 undefined、null
if (value == null) return;
// 权限验证
if (!hasRole(value)) {
const parentNode = el.parentNode;
el.parentNode?.removeChild(el);
// replaceHtml(parentNode as any);
}
}
const mounted = (el: Element, binding: DirectiveBinding<any>) => {
isAuth(el, binding);
};
const authDirective: Directive = {
mounted,
};
export function setupRoleDirective(app: App) {
app.directive('role', authDirective);
}
export default authDirective;
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
useRole hooks
相关代码如下:
/**
* @name useRole
* @description 处理角色权限
*/
import { usePermissioStore } from '/@/store/modules/permission';
export function useRole() {
const permissioStore = usePermissioStore();
function hasRole(value?: string | string[], def = true): boolean {
if (value == null) {
return def;
}
if (typeof value === 'number') {
return permissioStore.getRole === value;
}
return def;
}
return { hasRole };
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
对于replaceHtml
函数这里做下说明,为了优化UI展示,对于Table
组件中的相关按钮,会通过使用——
空值替换处理,而对于整个操作列相关权限都没有的,则会结合computed
动态的删除最后一列的操作列。
// 操作按钮无权限时,替换展示内容
function replaceHtml(parentNode: HTMLElement | null) {
if (!parentNode) return;
const child = document.createElement('span');
// 只过滤 Table里的操作按钮
const classNames = ['ant-space-item', 'ant-table-row-cell-break-word'];
// 用了lodash的intersection方法,进行数组的交集操作验证
const parentNodeText =
intersection(classNames, parentNode?.className?.split(' ')).length > 0 ? '——' : '';
// console.dir(parentNode);
child.innerHTML = parentNodeText;
child.style.color = 'rgba(0,0,0,.08)';
parentNode?.appendChild(child);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
我在项目中对于Table
组件进行了深层次的封装,并把columns
列通过JSX
的语法形式,进行解藕维护,这样就可以更加灵活的对列进行操作,比如添加操作按钮,删除操作按钮,自定义列等等(具体用法可以见我的仓库vite-vue3-ts)。
// <Table
// ref="ELRef"
// :url="fetchApi.list"
// :columns="getColumns"
// />
//
const getColumns = computed(() =>
columnsConfig(refresh).filter((n) => {
// 只针对操作列进行筛选处理
if (n.key === 'action') {
const isAuth = hasPermission(AuthEnum.node_operate);
const role = unref(isAdminNode);
return role && isAuth;
}
return true;
}),
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
对于v-auth
指令的代码实现类似,我们这里看下hooks的区别
// 用了lodash的intersection方法,进行数组的交集操作验证
import intersection from 'lodash-es/intersection';
export function useAuthn() {
const permissioStore = usePermissioStore();
function hasPermission(value?: string | string[], def = true): boolean {
// Visible by default
// 过滤掉 undefined、null
if (value == null) {
return def;
}
// 管理员不验证
if (permissioStore.getIsAdmin === 1) {
return true;
}
if (!isArray(value)) {
return permissioStore.getAuths?.includes(value);
}
if (isArray(value)) {
return intersection(value, permissioStore.getAuths).length > 0;
}
return true;
}
return { hasPermission };
}
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
# 使用方式
# 在模板语法中的使用
<!-- 按钮权限 -->
<template>
<a-button
v-auth="AuthEnum.user_create"
type="primary"
@click="$router.push('/app/user/add')"
>
新增用户
</a-button>
</template>
2
3
4
5
6
7
8
9
10
# 在插槽语法中使用
<template>
<Table
:url="getPublicKeyList"
:columns="getColumns"
:actions="tableActions"
/>
</template>
<script setup lang="ts">
// tableFilterButton最终会在Table组件中通过slot的形式渲染出来
const tableActions = reactive({
type: 'primary',
label: '修改',
role: 1, // 账号角色
auth: AuthEnum.publicKey_update, // 操作权限
onClick: (row) => {
// ...
},
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Table组件部分代码
<template>
<a-table
:class="['ant-table-striped', { border: hasBordered }]"
:rowClassName="(_, index) => (index % 2 === 1 ? 'table-striped' : null)"
:dataSource="dataSource"
:columns="columns"
:rowKey="(record) => record.id"
:pagination="pagination"
:loading="loading"
:scroll="scroll"
@change="handleTableChange"
>
<!-- 其他功能 -->
<!-- 函数式写法自定义 操作列 -->
<template #action="{ record }">
<template v-for="(action, index) in getActions" :key="`${index}-${action.label}`">
<!-- 气泡确认框 -->
<a-popconfirm
v-if="action.enable"
:title="action?.title"
@confirm="action?.onConfirm(record)"
@cancel="action?.onCancel(record)"
>
<a @click.prevent="() => {}" :type="action.type">{{ action.label }}</a>
</a-popconfirm>
<span v-else-if="!action.permission">——</span>
<!-- 按钮 -->
<a v-else @click="action?.onClick(record)" :type="action.type">{{ action.label }}</a>
<!-- 分割线 -->
<a-divider type="vertical" v-if="index < getActions.length - 1" />
</template>
</template>
</a-table>
</template>
<script setup lang="ts">
// action 操作列
const getActions = computed(() => {
return (
(toRaw(props.actions) || [])
// .filter((action) => hasPermission(action.auth) && hasRole(action.role))
.map((action) => {
const { popConfirm } = action;
return {
type: 'link',
...action,
...(popConfirm || {}),
enable: !!popConfirm,
permission: hasPermission(action.auth) && hasRole(action.role),
};
})
);
});
</script>
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
# 在JSX语法中使用
import { AuthEnum } from '/@/enums/authEnum';
import { usePermission } from '/@/hooks/usePermission';
const columns: ColumnProps[] = [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: '操作',
key: 'action',
customRender: ({ record }) => (
<Space>
{hasPermission(AuthEnum.access_update) ? (
<a
onClick={() =>
router.push({ path: '/app/access/update', query: record })
}
>
编辑
</a>
) : (
<span class="delete">——</span>
)}
</Space>
)
}
]
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
# 状态收尾
因为权限相关状态是通过store管理的,所以在登出逻辑中需要清除权限状态
本项目使用的store为 pinia
resetState() {
console.log('resetState');
this.isGetUserInfo = false;
this.isAdmin = 0;
this.auths = [];
this.modules = [];
this.role = 0;
},
2
3
4
5
6
7
8
# 接口过滤
这里直接结合axios
的interceptors.request
拦截器配合处理,前端对请求做个简易的优化处理,当然,真实场景是后端必须要进行验证的,这里我们先只关注前端部分。
const instance = axios.create({
baseURL: BASE_URL,
withCredentials: true,
timeout: 10000,
});
instance.interceptors.request.use(
(config) => {
// 接口权限拦截
const store = usePermissioStoreWithOut();
const { url = '' } = config;
// 过滤白名单、管理员
if (!WhiteList.includes(url) && store.getIsAdmin === 0) {
// 直接走数据的过滤逻辑
if (!store.getAuths.includes(url)) {
// 抛出一个Promise.rejec配合interceptors.response处理
return Promise.reject('没有操作权限');
}
}
// 请求头 token配置
// const token = getToken();
// if (token) {
// config.headers = {
// ...config.headers,
// Authorization: token,
// };
// }
return config;
},
(error) => {
return Promise.reject(error);
},
);
instance.interceptors.response.use(
(response) => {
const res = response.data as ResData;
// 正确状态
if (res.code === 0) {
return res.result || true;
}
// 登录失效
if (res.code === 401) {
useUserStoreWithOut().logout();
}
// 异常
createMessage.error(res.message);
return undefined;
},
(error) => {
console.log('err' + error); // for debug
// 没权限时,不再重复提示
if (error === '没有操作权限') return;
createMessage.error('网络超时,稍后再试吧');
// useUserStoreWithOut().logout();
},
);
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
OK,相关的部分应该都涉及到了,整体轮廓基本就是这样,接下来就是一些细节了,欢迎补充
# 总结下
- 对于路由,必须要结合用户信息的相关权限进行动态构建,保证路由表中的路由必须是有权限的,有效避免地址栏访问等异常情况,做到完整的路由拦截控制。
- 对于按钮,首先前端需要对按钮功能进行定义(如:新增、编辑所关联的
auth
),为了方便维护和统一管理,需要和后端返回的auths
数据保持一致,我们需要维护一张auth
表。为了适应不同的场景我们实现了指令和hooks
进行权限控制。 - 对于接口,配合
axios
的interceptors.request
拦截器处理,后端肯定要做处理,前端更多的是对展示层的优化处理。 - store状态独立维护,退出要及时clear