前言
拖更有點嚴重,過了半個月才寫了第二篇教程。無奈自己是一個業務猿,每天被我司的產品虐的死去活來,之前又病了一下休息了幾天,大家見諒。
進入正題,做后台項目區別於做其它的項目,權限驗證與安全性是非常重要的,可以說是一個后台項目一開始就必須考慮和搭建的基礎核心功能。我們所要做到的是:不同的權限對應着不同的路由,同時側邊欄也需根據不同的權限,異步生成。這里先簡單說一下,我實現登錄和權限驗證的思路。
- 登錄:當用戶填寫完賬號和密碼后向服務端驗證是否正確,驗證通過之后,服務端會返回一個token,拿到token之后(我會將這個token存貯到cookie中,保證刷新頁面后能記住用戶登錄狀態),前端會根據token再去拉取一個user_info的接口來獲取用戶的詳細信息(如用戶權限,用戶名等等信息)。
- 權限驗證:通過token獲取用戶的role,動態根據用戶的role算出其相應有權限的路由,通過router.addRoutes動態掛載路由。
上述所有的數據和操作都是通過vuex全局管理控制的。接下來,我們一起手摸手一步一步實現這個系統。
登錄篇
首先我們不管什么權限,來實現最基礎的登錄功能。
隨便找一個空白頁面擼上兩個input的框,一個是登錄賬號,一個是登錄密碼。再放置一個登錄按鈕。我們將登錄按鈕上綁上click事件,點擊登錄之后向服務端提交賬號和密碼進行驗證。
這就是一個最簡單的登錄頁面。如果你覺得還要寫的更加完美點,你可以在向服務端提交之前對賬號和密碼做一次簡單的校驗。詳細代碼

click事件觸發登錄操作
this.$store.dispatch('LoginByEmail', this.loginForm).then(() => { this.$router.push({ path: '/' }); //登錄成功之后重定向到首頁 }).catch(err => { this.$message.error(err); //登錄失敗提示錯誤 });
action
LoginByEmail({ commit }, userInfo) {
const email = userInfo.email.trim(); return new Promise((resolve, reject) => { loginByEmail(email, userInfo.password).then(response => { const data = response.data; Cookies.set('Token', response.data.token); //登錄成功后將token存儲在cookie之中 commit('SET_TOKEN', data.token); commit('SET_EMAIL', email); resolve(); }).catch(error => { reject(error); }); }); }
登錄成功后,服務端會返回一個token(該token的是一個能唯一標示用戶身份的一個key),之后我們將token存儲在本地cookie之中,這樣下次打開頁面或者刷新頁面的時候能記住用戶的登錄住在那台,不用再去登錄頁面重新登錄了。
ps:為了保證安全性,我司現在后台所有token有效期(Expires/Max-Age)都是Session,就是當瀏覽器關閉了就丟失了。重新打開游覽器都需要重新登錄驗證,后端也會在每周固定一個時間點重新刷新token,讓后台用戶全部重新登錄一次,確保后台用戶不會因為電腦遺失或者其它原因被人隨意使用賬號。
獲取用戶信息
用戶登錄成功之后,我們會在全局鈎子router.beforeEach中攔截路由,判斷是否已獲得token,在獲得token之后我們就要去獲取用戶的基本信息了
//router.beforeEach if (store.getters.roles.length === 0) { // 判斷當前用戶是否已拉取完user_info信息 store.dispatch('GetInfo').then(res => { // 拉取user_info const roles = res.data.role; next();//resolve 鈎子 })
就如前面所說的,我只在本地存儲了一個用戶的token,並沒有存儲別的用戶信息(如用戶權限,用戶名等)。有些人問過為什么不把一些其它的用戶信息也存一下?主要出於如下的考慮:
假設我把用戶權限和名字也存在了本地,但我這時候用另一台電腦登錄修改了自己的用戶名,之后再用這台存有之前用戶信息的電腦登錄,它默認會去讀取本地cookie的名字,並不會去拉去新的用戶信息。所以現在的策略是頁面會先從cookie中查看是否存有token,沒有,就走一遍上一部分的流程重新登錄,如果有token,就會去拉取user_info,保證用戶信息是最新的。
當然如果是做了單點登錄得功能的話,用戶信息存儲在本地也是可以的。但你一台電腦登錄時,另一台會被提下線,所以總會重新登錄獲取最新的內容。
但從代碼層面我建議還是把login和get_user_info兩件事分開比較好,在這個后端全面微服務的年代,后端同學也想寫優雅的代碼~
權限篇
先說一說我權限控制的主體思路,前端會有一份路由表,它表示了每一個路由可訪問的權限。當用戶登錄之后,通過token獲取用戶的role,動態根據用戶的role算出其相應有權限的路由,再通過router.addRoutes動態掛載路由。但這些控制都只是頁面級的,說白了前端再怎么做權限控制都不是絕對安全的,后端的權限驗證是逃不掉的。
我司現在就是前端來控制頁面級的權限,不同權限的用戶顯示不同的側邊欄和能進入不同的頁面(也做了少許按鈕級別的權限控制),后端則會驗證每一個涉及請求的操作,驗證其是否有該操作的權限,每一個后台的請求不管是get還是post都會讓前端在請求header里面攜帶用戶的token,后端會根據該token來驗證用戶是否有權限執行該操作。
權限前端or后端來控制?
有很多人表示他們公司的路由表是於后端根據用戶的權限動態生成的,我司不采取這種方式的原因如下:
- 項目不斷的迭代你會異常痛苦,前端新開發一個頁面還要讓后端配一下路由和權限,讓我們想了曾經前后端不分離,被后端支配的那段恐怖時間了。
- 其次,就拿我司的業務來說,雖然后端的確也是有權限驗證的,但它的驗證其實是針對業務來划分的,比如超級編輯可以發布文章,而實習編輯只能編輯文章不能發布,但對於前端來說不管是超級編輯還是實習編輯都是有權限進入文章編輯頁面的。所以前端和后端權限的划分是不太一致。
- 還有一點是就vue2.2.0之前異步掛載路由是很麻煩的一件事!不過好在官方也出了新的api,雖然本意是來解決ssr的痛點的。。。
addRoutes
在之前通過后端動態返回前端路由一直很難做的,因為vue-router必須是要vue在實例化之前就掛載上去的,不太方便動態改變。不過好在vue2.2.0以后新增了router.addRoutes
Dynamically add more routes to the router. The argument must be an Array using the same route config format with the routes constructor option.
有了這個我們就可相對方便的做權限控制了。(樓主之前在權限控制也走了不少歪路,可以在項目的commit記錄中看到,重構了很多次,最早沒用addRoute整個權限控制代碼里都是各種if/else的邏輯判斷,代碼相當的耦合和復雜)
具體實現
1.創建vue實例的時候將vue-router掛載,但這個時候vue-router掛載一些登錄或者不用權限的公用的頁面。
2.當用戶登錄后,獲取用role,將role和路由表每個頁面的需要的權限作比較,生成最終用戶可訪問的路由表。
3.調用router.addRoutes(store.getters.addRouters)添加用戶可訪問的路由。
4.使用vuex管理路由表,根據vuex中可訪問的路由渲染側邊欄組件。
router.js
首先我們實現router.js路由表,這里就拿前端控制路由來舉例(后端的也差不多,稍微改造一下就好了)
// router.js import Vue from 'vue'; import Router from 'vue-router'; import Login from '../views/login/'; const dashboard = resolve => require(['../views/dashboard/index'], resolve); //使用了vue-routerd的[Lazy Loading Routes ](https://router.vuejs.org/en/advanced/lazy-loading.html) //所有權限通用路由表 //如首頁和登錄頁和一些不用權限的公用頁面 export const constantRouterMap = [ { path: '/login', component: Login, hidden: true //hidden為自定義屬性,側邊欄那章會纖細解釋}, { path: '/', component: Layout, redirect: '/dashboard', name: '首頁', children: [{ path: 'dashboard', component: dashboard }] }, ] //實例化vue的時候只掛載constantRouter export default new Router({ routes: constantRouterMap }); //異步掛載的路由 //動態需要根據權限加載的路由表 export const asyncRouterMap = [ { path: '/permission', component: Layout, redirect: '/permission/index', name: '權限測試', meta: { role: ['admin','super_editor'] }, //頁面需要的權限 children: [ { path: 'index', component: Permission, name: '權限測試頁', meta: { role: ['admin','super_editor'] } //頁面需要的權限 }] }, { path: '*', redirect: '/404', hidden: true } ];
這里我們根據vue-router官方推薦的方法通過meta標簽來標示改頁面能訪問的權限有哪些。如meta: { role: ['admin','super_editor'] }表示該頁面只有admin和超級編輯才能有資格進入。
注意事項:這里有一個需要非常注意的地方就是404頁面一定要最后加載,如果放在constantRouterMap一同聲明了404,后面的所以頁面都會被攔截到404,詳細的問題見addRoutes when you've got a wildcard route for 404s does not work
main.js
關鍵的main.js
// main.js router.beforeEach((to, from, next) => { if (store.getters.token) { // 判斷是否有token if (to.path === '/login') { next({ path: '/' }); } else { if (store.getters.roles.length === 0) { // 判斷當前用戶是否已拉取完user_info信息 store.dispatch('GetInfo').then(res => { // 拉取info const roles = res.data.role; store.dispatch('GenerateRoutes', { roles }).then(() => { // 生成可訪問的路由表 router.addRoutes(store.getters.addRouters) // 動態添加可訪問路由表 next(to); // hack方法 確保addRoutes已完成 }) }).catch(err => { console.log(err); }); } else { next() //當有用戶權限的時候,說明所有可訪問路由已生成 如訪問沒權限的全面會自動進入404頁面 } } } else { if (whiteList.indexOf(to.path) !== -1) { // 在免登錄白名單,直接進入 next(); } else { next('/login'); // 否則全部重定向到登錄頁 } } });
這里的router.beforeEach也結合了上一章講的一些登錄邏輯代碼。

上面一張圖就是在使用addRoutes方法之前的權限判斷,非常的繁瑣,因為我是把所有的路由都掛在了上去,所有我要各種判斷當前的用戶是否有權限進入該頁面,各種if/else的嵌套,維護起來相當的困難。但現在有了addRoutes之后就非常的方便,我只掛載了用戶有權限進入的頁面,沒權限,路由自動幫我跳轉的404,省去了不少的判斷。
這里還有一個小hack的地方,就是router.addRoutes之后的next()可能會失效,因為可能next()的時候路由並沒有完全add完成,好在查閱文檔發現
next('/') or next({ path: '/' }): redirect to a different location. The current navigation will be aborted and a new one will be started.
這樣我們就可以簡單的通過next(to)巧妙的避開之前的那個問題了。
store/permission.js
就來就講一講GenerateRoutes Action
// store/permission.js import { asyncRouterMap, constantRouterMap } from 'src/router'; function hasPermission(roles, route) { if (route.meta && route.meta.role) { return roles.some(role => route.meta.role.indexOf(role) >= 0) } else { return true } } const permission = { state: { routers: constantRouterMap, addRouters: [] }, mutations: { SET_ROUTERS: (state, routers) => { state.addRouters = routers; state.routers = constantRouterMap.concat(routers); } }, actions: { GenerateRoutes({ commit }, data) { return new Promise(resolve => { const { roles } = data; const accessedRouters = asyncRouterMap.filter(v => { if (roles.indexOf('admin') >= 0) return true; if (hasPermission(roles, v)) { if (v.children && v.children.length > 0) { v.children = v.children.filter(child => { if (hasPermission(roles, child)) { return child } return false; }); return v } else { return v } } return false; }); commit('SET_ROUTERS', accessedRouters); resolve(); }) } } }; export default permission;
這里的代碼說白了就是干了一件事,通過用戶的權限和之前在router.js里面asyncRouterMap的每一個頁面所需要的權限做匹配,最后返回一個該用戶能夠訪問路由有哪些。
側邊欄
最后一個涉及到權限的地方就是側邊欄,不過在前面的基礎上已經很方便就能實現動態顯示側邊欄了。這里側邊欄基於element-ui的NavMenu來實現的。
<template> <el-menu :unique-opened='true' mode="vertical" theme="dark" :default-active="$route.path"> <template v-for="item in permission_routers" v-if="!item.hidden"> <el-submenu :index="item.name" v-if="!item.noDropdown"> <template slot="title"> <wscn-icon-svg :icon-class="item.icon||'wenzhang1'" /> {{item.name}} </template> <router-link v-for="child in item.children" :key="child.path" v-if="!child.hidden" class="title-link" :to="item.path+'/'+child.path"> <el-menu-item :index="item.path+'/'+child.path"> {{child.name}} </el-menu-item> </router-link> </el-submenu> <router-link v-if="item.noDropdown&&item.children.length>0" :to="item.path+'/'+item.children[0].path"> <el-menu-item :index="item.path+'/'+item.children[0].path"> <wscn-icon-svg :icon-class="item.icon||'geren1'" /> {{item.children[0].name}} </el-menu-item> </router-link> </template> </el-menu> </template> <script> import { mapGetters } from 'vuex'; export default { name: 'Sidebar', computed: { ...mapGetters([ 'permission_routers' ]) } } </script>
說白了就是遍歷之前算出來的permission_routers,通過vuex拿到之后動態v-for渲染而已。不過這里因為有一些業務需求所以加了很多判斷
比如我們在定義路由的時候會加很多參數
- icon : the icon show in the sidebar
- hidden : if hidden:true will not show in the sidebar
- redirect : if redirect:noredirect will not redirct in the levelbar
- noDropdown : if noDropdown:true will not has submenu
- meta : { role: ['admin'] } will control the page role
這里僅供參考,而且當前demo只支持兩級菜單,如需要請大家自行改造,來打造滿足自己業務需求的側邊欄。
側邊欄高亮問題:很多人在群里問為什么自己的側邊欄不能跟着自己的路由高亮,其實很簡單,element-ui官方已經給了default-active
所以我們只要
:default-active="$route.path"
將default-active
一直指向當前路由就可以了,就是這么簡單
按鈕級別權限控制
有很多人一直在問關於按鈕級別粒度的權限控制怎么做。我司現在是這樣的,真正需要按鈕級別控制的地方不是很多,現在是通過獲取到用戶的role之后,在前端用v-if手動判斷來區分不同權限對應的按鈕的。理由前面也說了,我司顆粒度的權限判斷是交給后端來做的,每個操作后端都會進行權限判斷。而且我覺得其實前端真正需要按鈕級別判斷的地方不是很多,如果一個頁面有很多種不同權限的按鈕,我覺得更多的應該是考慮產品層面是否設計合理。當然你強行說我想做按鈕級別的權限控制,你也可以參照路由層面的做法,搞一個操作權限表。。。但個人覺得有點多此一舉。
axios攔截器
這里再說一說axios吧,雖然在上一篇系列文章中簡單介紹過,不過這里還是要在嘮叨一下。如上文所說,我司服務端對每一個請求都會驗證權限,所以這里我們針對業務封裝了一下請求。首先我們通過request攔截器在每個請求頭里面塞入token,好讓后端進行權限驗證。並創建一個respone攔截器,當服務端返回特殊的狀態碼,我們統一做如沒權限或者token失效的操作。
import axios from 'axios'; import { Message } from 'element-ui'; import store from '../store'; // import router from '../router'; // 創建axios實例 const service = axios.create({ baseURL: process.env.BASE_API, // api的base_url timeout: 5000 // 請求超時時間 }); // request攔截器 service.interceptors.request.use(config => { // Do something before request is sent if (store.getters.token) { config.headers['X-Token'] = store.getters.token; // 讓每個請求攜帶token--['X-Token']為自定義key 請根據實際情況自行修改 } return config; }, error => { // Do something with request error console.log(error); // for debug Promise.reject(error); }) // respone攔截器 service.interceptors.response.use( response => response /** * 下面的注釋為通過response自定義code來標示請求狀態,當code返回如下情況為權限有問題,登出並返回到登錄頁 * 如通過xmlhttprequest 狀態碼標識 邏輯可寫在下面error中 */ // const code = response.data.code; // // 50014:Token 過期了 50012:其他客戶端登錄了 50008:非法的token // if (code === 50008 || code === 50014 || code === 50012) { // Message({ // message: res.message, // type: 'error', // duration: 5 * 1000 // }); // // 登出 // store.dispatch('FedLogOut').then(() => { // router.push({ path: '/login' }) // }); // } else { // return response // } , error => { console.log('err' + error);// for debug Message({ message: error.message, type: 'error', duration: 5 * 1000 }); return Promise.reject(error); } ) export default service;
兩步驗證

文章一開始也說了,后台的安全性是很重要的,簡簡單單的一個賬號+密碼的方式是很難保證安全性的。所以我司的后台項目都是用了兩步驗證的方式,之前我們也嘗試過使用基於google-authenticator或者youbikey這樣的方式但難度和操作成本都比較大。后來還是准備借助騰訊爸爸,這年代誰不用微信。。。安全性騰訊爸爸也幫我做好了保障。
樓主建議兩步驗證要支持多個渠道不要只微信或者QQ,前段時間QQ第三方登錄就出了bug,官方兩三天才修好的,害我背了鍋/(ㄒoㄒ)/~~ 。
這里的兩部驗證有點名不副實,其實就是賬號密碼驗證過之后還需要一個綁定的第三方平台登錄驗證而已。
寫起來也很簡單,在原有登錄得邏輯上改造一下就好。
this.$store.dispatch('LoginByEmail', this.loginForm).then(() => { //this.$router.push({ path: '/' }); //不重定向到首頁 this.showDialog = true //彈出選擇第三方平台的dialog }).catch(err => { this.$message.error(err); //登錄失敗提示錯誤 });
登錄成功之后不直接跳到首頁而是讓用戶兩步登錄,選擇登錄得平台。
接下來就是所有第三方登錄一樣的地方通過OAuth2.0授權。這個各大平台大同小異,大家自行查閱文檔,不展開了,就說一個微信授權比較坑的地方。注意你連參數的順序都不能換,不然會驗證不通過。具體代碼,同時我也封裝了openWindow方法大家自行看吧。
當第三方授權成功之后都會跳到一個你之前有一個傳入redirect——uri的頁面

如微信還必須是你授權賬號的一級域名。所以你授權的域名是vue-element-admin.com,你就必須重定向到vue-element-admin.com/xxx/下面,所以你需要寫一個重定向的服務,如vue-element-admin.com/auth/redirect?a.com 跳到該頁面時會再次重定向給a.com。
所以我們后台也需要開一個authredirect頁面:代碼。他的作用是第三方登錄成功之后會默認跳到授權的頁面,授權的頁面會再次重定向回我們的后台,由於是spa,改變路由的體驗不好,我們通過window.opener.location.href
的方式改變hash,在login.js里面再監聽hash的變化。當hash變化時,獲取之前第三方登錄成功返回的code與第一步賬號密碼登錄之后返回的uid一同發送給服務端驗證是否正確,如果正確,這時候就是真正的登錄成功。
created() {
window.addEventListener('hashchange', this.afterQRScan); }, destroyed() { window.removeEventListener('hashchange', this.afterQRScan); }, afterQRScan() { const hash = window.location.hash.slice(1); const hashObj = getQueryObject(hash); const originUrl = window.location.origin; history.replaceState({}, '', originUrl); const codeMap = { wechat: 'code', tencent: 'code' }; const codeName = hashObj[codeMap[this.auth_type]]; this.$store.dispatch('LoginByThirdparty', codeName).then(() => { this.$router.push({ path: '/' }); }); }
到這里涉及登錄權限的東西也差不多講完了,這里樓主只是給了大家一個實現的思路(都是樓主不斷摸索的血淚史),每個公司實現的方案都有些出入,請大家結合自己實際需求來一起擼一個后台吧~
占坑
常規占坑,這里是手摸手,帶你用vue擼后台系類
完整項目地址:vue-element-admin
系類文章一:手摸手,帶你用vue擼后台 系列一(基礎篇)
相應廣大需求 建了一個qq群 591724180 方便大家交流
下一次開始會講一些實際組件的使用方法和element-ui的一些心得。
作者:花褲衩coder
鏈接:http://www.jianshu.com/p/986776a20352
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。