Vue前端访问控制方案
1、前端访问控制的常规处理方法
前端访问控制,一般针对界面元素dom element进行可见属性或enable属性进行控制,有权限的,相关元素可见或使能;没权限的,相关元素不可见或失能。这样用户可以明确哪些是无权访问的。可见属性要比使能属性更广泛,这是每个dom元素都有的属性。
当然前端控制仅仅是整体访问控制的一部分,后端还需要进一步针对接口访问进行鉴权。因为通过编辑浏览器的界面元素的属性,可以绕过前端控制。
在vue中,也有通过控制路由来实现访问控制的,但没有控制界面元素的情况下,用户体验不是很好。
本文给出了vue框架下前端访问控制的整体方案。
2、总体方案
在用户登录时,或权限变更时,后端通过接口将权限树发给前端。为了减少不必要的数据传输,后端发出的权限树仅包括有权限的功能项,即前端收到的权限树的各个节点都是有权限的功能项。
权限树节点的数据部分即为功能项的权限信息,包括两个关键字段:url和domkey。url是后端自己使用,在aop鉴权切面类中,拦截非法的接口访问。domkey是给前端使用的,即dom element的id值,domkey的确定需要前后端协商一致,不能搞错。
domkey在同一个路径上,不允许重复;不同路径,允许重复。所谓路径,是从根节点开始,到该节点的一系列节点组成的树杈。当然,没有必要的话,domkey最好不重复。同一个界面视图范围的各子节点的domkey也不允许重复。
前端本地存储用户token和权限树json字符串,如果本地这个存储信息存在,重新打开浏览器,可以免登录。(仅本地token有效,不能完全保证token真的有效,如后端重启服务器、token过期等导致token失效,前端通过http访问时,仍然会跳到登录页面)。
登录成功后,将token和权限树json字符串保存到本地存储。
权限发生变更时,通过response拦截器,检查有无附加信息,如有需要,更新token和权限树json字符串。
前端开发一个权限树的管理的js文件,用于权限树json对象的访问,权限树json字符串被转换成权限树json对象。
开发前端页面vue文件时,需要进行权限控制的dom element,使用下列属性:
class="permissions" id="相关domkey"
通过class来标识该界面元素是与访问控制相关的,目的是确定需要进行权限控制的组件范围,id即为该功能项对应的domkey。
然后,使用一个公共权限设置方法,来统一处理权限相关的界面元素。
由于vue的组件style,可以有scoped属性设置,此时,在app.vue中,就不能访问到相关dom element的class,局部式样渲染后,在外部被改写,因此,在scoped限制的情况下,需要在scoped起作用的vue组件中,也要调用公共权限设置方法。另外,scoped的限制,恰好使得相同domkey的节点,可以通过上级节点domkey来加以区分。这样,就用统一的方法,实现了前端页面的访问控制。
3、方案实现
3.1、功能项的表结构定义
drop table if exists `function_tree`; create table `function_tree` ( `func_id` int(11) not null default 0 comment '功能id', `func_name` varchar(100) not null default '' comment '功能名称', `parent_id` int(11) not null default 0 comment '父功能id', `level` tinyint(4) not null default 0 comment '功能所在层级', `order_no` int(11) not null default 0 comment '显示顺序', `url` varchar(80) not null default '' comment '访问接口url', `dom_key` varchar(80) not null default '' comment 'dom对象的id', `remark` varchar(200) not null default '' comment '备注', -- 记录操作信息 `operator_name` varchar(80) not null default '' comment '操作人账号', `delete_flag` tinyint(4) not null default 0 comment '记录删除标记,1-已删除', `create_time` datetime(3) not null default now(3) comment '创建时间', `update_time` datetime(3) default null on update now(3) comment '更新时间', primary key (`func_id`) ) engine = innodb default charset = utf8 comment ='功能表';
如有需要,可以增加icon字段,用于前端树节点的显示。
3.2、后端权限树的输出
后端在登录成功后,给前端发送token和权限树json字符串。
关于树节点的生成,可参阅:java通用树结构数据管理---https://www.cnblogs.com/alabo1999/p/14928380.html。里面有关于权限树的例子。
为了方便前端管理,这里修改权限树的输出,将根节点也一并输出到前端。
在管理员修改用户权限后,动态权限更新,可通过附加信息,给前端发送token和权限树json字符串。参阅:spring boot动态权限变更实现的整体方案---https://www.cnblogs.com/alabo1999/p/14948914.html。
3.3、前端本地缓存
vue项目中,新建/src/store目录,创建inde.js文件。代码如下:
import vue from 'vue'; import vuex from 'vuex'; vue.use(vuex); const store = new vuex.store({ state: { // 存储token token: localstorage.getitem('token') ? localstorage.getitem('token') : '', // 存储权限树 rights: localstorage.getitem('rights') ? localstorage.getitem('rights') : '' }, mutations: { // 修改token,并将token存入localstorage changelogin (state, user) { if(user.token){ state.token = user.token; localstorage.setitem('token', user.token); } if (user.rights){ state.rights = user.rights; localstorage.setitem('rights', user.rights); } } } }); export default store;
3.4、创建权限管理模块
vue项目中,新建/src/common目录,创建treenode.js文件。代码如下:
/** * 处理树结构数据,这里主要指功能权限树 * 权限树的结构如下: * [ * { * nodedata:{ * funcid:1, //功能id * funcname:"", //功能名称 * parentid:0, //父节点id * level:1, //功能所在层级 * orderno:2, //显示顺序 * url:"", //访问接口url * domkey:"" //dom对象的id * }, * children:[ * nodedata:{...}, * children:[...] * ] * }, * { * nodedata:{...}, * children:[...] * } * ] */ var treenode = { //功能树 rightstree:null, /** * 将权限树的json字符串加载到树对象上 * @param {权限树的json字符串} rights */ loaddata(rights){ //将缓存的json字符串,转为json对象,为一级树节点的数组 var treenode = json.parse(rights); return treenode; }, /** * 在给定树上,找到上级domkey为superdomkey的给定domkey的树节点 * 不同子树如果存在子节点domkey重复的情况,也可以区分 * @param {给定树节点} rightstree * @param {上级的domkey} superdomkey * @param {树节点的domkey} domkey */ lookupnodebydomkeys(rightstree,superdomkey,domkey){ var node = null; var supernode = null; //先寻找superdomkey if(superdomkey != ""){ //如果上级对象的domkey非空 supernode = this.lookupnodebydomkey(rightstree,superdomkey); } if (supernode != null){ //如果上级节点非空,或已找到,则在子树上搜索,可加快搜索速度,并且可避免子节点domkey重复的情况 node = this.lookupnodebydomkey(supernode,domkey); }else{ node = this.lookupnodebydomkey(rightstree,domkey); } return node; }, /** * 在给定的子树中,搜索指定domkey的树节点 * @param {子树} rightstree * @param {domkey} domkey */ lookupnodebydomkey(rightstree,domkey){ var node = null; var functioninfo = rightstree.nodedata; //先查找自身的数据 if (functioninfo.domkey == domkey){ //如果找到,则返回 return rightstree; } //搜索子节点 for (var i = 0; i < rightstree.children.length; i++){ var item = rightstree.children[i]; node = this.lookupnodebydomkey(item,domkey); if (node != null){ break; } } return node; } } export default treenode;
如果domkey确保唯一的话,使用map可能是访问效率更高的方案。这里还是使用树型结构来管理权限树。
3.5、创建公共方法模块
vue项目中,在/src/common目录下,创建commonfuncs.js文件。代码如下:
import treenode from './treenode.js' var commonfuncs = { checkrights(superdomkey){ //先加载权限树 if (treenode.rightstree == null){ let rights = localstorage.getitem('rights'); if (rights === null || rights === ''){ //没有权限树 return; } //加载权限树 treenode.rightstree = treenode.loaddata(rights); } //获取class包含permissions的所有dom对象 var elements = document.getelementsbyclassname('permissions'); for(var i = 0; i < elements.length; i++){ var element = elements[i]; if (element.id != undefined) { var node = null; //如果对象有id,检查权限 if (superdomkey == null || superdomkey == undefined){ //如果未指定上级domkey,直接查找 node = treenode.lookupnodebydomkey(treenode.rightstree,element.id); }else{ //指定上级domkey node = treenode.lookupnodebydomkeys(treenode.rightstree,superdomkey,element.id) } if (node != null && node != undefined){ //包含节点 element.style.display = ""; console.log('has rights :'+element.id); }else{ element.style.display="none"; console.log('has not rights :'+element.id); } } } } }; export default commonfuncs;
checkrights方法,参数为superdomkey,即指定上级节点的domkey,允许为空或空串,相当于不指定。其查找当前页面或scoped范围的文档中,class名称包含permissions的所有dom元素。取得dom的id,即功能节点的domkey,如果在权限树中存在对应节点,则表示有权限;否则表示无权限。(注意:前端的权限树都是有权限的功能节点)。
3.6、修改main.js
修改main.js文件,使得公共模块生效。代码如下:
// the vue build version to load with the `import` command // (runtime-only or standalone) has been set in webpack.base.conf with an alias. import vue from 'vue' import app from './app' import router from './router' import store from './store' import elementui from 'element-ui' import 'element-ui/lib/theme-chalk/index.css' import md5 from 'js-md5'; import axios from 'axios' import vueaxios from 'vue-axios' import treenode_ from './common/treenode.js' import commonfuncs_ from './common/commonfuncs.js' import instance_ from './api/index.js' import global_ from '../config/global.js' vue.use(vueaxios,axios) vue.prototype.$md5 = md5 vue.prototype.treenode = treenode_ vue.prototype.$baseurl = process.env.api_root vue.prototype.instance = instance_ //axios实例 vue.prototype.global = global_ vue.prototype.commonfuncs = commonfuncs_ vue.use(elementui) vue.config.productiontip = false /* eslint-disable no-new */ var vue = new vue({ el: '#app', router, store, components: { app }, template: '<app/>', render:h=>h(app) }) export default vue
引入了commonfuncs和treenode全局对象,可以在vue文件中使用。
3.7、组件示例
侧边导航栏,与权限控制相关,可以作为示例。文件为left.vue,代码如下:
<template> <div class="left-sidebar"> <el-menu :default-openeds="['1']" style="background:#f0f6f6;"> <el-submenu index="1"> <el-menu-item-group > <el-menu-item index="1-1"> <router-link class="menu" tag="li" to="/home" exact-active-class="true" id="homemenu" active-class="_active"> <i class="el-icon-s-home"></i>首页 </router-link> </el-menu-item> <el-submenu index="1-2" id="usermanagementmain"> <template slot="title" ><i class="el-icon-user-solid"></i>用户管理</template> <el-menu-item index="1-2-1" class="permissions" id="usermanagementsub"> <router-link class="menu" tag="li" to="/usermanagement"> <i class="el-icon-user"></i>用户管理 </router-link> </el-menu-item> <el-menu-item index="1-2-2" class="permissions" id="changepassword"> <router-link class="menu"tag="li" to="/changepassword"> <i class="el-icon-key"></i>修改密码 </router-link> </el-menu-item> </el-submenu> <el-menu-item index="1-3" class="permissions" id="questionnairemanagement"> <router-link class="menu" tag="li" to="/questionnairemanagement"> <i class="el-icon-document"></i>问卷内容管理 </router-link> </el-menu-item> <el-submenu index="1-4" class="permissions" id="issuemanagementmain"> <template slot="title"><i class="el-icon-message"></i>问卷发布管理</template> <el-menu-item index="1-4-1" class="permissions" id="issuemanagementsub"> <router-link class="menu" tag="li" to="/issuemanagement"> <i class="el-icon-phone"></i>发布问卷查询 </router-link> </el-menu-item> <el-menu-item index="1-4-2" class="permissions" id="issuetaskquery"> <router-link class="menu" tag="li" to="/issuetaskquery"> <i class="el-icon-tickets"></i>发布任务查询 </router-link> </el-menu-item> </el-submenu> <el-menu-item index="1-5" class="permissions" id="answersheetmanagement"> <router-link class="menu" tag="li" to="/answersheetmanagement"> <i class="el-icon-receiving"></i>答卷管理 </router-link> </el-menu-item> </el-menu-item-group> </el-submenu> </el-menu> </div> </template> <style> /* 去掉右边框 */ .el-menu { border-right: none; } .el-submenu { background-color: rgb(231, 235, 220) ; } </style>
注意那些:class="permissions" id=“xxx”的dom元素,基本都是el-menu-item。这里,将scoped去掉了,因为菜单项,目前只有侧边导航栏在使用。
3.7、修改app.vue
app.vue,作为应用页面组件的总成,在里面进行总的权限控制。代码如下:
<template> <div id="app"> <!-- 其他页 --> <el-container style="min-height: calc(100% - 50px);" v-if="$route.meta.keepalive"> <!-- 无头部导航栏 --> <el-container> <el-aside :style="{width:collpasewidth}"> <!-- 侧边栏 --> <keep-alive> <left></left> </keep-alive> </el-aside> <el-main> <!-- body --> <router-view></router-view> </el-main> </el-container> <!-- 无足部 --> </el-container> <!-- 登录页 --> <router-view v-if="!$route.meta.keepalive"></router-view> </div> </template> <script> import left from './components/left.vue' export default { name: 'app', components: { left: left }, data(){ return { collpasewidth:200 } }, mounted:function(){ this.commonfuncs.checkrights(); }, methods: { } } </script> <style> #app { font-family: 'avenir', helvetica, arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin-top: 60px; } </style>
在页面加载时,调用commonfuncs.checkrights()方法,进行权限控制。
3.8、测试一下
3.8.1、获取权限树数据
登录成功后,后端输出的权限树数据如下:
{ rights = { "nodedata": { "funcid": 0, "funcname": "root", "parentid": -1, "level": 0, "orderno": 0, "url": "", "domkey": "" }, "children": [{ "nodedata": { "funcid": 1, "funcname": "用户管理一级菜单", "parentid": 0, "level": 1, "orderno": 0, "url": "", "domkey": "usermanagementmain" }, "children": [{ "nodedata": { "funcid": 3, "funcname": "修改密码", "parentid": 1, "level": 2, "orderno": 1, "url": "/userman/changepassword", "domkey": "changepassword" }, "children": [] }] }, { "nodedata": { "funcid": 10, "funcname": "问卷内容管理一级菜单", "parentid": 0, "level": 1, "orderno": 1, "url": "", "domkey": "questionnairemanagement" }, "children": [{ "nodedata": { "funcid": 11, "funcname": "新增问卷", "parentid": 10, "level": 2, "orderno": 0, "url": "/questionnaireman/addquestionnaire", "domkey": "addquestionnaire" }, "children": [] }, { "nodedata": { "funcid": 12, "funcname": "编辑问卷", "parentid": 10, "level": 2, "orderno": 1, "url": "/questionnaireman/editquestionnaire", "domkey": "editquestionnaire" }, "children": [] }, { "nodedata": { "funcid": 13, "funcname": "查询问卷", "parentid": 10, "level": 2, "orderno": 2, "url": "/questionnaireman/queryquestionnaires", "domkey": "queryquestionnaire" }, "children": [] }, { "nodedata": { "funcid": 14, "funcname": "复制新建问卷", "parentid": 10, "level": 2, "orderno": 3, "url": "", "domkey": "copyaddquestionnaire" }, "children": [] }, { "nodedata": { "funcid": 15, "funcname": "浏览问卷", "parentid": 10, "level": 2, "orderno": 4, "url": "/questionnaireman/previewquestionnaire", "domkey": "browsequestionnaire" }, "children": [] }, { "nodedata": { "funcid": 16, "funcname": "提交审核", "parentid": 10, "level": 2, "orderno": 5, "url": "/questionnaireman/submitaduit", "domkey": "submitaudit" }, "children": [] }, { "nodedata": { "funcid": 18, "funcname": "作废问卷", "parentid": 10, "level": 2, "orderno": 7, "url": "/questionnaireman/cancelquestionnaire", "domkey": "cancelquestionnaire" }, "children": [] }] }, { "nodedata": { "funcid": 20, "funcname": "问卷发布管理一级菜单", "parentid": 0, "level": 1, "orderno": 2, "url": "", "domkey": "issuemanagementmain" }, "children": [{ "nodedata": { "funcid": 21, "funcname": "发布管理二级菜单", "parentid": 20, "level": 2, "orderno": 0, "url": "", "domkey": "issuemanagementsub" }, "children": [] }, { "nodedata": { "funcid": 22, "funcname": "发布任务查询", "parentid": 20, "level": 2, "orderno": 1, "url": "", "domkey": "issuetaskquery" }, "children": [] }] }, { "nodedata": { "funcid": 40, "funcname": "答卷管理一级菜单", "parentid": 0, "level": 1, "orderno": 3, "url": "", "domkey": "answersheetmanagement" }, "children": [{ "nodedata": { "funcid": 41, "funcname": "查询答卷记录", "parentid": 40, "level": 2, "orderno": 0, "url": "/answersheetman/queryanswertask", "domkey": "queryanswersheet" }, "children": [] }, { "nodedata": { "funcid": 42, "funcname": "回收记录明细", "parentid": 40, "level": 2, "orderno": 1, "url": "/answersheetman/getanswersubmitdetail", "domkey": "recoverydetail" }, "children": [] }, { "nodedata": { "funcid": 43, "funcname": "答卷统计", "parentid": 40, "level": 2, "orderno": 2, "url": "/answersheetman/querystatresult", "domkey": "answersheetstat" }, "children": [] }, { "nodedata": { "funcid": 44, "funcname": "答卷原始记录", "parentid": 40, "level": 2, "orderno": 3, "url": "/answersheetman/queryoriginalanswer", "domkey": "queryoriginalanswer" }, "children": [] }] }] }, token = 873820ba39e64005bcce3e54a830ab2c }
这些功能项中,有些与导航栏有关,还有一些是页面的按钮或链接,在示例中没有用到。
3.8.2、制作首页
制作一个简单的首页home.vue,代码如下:
<template> <div id="home"> <h4>欢迎使用</h4> <h3>xx系统</h3> </div> </template>
3.8.3、简单设置路由导航文件
修改/src/router/index.js文件,代码如下:
import vue from 'vue' import router from 'vue-router' import helloworld from '@/components/helloworld' import home from '@/components/home.vue' import login from '@/components/login/login.vue' vue.use(router) const router = new router({ routes: [ { path: '/home', name: 'home', component: home, meta: { keepalive: true } }, { path: '/login', name: 'login', component: login, meta: { keepalive: false } }, ] }) // 导航守卫 // 使用 router.beforeeach 注册一个全局前置守卫,判断用户是否登陆 router.beforeeach((to, from, next) => { if (to.path === '/login') { next(); } else { let token = localstorage.getitem('token'); if (token === null || token === '') { next('/login'); } else { if (to.path === '/'){ next('/home'); }else{ next(); } } } }); export default router;
3.8.4、导航栏效果测试
现在运行vue,"npm run dev",然后显示首页,并用f12显示调式信息:
侧边栏页面显示如下:
浏览器的调试器的控制台输出信息为:
说明,domkey为usermangementsub的dom元素没有操作权限,与侧边栏的效果一致。
3.8.5、scoped情况测试
login.vue,使用了scoped,作为示例,现在将登录按钮,进行权限控制,修改如下:
<el-form-item> <el-button type="primary" class="permissions" id="login" style="width:160px" @click="submitform('form')">登录</el-button> </el-form-item>
在login.vue的script的mounted方法中,增加权限控制代码:
mounted:function(){ //页面加载时,显示验证码 this.getverifycode(); this.commonfuncs.checkrights(); },
由于domkey为login的,没有在权限树中,故其加入权限控制集合,又没有被授权,则该按钮应该不可见。
运行测试,显示登录页,效果图如下:
登录按钮不可见了,与预期效果一致。
3.9、登录成功后保存信息
登录成功后,将后端发生过来的token和权限树保存起来,并将json字符串转为json对象。
代码如下:
submitform(formname) { let _this = this; this.$refs[formname].validate(valid => { // 验证通过为true,有一个不通过就是false if (valid) { // 通过的逻辑 let passwd = this.$md5(this.form.password); this.instance.userlogin(this.$baseurl,{ loginname:_this.form.username, password:passwd, verifycode:_this.form.verifycode }).then(res => { console.log(res.data); if (res.data.code == this.global.sucessrequstcode){ //如果登录成功 _this.usertoken = res.data.data.token; _this.rights = res.data.data.rights; //更新权限树 this.treenode.rightstee = this.treenode.loaddata(_this.rights); console.log(this.treenode.rightstee) // 将用户token和权限树保存到vuex中 _this.changelogin({ token: _this.usertoken, rights: _this.rights}); _this.$router.push('/home'); //alert('登陆成功'); }else{ alert(res.data.message); } }).catch(error => { alert('账号或密码错误'); console.log(error); }); } else { console.log('验证失败'); return false; } }); },
3.10、权限动态更新的拦截处理
根据权限动态更新方案,管理员修改用户权限后,该用户第一次访问后端接口,返回信息中可能会携带附加信息。这个可能在任何返回json格式数据的接口中发生。因此,可使用拦截器,来进行统一处理。
import axios from 'axios'; import router from '../router' import vue from 'vue'; import vuex from 'vuex'; import treenode from '../common/treenode.js' const instance = axios.create({ timeout: 60000, headers: { 'content-type': "application/json;charset=utf-8" } }); //token相关的response拦截器 instance.interceptors.response.use(response => { if (response) { switch (response.data.code) { case 3: //token为空 case 4: //token过期 case 5: //token不正确 localstorage.clear(); //删除用户信息 //要跳转登陆页 alert('token失效,请重新登录!'); router.replace({ path: '/login', }); break; default: break; } if(response.data.additional){ //如果包含附加信息 var data = {}; if(response.data.additional.token){ //如果包含token data.token = response.data.additional.token; localstorage.setitem('token', data.token); } if(response.data.additional.rights) { data.rights = response.data.additional.rights; localstorage.setitem('rights', data.rights); //刷新权限树 treenode.rightstree = treenode.loaddata(data.rights); } } } return response; }, error => { return promise.reject(error.response.data.message) //返回接口返回的错误信息 })