背景
我司有很多需要進(jìn)行權(quán)限管理的產(chǎn)品蹲缠。其中有一個(gè)產(chǎn)品棺克,需要給多個(gè)客戶部署前中后臺(tái)。在開(kāi)發(fā)第一個(gè)版本時(shí)线定,代碼全部分離娜谊。前端三套,后端三套斤讥。加上kafka纱皆,redis,算法,數(shù)據(jù)庫(kù)等服務(wù)器派草,每有一個(gè)新的客戶就需要部署一次搀缠,需要花費(fèi)很長(zhǎng)的時(shí)間且代碼難以維護(hù)。
后決定重構(gòu)代碼近迁,產(chǎn)品分為前艺普,中,后三個(gè)平臺(tái)鉴竭。前后端分別一套代碼歧譬,支持權(quán)限管理,可拓展搏存。前端使用路由前綴判斷平臺(tái)瑰步,登錄時(shí)會(huì)返回不同的token和用戶信息。不同的token只能訪問(wèn)對(duì)應(yīng)平臺(tái)的接口祭埂,根據(jù)用戶角色生成可訪問(wèn)的菜單面氓,進(jìn)入不同的系統(tǒng)
前言
權(quán)限模塊對(duì)于一個(gè)項(xiàng)目來(lái)說(shuō)是比較麻煩的部分,通常一個(gè)項(xiàng)目的權(quán)限管理蛆橡,需要做的是下面三種級(jí)別的鑒權(quán)舌界。
- 平臺(tái)級(jí)別
- 頁(yè)面級(jí)別(菜單)
- 控件級(jí)別(如按鈕,表格展示字段等)
本篇文章站在前端的角度泰演,實(shí)現(xiàn)前兩種級(jí)別的權(quán)限管理(控件級(jí)別可以通過(guò)條件渲染實(shí)現(xiàn))呻拌。用vue從零搭建一個(gè)前中后臺(tái)權(quán)限管理模板。供大家參考睦焕。
演示地址:http://auth.percywang.top
項(xiàng)目地址:https://github.com/pppercyWang/vue-authentication
其實(shí)大部分項(xiàng)目都會(huì)分離前后臺(tái)藐握,因?yàn)檎显谝惶状a,確實(shí)對(duì)打包優(yōu)化垃喊,代碼分割需要做的更多猾普。且項(xiàng)目架構(gòu)上會(huì)復(fù)雜一些,安全性方面需要考慮的更全面本谜。這里也提供了一個(gè)純后臺(tái)的權(quán)限管理模板初家。
項(xiàng)目地址:https://github.com/pppercyWang/vue-authentication2
項(xiàng)目結(jié)構(gòu)
技術(shù)棧:vue vue-router vuex element
assets 靜態(tài)資源
plugins
element-style.scss element樣式
element.js 按需引入
router
index.js 靜態(tài)路由及createRouter方法
service
api.js 前中后臺(tái)接口管理
store vuex
utils
http.js axios封裝
views
foreground 前臺(tái)頁(yè)面
midground 中臺(tái)頁(yè)面
background 后臺(tái)頁(yè)面
layout 前中后臺(tái)布局文件
404.vue 404頁(yè)面
Login.vue 前臺(tái)登錄
AgentLogin.vue 中臺(tái)登錄
AdminLogin.vue 后臺(tái)登錄
permission.js 動(dòng)態(tài)路由 前中后臺(tái)鑒權(quán) 菜單數(shù)據(jù)生成
main.js 應(yīng)用入口
一. 路由初始化——staticRoutes
三個(gè)平臺(tái)登錄是三個(gè)不一樣的頁(yè)面。/開(kāi)頭的是前臺(tái)的路由乌助,/agent是中臺(tái)溜在,/admin是后臺(tái)。這里的重定向也可以跳轉(zhuǎn)到具體的頁(yè)面他托,但這里因?yàn)闄?quán)限角色不同的原因掖肋,不能寫(xiě)死,就直接重定向到登錄頁(yè)赏参。
注意:404頁(yè)需要放在路由的最后面志笼,所以放在動(dòng)態(tài)路由部分
router/index.js
const staticRoutes = [{
path: '/login',
name: '用戶登錄',
component: () => import('@/views/Login.vue'),
},
{
path: '/agent/login',
name: '中臺(tái)登錄',
component: () => import('@/views/AgentLogin.vue'),
},
{
path: '/admin/login',
name: '后臺(tái)登錄',
component: () => import('@/views/AdminLogin.vue'),
},
{
path: '/',
redirect: '/login',
},
{
path: '/agent',
redirect: '/agent/login',
},
{
path: '/admin',
redirect: '/admin/login',
},
]
二. 動(dòng)態(tài)路由——dynamicRoutes
本例只有中臺(tái)和后臺(tái)進(jìn)行鑒權(quán)沿盅,一級(jí)欄目需要icon字段,用于菜單項(xiàng)圖標(biāo)籽腕。children為一級(jí)欄目的子欄目嗡呼,meta中的roles數(shù)組代表可訪問(wèn)該route的角色纸俭。
permission.js
const dynamicRoutes = {
// 前臺(tái)路由
'user': [{
path: '/',
component: () => import('@/views/layout/Layout.vue'),
name: '首頁(yè)',
redirect: '/home',
children: [{
path: 'home',
component: () => import('@/views/foreground/Home.vue'),
}]
}, ],
// 中臺(tái)路由
'agent': [{
path: '/agent/member',
component: () => import('@/views/layout/AgentLayout.vue'),
name: '會(huì)員管理',
redirect: '/agent/member/index',
icon: 'el-icon-star-on',
children: [{
path: 'index',
component: () => import('@/views/midground/member/Index.vue'),
name: '會(huì)員列表',
meta: {
roles: ['super_agent', 'second_agent'] // 超級(jí)代理和二級(jí)都可訪問(wèn)
},
},
{
path: 'scheme',
component: () => import('@/views/midground/member/Scheme.vue'),
name: '優(yōu)惠方案',
meta: {
roles: ['super_agent'] // 只有超級(jí)代理可訪問(wèn)
},
},
]
},
],
// 后臺(tái)路由
'admin': [{
path: '/admin/user',
component: () => import('@/views/layout/AdminLayout.vue'),
name: '用戶管理',
redirect: '/admin/user/index',
icon: 'el-icon-user-solid',
children: [{
path: 'index',
component: () => import('@/views/background/user/Index.vue'),
name: '用戶列表',
meta: {
roles: ['super_admin', 'admin']
},
},
{
path: 'detail',
component: () => import('@/views/background/user/UserDetail.vue'),
name: '用戶詳情',
meta: {
roles: ['super_admin']
},
},
]
},
],
'404': {
path: "*",
component: () => import('@/views/404.vue'),
}
}
三. 登錄頁(yè)
通常在登錄成功之后皇耗,后端會(huì)返回token跟用戶信息,我們需要對(duì)token跟用戶信息進(jìn)行持久化揍很,方便使用郎楼,這里我直接存在了sessionStorage。再根據(jù)用戶角色的不同進(jìn)入不同的路由
views/adminLogin.vue
try {
const res = await this.$http.post(`${this.$api.ADMIN.login}`, this.form.loginModel)
sessionStorage.setItem("adminToken", res.Data.Token);
const user = res.Data.User
sessionStorage.setItem(
"user",
JSON.stringify({
username: user.username,
role: user.role,
ground: user.ground // 前中后臺(tái)的標(biāo)識(shí) 如 fore mid back
})
);
switch (user.role) {
case "ip_admin": // ip管理員
this.$router.push("/admin/ip/index");
break;
case "admin": // 普通管理員
this.$router.push("/admin/user/index");
break;
case "super_admin": // 超級(jí)管理員
this.$router.push("/admin/user/index");
break;
}
} catch (e) {
this.$message.error(e.Message)
}
四. 路由守衛(wèi)——router.beforeEach()
只要是進(jìn)入登錄頁(yè)窒悔,我們需要做兩個(gè)事呜袁。
- 清除存儲(chǔ)在sessionStorage的token信息和用戶信息
- 使用permission.js提供的createRouter()創(chuàng)建一個(gè)新的router實(shí)例,替換matcher简珠。
我們這里是使用addRoutes在靜態(tài)路由的基礎(chǔ)上添加新路由阶界,但是文檔中沒(méi)有提供刪除路由的api×郑可以試想一下膘融,如果登錄后臺(tái)再登錄中臺(tái),則會(huì)出現(xiàn)中臺(tái)可以訪問(wèn)后臺(tái)路由的情況祭玉。為什么替換matcher可以刪除addRoutes添加的路由氧映?
注:router.beforeEach一定要放在vue實(shí)例創(chuàng)建之前,不然當(dāng)頁(yè)面刷新時(shí)的路由不會(huì)進(jìn)beforeEach鉤子
main.js
router.beforeEach((to, from, next) => {
if (to.path === '/login' || to.path === '/agent/login' || to.path === '/admin/login') {
sessionStorage.clear();
router.matcher = createRouter().matcher // 初始化routes,移除所有dynamicRoutes
next()
return
}
authentication(to, from, next, store, router); //路由鑒權(quán)
})
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
五. 前中后臺(tái)鑒權(quán)——authentication()
這里的switch函數(shù)根據(jù)to.path.split("/")[1]判定平臺(tái)脱货。在登錄時(shí)成功后我們sessionStorage.setItem()保存token岛都。
為什么要使用token agentToken adminToken三個(gè)不同的key來(lái)儲(chǔ)存呢?而不是只將token作為key呢振峻。這樣在axios.interceptors.request.use攔截器中設(shè)置token頭也不需要通過(guò)switch去獲取不同的token了臼疫。
因?yàn)榧僭O(shè)我們當(dāng)前的頁(yè)面路由是agent/member/index,我們手動(dòng)修改為admin/xxx/xxx扣孟。我們希望它跳轉(zhuǎn)到admin的登錄頁(yè)烫堤,而不是404頁(yè)面。
isAuthentication標(biāo)識(shí)是否完成鑒權(quán)哈打,沒(méi)有鑒權(quán)則調(diào)用generateRoutes獲取有效路由,再通過(guò)addRoutes添加新路由
permission.js
export function authentication(to, from, next, store, router) {
let token;
switch (to.path.split("/")[1]) {
case 'agent':
token = sessionStorage.getItem('agentToken');
if (!token && to.path !== '/agent/login') {
next({
path: '/agent/login'
})
return
}
break;
case 'admin':
token = sessionStorage.getItem('adminToken');
if (!token && to.path !== '/admin/login') {
next({
path: '/admin/login'
})
return
}
break;
default:
token = sessionStorage.getItem('token');
if (!token && to.path !== '/login') {
next({
path: '/login'
})
return
}
break;
}
const isAuth = sessionStorage.getItem('isAuthentication')
if (!isAuth || isAuth === '0') {
store.dispatch('getValidRoutes', JSON.parse(sessionStorage.getItem('user')).role).then(validRoutes => {
router.addRoutes(validRoutes)
sessionStorage.setItem('isAuthentication', '1')
})
}
next();
}
通過(guò)user.ground判定平臺(tái)
store/index.js
getValidRoutes({commit}, role) {
return new Promise(resolve => {
let validRoutes
switch (JSON.parse(sessionStorage.getItem('user')).ground) {
case 'fore':
validRoutes = generateRoutes('user', role, commit)
resolve(validRoutes);
break
case 'mid':
validRoutes = generateRoutes('agent', role, commit)
resolve(validRoutes);
break
case 'back':
validRoutes = generateRoutes('admin', role, commit)
resolve(validRoutes);
break
}
})
},
六. 角色篩選——ValidRoutes()
這里干了兩件最重要的事
- 生成el-menu的菜單數(shù)據(jù)
- 生成當(dāng)前角色有效的路由
permission.js
export function generateRoutes(target, role, commit) {
let targetRoutes = _.cloneDeep(dynamicRoutes[target]);
targetRoutes.forEach(route => {
if (route.children && route.children.length !== 0) {
route.children = route.children.filter(each => {
if (!each.meta || !each.meta.roles) {
return true
}
return each.meta.roles.includes(role) === true
})
}
});
switch (target) {
case 'admin':
commit('SET_BACKGROUD_MENU_DATA', targetRoutes.filter(route => route.children && route.children.length !== 0)) // 菜單數(shù)據(jù)是不需要404的
break
case 'agent':
commit('SET_MIDGROUD_MENU_DATA', targetRoutes.filter(route => route.children && route.children.length !== 0))
break
}
return new Array(...targetRoutes, dynamicRoutes['404'])
}
七.頁(yè)面刷新后數(shù)據(jù)丟失
在登錄后isAuthentication為1塔逃,刷新時(shí)不會(huì)重新生成路由,導(dǎo)致數(shù)據(jù)丟失料仗,在main.js監(jiān)聽(tīng)window.onbeforeunload即可
main.js
window.onbeforeunload = function () {
if (sessionStorage.getItem('user')) {
sessionStorage.setItem('isAuthentication', '0') // 在某個(gè)系統(tǒng)登錄后湾盗,頁(yè)面刷新,需重新生成路由
}
}
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
拓展
這時(shí)候差不多就大功告成了立轧,只需將數(shù)據(jù)渲染到el-menu即可格粪。
1.后臺(tái)控制權(quán)限
當(dāng)前的路由鑒權(quán)基本上由前端控制躏吊,后端只需返回平臺(tái)標(biāo)識(shí)和角色。但實(shí)際開(kāi)發(fā)時(shí)帐萎,肯定都是通過(guò)后臺(tái)控制比伏,菜單角色等信息需要建表入庫(kù)。來(lái)修改欄目名稱疆导,一級(jí)欄目icon赁项,菜單權(quán)限等
我們可以在getValidRoutes時(shí)獲取一張權(quán)限表,將這些數(shù)據(jù)插入到dynamicRoutes中澈段。后端返回的數(shù)據(jù)大致如下:
[{
id: 1,
name: '用戶管理',
icon: 'el-icon-user-solid',
children: [{
id: 3,
name: '用戶列表',
meta: {
roles: [1, 2]
},
},
{
id: 4,
path: 'detail',
name: '用戶詳情',
meta: {
roles: [1]
},
},
]
},
{
id: 2,
name: 'IP管理',
icon: 'el-icon-s-promotion',
children: [{
id: 5,
name: 'IP列表',
meta: {
roles: [1, 2, 3]
},
}, ]
},
]
2.安全性方面
前端:
- 跨平臺(tái)進(jìn)入路由悠菜,直接跳到該平臺(tái)登錄頁(yè)。
- 當(dāng)前平臺(tái)訪問(wèn)沒(méi)有權(quán)限的頁(yè)面報(bào)404錯(cuò)誤败富。
后端:
- 一定要保證相應(yīng)平臺(tái)的token只能調(diào)對(duì)應(yīng)接口悔醋,否則報(bào)錯(cuò)。
- 如果能做到角色接口鑒權(quán)就更好了兽叮,從接口層面拒絕請(qǐng)求
3.axios封裝
在請(qǐng)求攔截器中根據(jù)用戶信息拿不同的token芬骄,設(shè)置頭部信息
在響應(yīng)攔截器中,如果token過(guò)期鹦聪,再根據(jù)用戶信息跳轉(zhuǎn)到不同的登錄頁(yè)
4.api管理
如果后端也是一套代碼账阻。那api也可以這樣進(jìn)行管理,但如果沒(méi)有一個(gè)統(tǒng)一的前綴椎麦≡咨可以在axios設(shè)置一個(gè)統(tǒng)一的前綴例如proxy,這樣就解決了跨域的問(wèn)題观挎。
const USER = 'api'
const AGENT = 'agent'
const ADMIN = 'admin'
export default {
USER: {
login: `${USER}/User/login`,
},
AGENT: {
login: `${AGENT}/User/login`,
uploadFile: `${AGENT}/Utils/uploadFile`,
},
ADMIN: {
login: `${ADMIN}/User/login`,
},
}
devServer: {
proxy: {
'/proxy': {
target: 'http://localhost:8848',
changeOrigin: true,
pathRewrite: {
'^proxy': '' //將url中的proxy子串去掉
}
}
}
},