基于Vue的权限控制系统的解决方案

目前公司内部,基于 Vue 全家桶的项目还是比较多的。在入职公司后,刚好团队在开发内部的一个运营管理的 MIS 系统。在项目初期,对于系统的权限控制,比较粗糙。随着 MIS 系统的内容越来越多,对于系统的用户权限便不得不去进行限制区分了。

公司内部项目最初是采用的,先加载所有的路由,只在菜单上做了显示与隐藏,这个显而易见的风险就是研发人员可以通过拼接路由的形式,访问到本来没有权限访问的内容,这样的权限设置对于研发而言,形同虚设。但是,由于是内部系统,最初的这个方案,就一直延续下来了。随着 MIS 系统内容的丰富,许多核心的数据开始增加,以前的这种方案带来的风险,逐渐被放大。于是,针对权限控制这一方案,开始了自我探索。

iview-admin的路由权限

iview-admin采用的是挂载全部路由,然后在路由的全局守卫进行判断。router.js

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
import Main from '@/views/Main.vue';

// 不作为Main组件的子页面展示的页面单独写,如下
export const loginRouter = {
path: '/login',
name: 'login',
meta: {
title: 'Login - 登录'
},
component: () => import('@/views/login.vue')
};

export const page404 = {
path: '/*',
name: 'error-404',
meta: {
title: '404-页面不存在'
},
component: () => import('@/views/error-page/404.vue')
};

// 作为Main组件的子页面展示并且在左侧菜单显示的路由写在appRouter里
export const appRouter = [
{
path: '/access',
icon: 'key',
name: 'access',
title: '权限管理',
component: Main,
children: [
{ path: 'index', title: '权限管理', name: 'access_index', component: () => import('@/views/access/access.vue') }
]
},
{
path: '/access-test',
icon: 'lock-combination',
title: '权限测试页',
name: 'accesstest',
access: 0,
component: Main,
children: [ //注意这里,有一个字段 access,表示是否需要进行权限控制的字段。
{ path: 'index', title: '权限测试页', name: 'accesstest_index', access: 0, component: () => import('@/views/access/access-test.vue') }
]
},
];

...

// 所有上面定义的路由都要写在下面的routers里
export const routers = [
loginRouter,
otherRouter,
preview,
locking,
...appRouter,
page500,
page403,
page404
];

然后路由处理方法:

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
import Vue from 'vue';
import iView from 'iview';
import Util from '../libs/util';
import VueRouter from 'vue-router';
import Cookies from 'js-cookie';
import {routers, otherRouter, appRouter} from './router';

Vue.use(VueRouter);

// 路由配置
const RouterConfig = {
// mode: 'history',
routes: routers
};

export const router = new VueRouter(RouterConfig);

router.beforeEach((to, from, next) => {
iView.LoadingBar.start();
Util.title(to.meta.title, router.app);
if (Cookies.get('locking') === '1' && to.name !== 'locking') { // 判断当前是否是锁定状态
next({
replace: true,
name: 'locking'
});
} else if (Cookies.get('locking') === '0' && to.name === 'locking') {
next(false);
} else {
if (!Cookies.get('user') && to.name !== 'login') { // 判断是否已经登录且前往的页面不是登录页
next({
name: 'login'
});
} else if (Cookies.get('user') && to.name === 'login') { // 判断是否已经登录且前往的是登录页
Util.title();
next({
name: 'home_index'
});
} else {
//
const curRouterObj = Util.getRouterObjByName([otherRouter, ...appRouter], to.name);
if (curRouterObj && curRouterObj.title) {
Util.title(curRouterObj.title, router.app);
}
// 根据路由表里的 access 字段
if (curRouterObj && curRouterObj.access !== undefined) { // 需要判断权限的路由
if (curRouterObj.access === parseInt(Cookies.get('access'))) {
Util.toDefaultPage([otherRouter, ...appRouter], to.name, router, next); // 如果在地址栏输入的是一级菜单则默认打开其第一个二级菜单的页面
} else {
next({
replace: true,
name: 'error-403'
});
}
} else { // 没有配置权限的路由, 直接通过
Util.toDefaultPage([...routers], to.name, router, next);
}
}
}
});

router.afterEach((to) => {
Util.openNewPage(router.app, to.name, to.params, to.query);
iView.LoadingBar.finish();
window.scrollTo(0, 0);
});

通过在全局守卫来进行判断,先判断用户是否登录,如果未登录,就跳转到登录页。根据cookie里数据,如果用户已登陆过,且要跳转的地址为登录页,则直接跳到 hone_index 页面。登录后,先判断路由是否包含 access 字段,该字段是用来针对用户进行权限控制权限使用的。如果路由中没有该字段,则表示该路由无需进行权限控制。如果路由进行了权限控制,则进行判断,有权限则进入,无权限则进入到 403 页面。
诚然这种方式,还是可以解决目前我们项目目前的问题,但是还是有问题的:

  • 路由和菜单混合,因为有些路由不需要作为菜单显示,最后导致整个路由信息比较臃肿;
  • 菜单文字和icon在前端维护,如需修改,需要重新部署上线;
  • 每次跳转都需要在全局路由守护中进行判断
  • 系统每次加载都需要挂载全部路由,对于无权限访问的路由,显然有点多余

vue-element-admin

github 上 star 数目较多的一个基于 element-ui 的后台管理系统 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
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

/* Layout */
import Layout from '@/views/layout/Layout'

/* Router Modules */
import componentsRouter from './modules/components'
import chartsRouter from './modules/charts'
import tableRouter from './modules/table'
import nestedRouter from './modules/nested'

/** note: Submenu only appear when children.length>=1
* detail see https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html
**/

/**
* hidden: true if `hidden:true` will not show in the sidebar(default is false)
* alwaysShow: true if set true, will always show the root menu, whatever its child routes length
* if not set alwaysShow, only more than one route under the children
* it will becomes nested mode, otherwise not show the root menu
* redirect: noredirect if `redirect:noredirect` will no redirect in the breadcrumb
* name:'router-name' the name is used by <keep-alive> (must set!!!)
* meta : {
roles: ['admin','editor'] will control the page roles (you can set multiple roles)
title: 'title' the name show in submenu and breadcrumb (recommend set)
icon: 'svg-name' the icon show in the sidebar
noCache: true if true, the page will no be cached(default is false)
breadcrumb: false if false, the item will hidden in breadcrumb(default is true)
}
**/
export const constantRouterMap = [
{
path: '/redirect',
component: Layout,
hidden: true,
children: [
{
path: '/redirect/:path*',
component: () => import('@/views/redirect/index')
}
]
},
{
path: '/login',
component: () => import('@/views/login/index'),
hidden: true
},

....

{
path: '',
component: Layout,
redirect: 'dashboard',
children: [
{
path: 'dashboard',
component: () => import('@/views/dashboard/index'),
name: 'Dashboard',
meta: { title: 'dashboard', icon: 'dashboard', noCache: true }
}
]
},
{
path: '/documentation',
component: Layout,
redirect: '/documentation/index',
children: [
{
path: 'index',
component: () => import('@/views/documentation/index'),
name: 'Documentation',
meta: { title: 'documentation', icon: 'documentation', noCache: true }
}
]
},
{
path: '/guide',
component: Layout,
redirect: '/guide/index',
children: [
{
path: 'index',
component: () => import('@/views/guide/index'),
name: 'Guide',
meta: { title: 'guide', icon: 'guide', noCache: true }
}
]
}
]

export default new Router({
// mode: 'history', // require service support
scrollBehavior: () => ({ y: 0 }),
routes: constantRouterMap
})

export const asyncRouterMap = [
{
path: '/permission',
component: Layout,
redirect: '/permission/index',
alwaysShow: true, // will always show the root menu
meta: {
title: 'permission',
icon: 'lock',
roles: ['admin', 'editor'] // you can set roles in root nav
},
children: [
{
path: 'page',
component: () => import('@/views/permission/page'),
name: 'PagePermission',
meta: {
title: 'pagePermission',
roles: ['admin'] // or you can only set roles in sub nav
}
},
{
path: 'directive',
component: () => import('@/views/permission/directive'),
name: 'DirectivePermission',
meta: {
title: 'directivePermission'
// if do not set roles, means: this page does not require permission
}
}
]
},

{
path: '/icon',
component: Layout,
children: [
{
path: 'index',
component: () => import('@/views/svg-icons/index'),
name: 'Icons',
meta: { title: 'icons', icon: 'icon', noCache: true }
}
]
},

....

{ path: '*', redirect: '/404', hidden: true }
]

路由处理方法permission.js

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
...
router.beforeEach((to, from, next) => {
NProgress.start() // start progress bar
if (getToken()) { // determine if there has token
/* has token*/
if (to.path === '/login') {
next({ path: '/' })
NProgress.done() // if current page is dashboard will not trigger afterEach hook, so manually handle it
} else {
if (store.getters.roles.length === 0) { // 判断当前用户是否已拉取完user_info信息
store.dispatch('GetUserInfo').then(res => { // 拉取user_info
const roles = res.data.roles // note: roles must be a array! such as: ['editor','develop']
store.dispatch('GenerateRoutes', { roles }).then(() => { // 根据roles权限生成可访问的路由表
router.addRoutes(store.getters.addRouters) // 动态添加可访问路由表
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成 ,set the replace: true so the navigation will not leave a history record
})
}).catch((err) => {
store.dispatch('FedLogOut').then(() => {
Message.error(err)
next({ path: '/' })
})
})
} else {
// 没有动态改变权限的需求可直接next() 删除下方权限判断 ↓
if (hasPermission(store.getters.roles, to.meta.roles)) {
next()
} else {
next({ path: '/401', replace: true, query: { noGoBack: true }})
}
// 可删 ↑
}
}
} else {
/* has no token*/
if (whiteList.indexOf(to.path) !== -1) { // 在免登录白名单,直接进入
next()
} else {
next(`/login?redirect=${to.path}`) // 否则全部重定向到登录页
NProgress.done() // if current page is login will not trigger afterEach hook, so manually handle it
}
}
})

router.afterEach(() => {
NProgress.done() // finish progress bar
})

相对于 iview-admin, vue-element-admin 的路由权限控制应该是相对更加成熟一点。对于不需要权限控制的路由,直接在 constantRouterMap 里设置好了,并在初始化时挂载到应用上了。对于需要根据用户访问的权限,可以通过 asyncRouterMap 中设置路由的权限,然后通过 addRoutes 动态挂在到 router 上。

当然作者还用了指令权限方式。

基于自有项目的解决方案

在结合自身的项目的特点以及需求的基础上,后来总结了一套符合自身的开发方案,反过来与已有的方案对比,感觉有共同的地方,也有与之不同的地方,总而言之,是目前最符合项目的方案吧。

同上 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
24
25
26
27
28
import Vue from 'vue';
import Router from 'vue-router';

Vue.use(Router);
const Login = () => import('../page/login/Login.vue');
const Home = () => import('../page/Home.vue');
const Forbidden = () => import('../page/403.vue');
const Dashboard = () => import('../page/dashboard/Dashboard.vue');
const routes = [
{
path: '/',
redirect: '/login'
},{
path: '/login',
component: Login
},{
path: '/403',
component: Forbidden
},
{
path: '/dashboard',
component: Dashboard,
},
];
export default new Router({
mode: 'history',
routes: routes,
})

另外还需一个组件文件(component.js):

1
2
3
4
5
6
7
8
9
const home = () => import('../page/Home.vue');
const home1 = () => import('../page/Home1.vue');
const home2 = () => import('../page/Home2.vue');

export default {
home,
home2,
home3,
};

路由和菜单权限通过服务端放回,在路由钩子函数(导航守卫)里进行操作:

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
// 设置一个标志位用来标识是否需要从服务端获取菜单和路由权限
let isFetchRemote = true;
//使用钩子函数对路由进行权限跳转
router.beforeEach((to, from, next) => {
const username = sessionStorage.getItem('username');
if(!username && to.path !== '/login'){
next({path: '/login'});
}
else if (isFetchRemote && to.path !== '/login') {
// 此处为一个ajax请求, ajaxPost 假定是一个封装好的 ajax 方法。
ajaxPost('/xxxx/getMenuData').then(res =>{
if (res.status === 200 && res.data.errno === 0) {
isFetchRemote = false;
const routerData = res.data.result;

// do something 处理路由数据
// 这个地方比较关键的是,根据服务端返回的路由数据,和前端组件进行匹配。
// 然后将处理后的路由,动态加入到router
// router数据类似于
// [
// {
// component: 'home', // 这个地方就是需要和component.js进行匹配,将组件匹配到对应的路由上
// path: '/home',
// meta: {
// title: 'home'
// }
// },
// {
// component: 'home1',
// path: 'home1',
// meta: {
// title: 'home1',
// }
// }
// ]
//
router.addRoutes([routeData].concat([
{ name:'404',path:'/404',component: },
{ path:'*',redirect: '/404' }]));
router.push({
path: to.path,
query: to.query
});
}
else {
isFetchRemote = true;
}
next();
})
.catch(err => {
console.log(err);
});
}
else {
next();
}
});

该方案与 vue-element-admin 方案有很大的类似的地方,不同的在于,我们的路由权限完全由服务端返回,所以不会存在用户能访问超过权限之外的页面的情况。
同时,该方案有一个最大的弊端,就是路由和组件名与后端是强耦合的,同时,有个待改进的地方,就是加入用户拥有某个菜单权限,用户直接在浏览器输入后,进入登录页再回跳到该页面(vue-element-admin已经实现了,我们的改造成本也不大^_^)。目前该方案已在内部项目中成功的实施了。后续再考虑下,是否有更好的优化方案。