場景
在年初開發一箇中後臺管理系統,功能涉及到了各個部門(產品、客服、市場等等),在開始的版本中,我和後端配合使用了花褲衩手摸手系列的許可權方案,前期非常nice,但是慢慢的隨著功能增多、業務越來越複雜,就變得有些吃力了,因為我們的許可權動態性太大了
- 手摸手系列許可權方案是有比較清晰的許可權劃分的,而我們公司部門的崗位職責有時比較模糊。
- 後端採用RBAC許可權方案,為了達到第1點要求,將角色劃分的很細,並且角色有時頻繁變動,導致每一次前端都需要手動維護
為了解決上面2個痛點,我將原方案進行了一丟丟改造。
- 前端不再以角色來控制許可權,而是以更小粒度的操作(介面)來控制,也就是前端不關心角色
- 路由還是由前端維護(我們的後端很排斥維護和他們不相干的東西?),但改為通過操作列表對許可權路由進行過濾
- 使用單一的方式(方便維護)控制頁面的區域性許可權,不再使用自定義指令方式,而是通過函式式元件,原因是使用自定義指令有多餘的開銷(插入再移除)
後端的配合:
- 提供一個獲取當前使用者操作列表的介面
- 操作列表需要增加一個唯一標識(操作碼)供前端使用,不變的
- 操作列表需要增加一個
routerName
欄位,用於視覺化許可權編輯
有一些注意點:
- 比如一個有許可權的列表頁面A,同時這個列表介面被許可權頁面B使用,現在你配置許可權讓某一個使用者沒有A頁面許可權,但可以使用B頁面,如果你的本意是可以使用B頁面的所有功能,這時就會有問題,所以儘量不要將許可權介面跨頁面使用,需要分清哪些資料需要通過字典介面獲取還是通過許可權介面獲取
- 有些人可能會糾結,前端維護許可權安全嗎?肯定是不安全的,安全性主要還在後端這邊把控,後端做好資料和介面方面的許可權控制,前端做許可權控制我認為主要還是為了互動體驗等。沒有許可權你為什麼要讓我看到那一坨?
- 在使用這種方式之前,要明確當前場景是否確實需要這麼做,畢竟在專案比較大且介面很多的情況下,你跟操作碼之間有一場持久戰
實現
操作列表示例
以Restful風格介面為例
const operations = [
{
url: '/xxx',
type: 'get',
name: '查詢xxx',
routeName: 'route1', // 介面對應的路由
opcode: 'XXX_GET' // 操作碼,不變的
},
{
url: '/xxx',
type: 'post',
name: '新增xxx',
routeName: 'route1',
opcode: 'XXX_POST'
},
// ......
]
複製程式碼
路由的變化
在路由的meta
中增加一個配置欄位如requireOps
,值可能為String
或者Array
,這表示當前路由頁面要顯示的必要的操作碼,Array
型別是為了處理一個路由頁面需要滿足同時存在多個操作許可權時才顯示的情況。若值不為這2種則視為無許可權控制,任何使用者都能訪問
由於最終需要根據過濾後的許可權路由動態生成選單,所以還需要在路由選項中增加幾個欄位處理顯示問題,其中hidden
優先順序大於visible
hidden
,值為true時,路由包括子路由都不會出現在選單中visible
,值為false時,路由不顯示,但顯示子路由
const permissionRoutes = [
{
// visible: false,
// hidden: true,
path: '/xxx',
name: 'route1',
meta: {
title: '路由1',
requireOps: 'XXX_GET'
},
// ...
}
]
複製程式碼
由於路由在前端維護,所以以上配置只能寫死,如果後端能同意維護這一份路由表,那就可以有很多的發揮空間了,體驗也能做的更好。
許可權路由過濾
先將許可權路由規範一下,同時保留一個副本,可能在視覺化時需要用到
const routeMap = (routes, cb) => routes.map(route => {
if (route.children && route.children.length > 0) {
route.children = routeMap(route.children, cb)
}
return cb(route)
})
const hasRequireOps = ops => Array.isArray(ops) || typeof ops === 'string'
const normalizeRequireOps = ops => hasRequireOps(ops)
? [].concat(...[ops])
: null
const normalizeRouteMeta = route => {
const meta = route.meta = {
...(route.meta || {})
}
meta.requireOps = normalizeRequireOps(meta.requireOps)
return route
}
permissionRoutes = routeMap(permissionRoutes, normalizeRouteMeta)
const permissionRoutesCopy = JSON.parse(JSON.stringify(permissionRoutes))
複製程式碼
獲取到操作列表後,只需要遍歷許可權路由,然後查詢requireOps
代表的操作有沒有在操作列表中。這裡需要處理一下requireOps
未設定的情況,如果子路由中都是許可權路由,需要為父級路由自動加上requireOps
值,不然當所有子路由都沒有許可權時,父級路由就被認為是無許可權控制且可訪問的;而如果子路由中只要有一個路由無許可權控制,那就不需要處理父路由。所以這裡可以用遞迴來解決,先處理子路由再處理父路由
const filterPermissionRoutes = (routes, cb) => {
// 可能父路由沒有設定requireOps 需要根據子路由確定父路由的requireOps
routes.forEach(route => {
if (route.children) {
route.children = filterPermissionRoutes(route.children, cb)
if (!route.meta.requireOps) {
const hasNoPermission = route.children.some(child => child.meta.requireOps === null)
// 如果子路由中存在不需要許可權控制的路由,則跳過
if (!hasNoPermission) {
route.meta.requireOps = [].concat(...route.children.map(child => child.meta.requireOps))
}
}
}
})
return cb(routes)
}
複製程式碼
然後根據操作列表對許可權路由進行過濾
let operations = null // 從後端獲取後更新它
const hasOp = opcode => operations
? operations.some(op => op.opcode === opcode)
: false
const proutes = filterPermissionRoutes(permissionRoutes, routes => routes.filter(route => {
const requireOps = route.meta.requireOps
if (requireOps) {
return requireOps.some(hasOp)
}
return true
}))
// 動態新增路由
router.addRoutes(proutes)
複製程式碼
函式式元件控制區域性許可權
這個元件實現很簡單,根據傳入的操作碼進行許可權判斷,若通過則返回插槽內容,否則返回null。另外,為了統一風格,支援一下root
屬性,表示元件的根節點
const AccessControl = {
functional: true,
render (h, { data, children }) {
const attrs = data.attrs || {}
// 如果是root,直接透傳
if (attrs.root !== undefined) {
return h(attrs.root || 'div', data, children)
}
if (!attrs.opcode) {
return h('span', {
style: {
color: 'red',
fontSize: '30px'
}
}, '請配置操作碼')
}
const opcodes = attrs.opcode.split(',')
if (opcodes.some(hasOp)) {
return children
}
return null
}
}
複製程式碼
動態生成許可權選單
以ElementUI為例,由於動態渲染需要進行遞迴,如果以檔案元件的形式會多一層根元件,所以這裡直接用render function簡單寫一個示例,可以根據自己的需求改造
// 許可權選單元件
export const PermissionMenuTree = {
name: 'MenuTree',
props: {
routes: {
type: Array,
required: true
},
collapse: Boolean
},
render (h) {
const createMenuTree = (routes, parentPath = '') => routes.map(route => {
// hidden: 為true時當前選單和子選單都不顯示
if (route.hidden === true) {
return null
}
// 子路徑處理
const fullPath = route.path.charAt(0) === '/' ? route.path : `${parentPath}/${route.path}`
// visible: 為false時不顯示當前選單,但顯示子選單
if (route.visible === false) {
return createMenuTree(route.children, fullPath)
}
const title = route.meta.title
const props = {
index: fullPath,
key: route.path
}
if (!route.children || route.children.length === 0) {
return h(
'el-menu-item',
{ props },
[h('span', title)]
)
}
return h(
'el-submenu',
{ props },
[
h('span', { slot: 'title' }, title),
...createMenuTree(route.children, fullPath)
]
)
})
return h(
'el-menu',
{
props: {
collapse: this.collapse,
router: true,
defaultActive: this.$route.path
}
},
createMenuTree(this.routes)
)
}
}
複製程式碼
介面的許可權控制
我們一般用axios,這裡只需要在axios封裝的基礎上加幾行程式碼就可以了,axios封裝花樣多多,這裡簡單示例
const ajax = axios.create(/* config */)
export default {
post (url, data, opcode, config = {}) {
if (opcode && !hasOp(opcode)) {
return Promise.reject(new Error('沒有操作許可權'))
}
return ajax.post(url, data, { /* config */ ...config }).then(({ data }) => data)
},
// ...
}
複製程式碼
到這裡,這個方案差不多就完成了,許可權配置的視覺化可以根據操作列表中的routeName
來做,將操作與許可權路由一一對應,在demo中有一個簡單實現