前端访问控制,通常针对界面元素dom element进行可见属性或enable属性进行控制,有权限的,相关元素可见或使能;没权限的,相关元素不可见或失能。这样用户能够明确哪些是无权访问的。可见属性要比使能属性更普遍,这是每一个dom元素都有的属性。css
固然前端控制仅仅是总体访问控制的一部分,后端还须要进一步针对接口访问进行鉴权。由于经过编辑浏览器的界面元素的属性,能够绕过前端控制。html
在Vue中,也有经过控制路由来实现访问控制的,但没有控制界面元素的状况下,用户体验不是很好。前端
本文给出了Vue框架下前端访问控制的总体方案。vue
在用户登陆时,或权限变动时,后端经过接口将权限树发给前端。为了减小没必要要的数据传输,后端发出的权限树仅包括有权限的功能项,即前端收到的权限树的各个节点都是有权限的功能项。node
权限树节点的数据部分即为功能项的权限信息,包括两个关键字段:url和domKey。url是后端本身使用,在AOP鉴权切面类中,拦截非法的接口访问。domKey是给前端使用的,即dom element的id值,domKey的肯定须要先后端协商一致,不能搞错。webpack
domKey在同一个路径上,不容许重复;不一样路径,容许重复。所谓路径,是从根节点开始,到该节点的一系列节点组成的树杈。固然,没有必要的话,domKey最好不重复。同一个界面视图范围的各子节点的domKey也不容许重复。ios
前端本地存储用户token和权限树JSON字符串,若是本地这个存储信息存在,从新打开浏览器,能够免登陆。(仅本地token有效,不能彻底保证token真的有效,如后端重启服务器、token过时等致使token失效,前端经过HTTP访问时,仍然会跳到登陆页面)。web
登陆成功后,将token和权限树JSON字符串保存到本地存储。vue-router
权限发生变动时,经过response拦截器,检查有无附加信息,若有须要,更新token和权限树JSON字符串。sql
前端开发一个权限树的管理的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来加以区分。这样,就用统一的方法,实现了前端页面的访问控制。
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字段,用于前端树节点的显示。
后端在登陆成功后,给前端发送token和权限树JSON字符串。
关于树节点的生成,可参阅:Java通用树结构数据管理---里面有关于权限树的例子。
为了方便前端管理,这里修改权限树的输出,将根节点也一并输出到前端。
在管理员修改用户权限后,动态权限更新,可经过附加信息,给前端发送token和权限树JSON字符串。参阅:Spring Boot动态权限变动实现的总体方案
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;
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多是访问效率更高的方案。这里仍是使用树型结构来管理权限树。
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){ //包含节点 if (element.style.display == "none"){ 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,若是在权限树中存在对应节点,则表示有权限;不然表示无权限。(注意:前端的权限树都是有权限的功能节点)。
修改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文件中使用。
侧边导航栏,与权限控制相关,能够做为示例。文件为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去掉了,由于菜单项,目前只有侧边导航栏在使用。
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()方法,进行权限控制。
登陆成功后,后端输出的权限树数据以下:
{ 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 }
这些功能项中,有些与导航栏有关,还有一些是页面的按钮或连接,在示例中没有用到。
制做一个简单的首页Home.vue,代码以下:
<template> <div id="home"> <h4>欢迎使用</h4> <h3>XX系统</h3> </div> </template>
修改/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;
如今运行Vue,"npm run dev",而后显示首页,并用F12显示调式信息:
侧边栏页面显示以下:
浏览器的调试器的控制台输出信息为:
说明,domKey为userMangementSub的dom元素没有操做权限,与侧边栏的效果一致。
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的,没有在权限树中,故其加入权限控制集合,又没有被受权,则该按钮应该不可见。
运行测试,显示登陆页,效果图以下:
登陆按钮不可见了,与预期效果一致。
登陆成功后,将后端发生过来的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.Suce***equstCode){ //若是登陆成功 _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; } }); },
根据权限动态更新方案,管理员修改用户权限后,该用户第一次访问后端接口,返回信息中可能会携带附加信息。这个可能在任何返回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) //返回接口返回的错误信息 })