09-前后端权限管理,权限和菜单关联,权限在前后端项目中的应用

105 阅读8分钟

权限管理的几点思考

现在我们需要做权限管理, 而且要使得权限在前端具象化以方便理解管理和操作.

权限的结构可以是扁平的数组(方便存储), 但有时候也需要是一在树(方便在前端展示和操作). 这就要求我们的权限节点指点相互存在某种关联性

权限的节点有不同的节点类型, 每一个节点都是唯一的, 类型考虑如下

  • 菜单节点(可以展开或者收起, 子节点是页面节点)
  • 页面节点(每一个节点代表一个页面, 子节点是按钮节点, 而且本身具有隐藏属性 )
  • 按钮节点(前端用于控制具体的按钮是否显示, 后端用于控制对应的接口是否可访问)

这些节点的id将会组合在一起被不同的角色保存, 该角色将会具有这些节点的权限

权限作为前端的重要一环,和前端项目联系非常紧密,并且会不断更新. 所以我选择在前端项目中生成这颗权限节点树.

权限在前端项目中的实现

下面的代码都在前端项目中实现

我想在一个管理系统中生成如下菜单 image.png

这个权限树转化成数据是一个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 image.png

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' }),
  ]),
]

把这棵树和角色关联起来

image.png

image.png

image.png

把角色和用户关联起来

image.png

image.png

image.png

按钮权限在前端的控制

创建文件工具文件: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>

判断权限用法如下 image.png

页面权限在前端的控制

创建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, 这个页面自己随便写

如果我访问没有权限的"菜单列表"页面, 路由会自动定位到无权限页面 image.png

权限在后端项目中的应用

在后端项目中权限守卫

创建文件: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)
  }
  
}