权限管理的几点思考
现在我们需要做权限管理, 而且要使得权限在前端具象化以方便理解管理和操作.
权限的结构可以是扁平的数组(方便存储), 但有时候也需要是一在树(方便在前端展示和操作). 这就要求我们的权限节点指点相互存在某种关联性
权限的节点有不同的节点类型, 每一个节点都是唯一的, 类型考虑如下
- 菜单节点(可以展开或者收起, 子节点是页面节点)
- 页面节点(每一个节点代表一个页面, 子节点是按钮节点, 而且本身具有隐藏属性 )
- 按钮节点(前端用于控制具体的按钮是否显示, 后端用于控制对应的接口是否可访问)
这些节点的id将会组合在一起被不同的角色保存, 该角色将会具有这些节点的权限
权限作为前端的重要一环,和前端项目联系非常紧密,并且会不断更新. 所以我选择在前端项目中生成这颗权限节点树.
权限在前端项目中的实现
下面的代码都在前端项目中实现
我想在一个管理系统中生成如下菜单
这个权限树转化成数据是一个json格式
[
{ "id": "6Pjk3sSP4S", "label": "Dashboard", "href": "/admin", "pid": "", "inMenu": true, "type": "page", "sort": 0, "children": [] },
{
"id": "9CQPQbUQ6X",
"label": "系统管理",
"href": "/admin/system",
"pid": "",
"type": "menu",
"sort": 13,
"children": [
{
"id": "xHtfbRkVwn",
"label": "用户管理",
"href": "/admin/system/users",
"pid": "9CQPQbUQ6X",
"inMenu": true,
"type": "page",
"sort": 6,
"children": [
{ "id": "USER_DETAIL", "label": "用户详情", "pid": "xHtfbRkVwn", "type": "auth", "sort": 1 },
{ "id": "USER_CREATE", "label": "创建用户", "pid": "xHtfbRkVwn", "type": "auth", "sort": 2 },
{ "id": "USER_UPDATE", "label": "编辑用户", "pid": "xHtfbRkVwn", "type": "auth", "sort": 3 },
{ "id": "USER_DELETE", "label": "删除用户", "pid": "xHtfbRkVwn", "type": "auth", "sort": 4 },
{ "id": "USER_RESET_PWD", "label": "重置密码", "pid": "xHtfbRkVwn", "type": "auth", "sort": 5 }
]
},
{
"id": "SaKGrKBSHm",
"label": "角色管理",
"href": "/admin/system/roles",
"pid": "9CQPQbUQ6X",
"inMenu": true,
"type": "page",
"sort": 11,
"children": [
{ "id": "ROLE_DETAIL", "label": "角色详情", "pid": "SaKGrKBSHm", "type": "auth", "sort": 7 },
{ "id": "ROLE_CREATE", "label": "创建角色", "pid": "SaKGrKBSHm", "type": "auth", "sort": 8 },
{ "id": "ROLE_UPDATE", "label": "编辑角色", "pid": "SaKGrKBSHm", "type": "auth", "sort": 9 },
{ "id": "ROLE_DELETE", "label": "删除角色", "pid": "SaKGrKBSHm", "type": "auth", "sort": 10 }
]
},
{ "id": "weYzGDXbSW", "label": "菜单列表", "href": "/admin/system/menus", "pid": "9CQPQbUQ6X", "inMenu": true, "type": "page", "sort": 12, "children": [] }
]
}
]
在前端实现树结构
创建文件:src/config/menu-config.tsx
import { Document, Menu as IconMenu, Location, Setting } from '@element-plus/icons-vue'
// 按钮权限
interface AuthItem {
id: string
label: string
pid?: string
type?: 'auth'
sort?: number
}
// 页面
interface PageItem {
id: string
label: string
pid?: string
type?: 'page'
sort?: number
href: string
icon?: () => any
inMenu?: boolean // 是否在侧边栏显示(默认true)
children?: AuthItem[]
}
// 菜单
interface MenuItem {
id: string
label: string
pid?: string
type?: 'menu'
sort?: number
href: string
icon: () => any
children?: PageItem[]
}
type Menu = (MenuItem | PageItem)[]
let _cacheId: string = ''
let _cacheSort = 0
// 生成菜单
const genMenu = (config: MenuItem, fn?: () => PageItem[]): MenuItem => {
_cacheId = config.id
const children = fn ? fn() : []
_cacheId = ''
return { ...config, pid: '', type: 'menu', sort: _cacheSort++, children }
}
// 生成页面
const genPage = (config: PageItem, fn?: () => AuthItem[]): PageItem => {
const _prevId = _cacheId
_cacheId = config.id
const children = fn ? fn() : []
_cacheId = _prevId
return { ...config, pid: _prevId, inMenu: config.inMenu === false ? false : true, type: 'page', sort: _cacheSort++, children }
}
// 生成权限
const genAuth = (config: AuthItem): AuthItem => {
return { ...config, pid: _cacheId, type: 'auth', sort: _cacheSort++ }
}
// 拷贝一个对象
const cloneObject = (source: any): any => {
if (Array.isArray(source)) {
return source.map((i) => cloneObject(i))
} else if (toString.call(source) === '[object Object]') {
const tempObj: Record<string, any> = {}
Object.keys(source).forEach((k) => (tempObj[k] = cloneObject(source[k])))
return tempObj
}
return source
}
/**
* 把数组和数组的子元素展平成一维度数组
* @param source 菜单对象
* @returns
*/
const getFlatMenuList = (source?: Menu): (MenuItem | PageItem | AuthItem)[] => {
let tempList = source ? [...source] : [...cloneObject(dataSource)]
let i = 0
while (true) {
const temoObj = tempList[i]
if (!temoObj) break
if (Array.isArray(temoObj.children)) {
tempList = [...tempList, ...temoObj.children]
temoObj.children = []
}
i++
}
return tempList
}
/**
* 通过权限id列表获取所有的有权限的admin路径集合
* @param authIds 权限id集合
* @returns
*/
export const getPathsByAuths = (authIds: string[]): string[] => {
const source = getAuthMenuList(authIds, false, false)
let tempList: (MenuItem | PageItem)[] = getFlatMenuList(source) as (MenuItem | PageItem)[]
tempList = tempList.filter((item) => {
return ['menu', 'page'].includes(item.type || '')
}) as (MenuItem | PageItem)[]
return tempList.map((item) => item.href)
}
// 获取整个配置对象
export const getFullMenuList = (): Menu => {
return cloneObject(dataSource)
}
/**
* 通过权限配置获取菜单对象
* @param authIds 权限id列表(如果传递null,会默认为全部权限)
* @param filterHidden 是否过滤隐藏的菜单(true: 不应该在菜单栏显示的页面就被移出)
* @param pageChildrenToAuthList 是否过滤按钮权限(true: 会把page中children赋值给authList属性,同时删除children属性)
* @param options
* @param options.relateToSuperAdmin 需要考虑到超级管理员吗(默认true, 默认超级管理员拥有全部权限)
* @returns
*/
export const getAuthMenuList = (
authIds?: string[] | null,
filterHidden = true,
pageChildrenToAuthList = false,
options = {
relateToSuperAdmin: true,
},
): Menu => {
const userStore = useUserStore()
const fullList = getFlatMenuList()
let authList = [...fullList]
// 需要判断是否是超级管理员
if (Array.isArray(authIds)) {
if (!options.relateToSuperAdmin || (options.relateToSuperAdmin && !userStore.isSuperAdmin)) {
authList = fullList.filter((item) => authIds.includes(item.id))
}
}
// 过滤不应该在菜单列表显示的配置
if (filterHidden) {
authList = authList.filter((item) => !(item.type === 'page' && item.inMenu === false))
}
/** 要把当前数组中节点的父节点找出来 */
let i = 0
while (true) {
const tempObj = authList[i]
if (!tempObj) break
// 找出父元素
const parentObj = tempObj.pid ? fullList.find((item) => item.id === tempObj.pid) : null
if (parentObj) authList.push(parentObj)
i++
}
authList = Array.from(new Set(authList)).sort((a, b) => {
return (a as any).sort - (b as any).sort
})
/** 找出每个节点的子节点, 组装出整棵树 */
authList.forEach((item) => {
if (item.type === 'auth') return
const children = authList.filter((cur) => item.id === cur.pid)
if (children.length) {
if (pageChildrenToAuthList && item.type === 'page') {
;(item as any).authList = children
} else {
;(item as any).children = children
}
}
})
authList = authList.filter((item) => item.pid === '')
return authList as Menu
}
// 整个菜单权限
// 在线生成id(长度10位):https://www.bchrt.com/tools/suijimima/
const dataSource: Menu = [
genPage({ id: '6Pjk3sSP4S', label: 'Dashboard', href: '/admin', icon: () => <Document /> }),
genMenu({ id: '9CQPQbUQ6X', label: '系统管理', href: '/admin/system', icon: () => <IconMenu /> }, () => [
genPage({ id: 'xHtfbRkVwn', label: '用户管理', href: '/admin/system/users' }, () => [
genAuth({ id: 'USER_DETAIL', label: '用户详情' }),
genAuth({ id: 'USER_CREATE', label: '创建用户' }),
genAuth({ id: 'USER_UPDATE', label: '编辑用户' }),
genAuth({ id: 'USER_DELETE', label: '删除用户' }),
genAuth({ id: 'USER_RESET_PWD', label: '重置密码' }),
]),
genPage({ id: 'SaKGrKBSHm', label: '角色管理', href: '/admin/system/roles' }, () => [
genAuth({ id: 'ROLE_DETAIL', label: '角色详情' }),
genAuth({ id: 'ROLE_CREATE', label: '创建角色' }),
genAuth({ id: 'ROLE_UPDATE', label: '编辑角色' }),
genAuth({ id: 'ROLE_DELETE', label: '删除角色' }),
]),
genPage({ id: 'weYzGDXbSW', label: '菜单列表', href: '/admin/system/menus' }),
]),
]
把这棵树和角色关联起来
把角色和用户关联起来
按钮权限在前端的控制
创建文件工具文件:src/utils/core.ts
import { useUserStore } from '@/stores/user'
/**
* 依据传递的权限id判断是否存在对应权限
* @param authId 当前按钮的权限id
* @returns
*/
export const checkAuthById = (authIds?: string[] | string) => {
const userStore = useUserStore()
if (userStore.isSuperAdmin) return true // 超级用户不验证权限
if (!authIds) return true
authIds = Array.isArray(authIds) ? authIds : authIds ? [authIds] : []
if (Array.isArray(authIds) && authIds.length) {
// 校验后台按钮权限
const allAuthIds: string[] = userStore.authIdList // 当前用户拥有的全部权限按钮id集合
if (Array.from(new Set([...allAuthIds, ...authIds])).length < allAuthIds.length + authIds.length) {
return true // 有权限
}
return false
}
return true
}
在.vue文件中使用: 创建公共组件button.vue
<template>
<button v-if="existPeimit" >点击</button>
</template>
<script setup lang="tsx">
import { checkAuthById } from '@/utils/core'
const props = defineProps({
/** 校验按钮权限 */
checkAuthById: { type: Array as PropType<string[]> },
})
// 查看是否有按钮权限
const existPeimit = computed(() => {
return checkAuthById(props.checkAuthById || undefined)
})
</script>
判断权限用法如下
页面权限在前端的控制
创建nuxt中间件:middleware/auth-admin.ts
import { getPathsByAuths } from '@/config/menu-config'
import * as pathToRegexp from 'path-to-regexp'
import { useUserStore } from '@/stores/user'
/**
* 这里校验管理员权限
* definePageMeta({ layout: 'XXX', middleware: 'auth-admin' })
*/
export default defineNuxtRouteMiddleware((to, from) => {
const userStore = useUserStore()
// 超级管理员不需要校验权限
if (userStore.isSuperAdmin) return
/** 判断是否有权限 */
if (!userStore.hasAdminAuth || !userStore.isLogin) {
// 不存在登录,和进入管理系统的权限
return navigateTo(`/login?redirect=${from.fullPath}`)
} else {
if (to.fullPath === '/admin/no-auth') return
/** 判断是否存在路由权限 */
if (process.client) {
const authIds = userStore.authIdList
const pathList = getPathsByAuths(authIds)
const existPath = pathList.find((pathname: string) => {
return pathToRegexp.match(pathname, { decode: decodeURIComponent })(to.fullPath) !== false
})
if (!existPath) return navigateTo(`/admin/no-auth`)
}
}
})
在管理系统入口使用该中间件
<template>
<Head>
<Title>管理中心</Title>
</Head>
<NuxtPage />
</template>
<script setup lang="ts">
// 需要校验后台管理系统权限
definePageMeta({ layout: 'admin', middleware: 'auth-admin' })
</script>
当然我们还需要创建一个无权限的前端页面: src/pages/admin/no-auth.vue
, 这个页面自己随便写
如果我访问没有权限的"菜单列表"页面, 路由会自动定位到无权限页面
权限在后端项目中的应用
在后端项目中权限守卫
创建文件:src/guards/role.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'
import { UserService } from '../user/user.service'
import { SetMetadata } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
const PERMISSION_META_KEY = 'permissions'
/**
* 这是一个设置元数据的装饰器
* 用法:
* @Post()
* @RolePermission('CAT_CREATE', 'CAT_UPDATE')
* async create(@Body() createCatDto: CreateCatDto) {
* this.catsService.create(createCatDto);
* }
* @param roles
* @returns
*/
export const RolePermission = (...roles: string[]) => SetMetadata(PERMISSION_META_KEY, roles)
/**
* 这是一个用于验证当前用户有没有接口的操作权限的守卫
* 用法:
* @Controller('user')
* @UseGuards(JwtGuard, RoleGuard)
* export class UserController {
* @Post()
* @RolePermission('CAT_CREATE', 'CAT_UPDATE')
* async create(@Body() createCatDto: CreateCatDto) {
* this.catsService.create(createCatDto);
* }
* }
* @param args 需要验证的权限id
* @returns
*/
@Injectable()
export class RoleGuard implements CanActivate {
constructor(
public userService: UserService,
private reflector: Reflector,
) {}
// 返回结果为true:有权限, false:无权限
async canActivate(context: ExecutionContext): Promise<boolean> {
// 1.获取用户信息
const req = context.switchToHttp().getRequest()
const user = await this.userService.findOne(req.user?.userId)
// 用户登录信息不存在
if (!user) return false
// 用户是超级管理员
if (user.isAdmin === 1) return true
// 当前用户拥有的所以权限集合
const authList: (number | string)[] = Array.from(
new Set(
user.roles
.map((r) => {
try {
return JSON.parse(r.auths)
} catch (error) {
return []
}
})
.flat(),
),
)
/** 验证用户是否存在当前需要验证的权限 */
// 获取设置的元数据
const permission = this.reflector.get<string[]>(PERMISSION_META_KEY, context.getHandler())
const resultIds = Array.isArray(permission) ? permission.filter((vid) => authList.includes(vid)) : []
console.log('用于验证当前用户有没有改接口的操作权限:', permission, resultIds)
if (!permission || permission.length === 0) return true
// 开始验证
return resultIds.length > 0
}
}
在roles.controller.ts
中使用权限守卫
import { JwtGuard } from '../guards/jwt.guard'
import { RoleGuard, RolePermission } from '../guards/role.guard'
@Controller('roles')
@UseFilters(new TypeormFilter())
+ @UseGuards(JwtGuard, RoleGuard)
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get('/list')
// 只有当前用户所拥有的角色中所有权限中包含`aaaaa`或者`ROLE_DETAIL`权限的蔡能访问此接口
// 打印出: 用于验证当前用户有没有改接口的操作权限: [ 'aaaaa', 'ROLE_DETAIL' ] [ 'ROLE_DETAIL' ]
+ @RolePermission('aaaaa', 'ROLE_DETAIL')
findList(@Query() query: QueryRoleListDto) {
return this.rolesService.findList(query)
}
}