前端訪問控制,通常針對界面元素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) //返回接口返回的錯誤信息 })