🧨🧨🧨你想要的 RBAC 权限管理实现全流程来啦!~~代码含量过多,请谨慎阅读~~

886 阅读12分钟

前言

哈喽大家好!我是嘟老板。之前写了《一文带你了解多数企业系统都在用的 RBAC 权限管理策略》一文,比较详尽的讲述了 RBAC 权限管理的理论内容。恰好最近在开发 ZimuAdmin 的权限模块,于是特地整理一份实现篇,分享从服务端设计到前端权限控制的一整个实现过程。

ZimuAdmin 项目基于以下技术构建:

阅读本文您将收获:

  1. 服务端权限管理模块实体设计用户权限接口实现。
  2. 前端导航菜单动态路由匹配过程。
  3. 等等...

设计思路

基础的 RBAC 权限主要分为四个部分:用户角色权限资源,其中资源又可分为 菜单资源按钮资源页面资源等。

这四部分的关系如图:

image.png

其中,权限 是资源集合的最小单位,按照业务要求,分配相关资源,如出纳权限、采购权限;角色 对应职责或岗位,可拥有多个权限,比如财务主管同时拥有出纳和采购权限;用户 对应到人/员工,可为每个员工分配一个或多个角色。

更多关于 RBAC 的介绍,请点击《一文带你了解多数企业系统都在用的 RBAC 权限管理策略》

完整的权限控制逻辑,需要 客户端服务端 配合完成。服务端核心逻辑在于获取用户权限资源,响应给客户端。客户端再通过资源数据,生成导航菜单及路由匹配等,与客户端既有的静态资源进行整合,实现基于用户角色的权限管理。

image.png

接下来,我们具体来看一下服务端和前端分别处理的内容。

服务端

服务端模块主要定义 RBAC 四大组成部分,用户服务、角色服务、权限服务、菜单服务,对应的实体类控制层服务等相关内容。

由于代码量较大,非关键代码将省略,感兴趣的小伙伴,可点击 ZimuAdmin 查看。

定义实体类

用户实体 user.entity

核心属性:
  • 用户相关属性,如 用户名username、姓名name、性别sex、电话tel 等。
  • status:状态,可选 激活/停用
  • roles: 关联的角色列表。
实体类定义
import { USER_GENDER, USER_STATUS, Y_N } from '@constants/enums'
import {
  Column,
  Entity,
  JoinTable,
  ManyToMany,
  PrimaryColumn,
  PrimaryGeneratedColumn
} from 'typeorm'
import { Role } from './role.entity'

@Entity('user')
export class User {
  // rowId
  @PrimaryGeneratedColumn()
  id!: number

  // 用户账号/工号
  @PrimaryColumn({ name: 'user_name' })
  username!: string

  // 其他用户属性字段...

  // 状态
  @Column({
    type: 'enum',
    enum: USER_STATUS,
    default: USER_STATUS.SERVING
  })
  status!: USER_STATUS

  // 关联的角色列表
  @ManyToMany(() => Role, role => role.users)
  @JoinTable({
    name: 'zm-role-user-relation',
    joinColumn: {
      name: 'user_id'
    },
    inverseJoinColumn: {
      name: 'role_id'
    }
  })
  roles!: Role[]
}

此处的 roles 属性,并不是用户表的具体字段,而是为关联角色数据定义的特殊属性。借助了 TypeORM 对于多对多关系的支持,达到获取关联角色的目的。
后续实体中定义的 menusauthorizationsusers 同理。

菜单实体 menu.entity

核心属性
  • code:编码,全局唯一。
  • name:名称。
  • type:类型,可选 菜单/按钮/页面
  • level:层级。
  • sort:排序序号。
  • icon:导航栏图标。
  • status:状态,可选 激活/停用
  • parent:父菜单编码。
实体类定义
import { ACTIVATION_STATUS, MENU_TYPE, MENU_TYPE_DESC } from '@constants/enums'
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  ManyToMany,
  PrimaryColumn,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
  VersionColumn
} from 'typeorm'

@Entity('menu')
export class Menu {
  @PrimaryGeneratedColumn()
  id!: number

  // 菜单编码
  @PrimaryColumn()
  code!: string

  // 菜单名称
  @Column()
  name!: string

  // 菜单类型(菜单menu or 按钮button or 页面 page)
  @Column({
    type: 'enum',
    enum: MENU_TYPE
  })
  type!: MENU_TYPE

  // 层级
  @Column()
  level!: number

  // 排序
  @Column()
  sort!: number

  // 菜单图标
  @Column()
  icon!: string

  // 状态(激活 or 停用)
  @Column({
    type: 'enum',
    enum: ACTIVATION_STATUS,
    default: ACTIVATION_STATUS.ACTIVATED
  })
  status!: ACTIVATION_STATUS

  // 父菜单编码
  @Column()
  parent!: string
}

权限实体 auth.entity

核心属性
  • code:编码,全局唯一。
  • name:名称。
  • status:状态,可选 激活/停用
  • roles:关联的角色列表。
  • menus:关联的菜单列表。
实体类定义
import { ACTIVATION_STATUS } from '@constants/enums'
import {
  Column,
  Entity,
  JoinTable,
  ManyToMany,
  PrimaryColumn,
  PrimaryGeneratedColumn
} from 'typeorm'
import { Menu } from './menu.entity'
import { Role } from './role.entity'

@Entity('auth')
export class Auth {
  @PrimaryGeneratedColumn()
  id!: number

  // 权限编码
  @PrimaryColumn()
  code!: string

  // 权限名称
  @Column()
  name!: string

  // 状态(激活 or 停用)
  @Column({
    type: 'enum',
    enum: ACTIVATION_STATUS,
    default: ACTIVATION_STATUS.ACTIVATED
  })
  status!: ACTIVATION_STATUS

  // 关联的角色列表
  @ManyToMany(() => Role, role => role.authorizations)
  roles!: Role[]

  // 关联的菜单列表
  @ManyToMany(() => Menu, menu => menu.authorizations)
  @JoinTable({
    name: 'auth-menu-relation',
    joinColumn: {
      name: 'auth_id'
    },
    inverseJoinColumn: {
      name: 'menu_id'
    }
  })
  menus!: Menu[]
}

角色实体 role.entity

核心属性
  • code:编码,全局唯一。
  • name:名称。
  • status:状态,可选 激活/停用
  • authorizations:关联的权限列表。
  • users:关联的用户列表。
实体类定义
import { ACTIVATION_STATUS } from '@constants/enums'
import {
  Column,
  Entity,
  JoinTable,
  ManyToMany,
  PrimaryColumn,
  PrimaryGeneratedColumn
} from 'typeorm'
import { Auth } from './auth.entity'
import { User } from './user.entity'

@Entity('role')
export class Role {
  @PrimaryGeneratedColumn()
  id!: number

  // 角色编码
  @PrimaryColumn()
  code!: string

  // 角色名称
  @Column()
  name!: string

  // 状态(激活 or 停用)
  @Column({
    type: 'enum',
    enum: ACTIVATION_STATUS,
    default: ACTIVATION_STATUS.ACTIVATED
  })
  status!: ACTIVATION_STATUS

  // 关联的权限列表
  @ManyToMany(() => Auth, auth => auth.roles)
  @JoinTable({
    name: 'role-auth-relation',
    joinColumn: {
      name: 'role_id'
    },
    inverseJoinColumn: {
      name: 'auth_id'
    }
  })
  authorizations!: Auth[]

  // 关联的用户列表
  @ManyToMany(() => User, user => user.roles)
  users!: User[]
}

用户权限接口 user/auth

用户权限接口可以说是整个 RBAC 的核心,通过该接口,可以拿到当前用户所有的权限资源,并响应给前端做对应处理。

定义接口

在 UserController 中定义 /auth 接口,大致逻辑如下:

  1. 获取当前账户,登录成功后会将账户名保存至 Redis,此处 get 函数即是从 Redis 中获取用户名,CURRENT_USERRedis 存储的 key 值。
  2. 调用 queryAuthByUsername 服务函数,通过用户名获取资源数据,具体逻辑请往下看。
  /**
   * 通过用户名获取所有权限,返回菜单、按钮、等所有资源
   *
   * @param username 用户名
   * @returns resources 所有菜单资源数组
   */
  @Get('/auth')
  async auth() {
    // 获取当前账户
    const currentUsername = await get(CURRENT_USER)
    const resources =
      await this.currentService.queryAuthByUsername(currentUsername)

    return success(resources)
  }

定义服务函数

用户服务 UserService 中新增一个 根据用户账户查询权限资源 的方法 - queryAuthByUsername,核心逻辑如下:

  1. 获取登录用户信息,若当前用户为超管权限(即 isAdminY),则默认授权所有资源;否则执行后续逻辑。
  2. 通过 用户名,获取该用户关联的所有 角色 roleIds
  3. 获取 角色 roleIds 关联的 权限 authIds,去重合并。
  4. 获取 权限 authIds 关联的 资源 menuIds,去重合并。
  5. 获取 资源 menuIds 对应的明细列表数据。

最终返回该用户已分配的所有资源列表,包括菜单按钮页面等。

  /**
   * 根据用户名查询所有资源数据
   * @param username 用户名
   */
  async queryAuthByUsername(username: string) {
    // 1. 当前用户绑定的角色
    const { isAdmin, roles } = await this.queryByUsername(username, {
      relations: {
        roles: true
      }
    })

    // 若为超管权限,则返回所有的资源
    if (isAdmin === Y_N.Y) {
      const { rows: allMenus } = await this.menuService.queryList()
      return allMenus
    }

    const roleIds = roles.map((r: Role) => r.id)
    if (!roleIds.length) return []
    const { rows: rolesWithAuth, total } = await this.roleService.queryList(
      {
        id: In(roleIds)
      },
      {
        relations: {
          authorizations: true
        }
      }
    )
    if (!total) return []

    // 2. 所有角色关联的权限
    const authIds = [
      ...new Set(
        rolesWithAuth.reduce((ret: number[], role: Role) => {
          if (role.authorizations.length) {
            const authIdsOfRole = role.authorizations.map((a: Auth) => a.id)
            ret.push(...authIdsOfRole)
          }
          return ret
        }, []) as number[]
      )
    ]
    if (!authIds.length) return []

    const { rows: authsWithMenus, total: authTotal } =
      await this.authService.queryList(
        {
          id: In(authIds)
        },
        {
          relations: {
            menus: true
          }
        }
      )
    if (!authTotal) return []

    // 3. 所有权限关联的资源列表
    const menuIds = [
      ...new Set(
        authsWithMenus.reduce((ret: number[], auth: Auth) => {
          if (auth.menus.length) {
            const menuIdsOfAuth = auth.menus.map((m: Menu) => m.id)
            ret.push(...menuIdsOfAuth)
          }
          return ret
        }, []) as number[]
      )
    ]
    const { rows: allMenus } = await this.menuService.queryList({
      id: In(menuIds)
    })

    return allMenus
  }

上述实现可以用 QueryBuilder 进行精简,之所以写这么一堆,是为了向大家展示每一步的数据获取逻辑,方便理解整个过程。

前端

前端权限控制可以分为以下两部分:

  • 导航菜单
  • 路由控制

其中导航菜单是指系统界面展示的功能入口菜单,通常在界面左侧或顶部,用户可以通过点击某个菜单,展示指定的页面内容;路由控制是指对于前端路由的权限控制,目前 SPA 应用都是通过路由进行页面跳转,根据权限对路由进行精准控制,可有效避免越权问题。比如通过浏览器地址栏输入链接跳转,若仅仅控制了导航菜单,没有控制路由,就可能允许查看未授权的页面。

整个处理流程可简化为下图:

image.png

以上流程从初始化权限开始,入口函数包含以下内容:

  • 获取用户权限数据。
  • 筛选权限菜单,供后续导航菜单和路由控制使用。
  • 根据权限菜单,初始化 Vue 路由。
  • 将平铺的数组结构菜单数据,转换为导航菜单需要的树形结构。

具体代码如下:

// 初始化权限
const initAuthMenus = async (username: string) => {
    // menus 包含所有权限资源,菜单、按钮等
    const menus = await getUserAuth(username)
    // 筛选菜单类型
    flatMenus.value = menus.filter(
      (m: ZiMuAuth.Menu) => m.type === MENU_TYPE.MENU
    )
    // 初始化 Vue 路由
    routeStore.initRoutes(flatMenus.value)
    // 导航菜单树形结构
    authMenus.value = transformFlatMenusToTree(flatMenus.value)
    isAuthInitialized.value = true
}

注:
由于导航菜单和路由都是针对菜单类型的资源,所以先筛选出菜单资源 flatMenus,避免多余的处理。

接下来分别看下导航菜单路由控制如何处理。

导航菜单

通过上面接口部分可知,目前用户权限结构的响应结构是平铺的数组形式,而系统的导航菜单通常是存在层级关系的,即可能有一级菜单、二级菜单、三级菜单甚至更多,所以我们需要先将接口返回的数组结构数据,转换为导航菜单可用的树形结构

image.png

这块主要是通过算法,将平铺的数组结构转化为树形结构,代码如下:

/**
 * 将平铺的菜单结构转化为树形结构
 * @param flatMenus 平铺的菜单列表
 * @returns
 */
export function transformFlatMenusToTree(flatMenus: ZiMuAuth.Menu[]) {
  if (!flatMenus.length) return []
  const result: ZiMuAuth.Menu[] = []

  // 菜单 code 与 菜单对象的映射
  const menuCodeMap = new Map()
  for (const menu of flatMenus) {
    menu.children = []
    menuCodeMap.set(menu.code, menu)
  }
  /**
   * 1. 若当前菜单存在父菜单 parent,则将其加入到父菜单的 children 中
   * 2. 若无父菜单,插入结果数组
   */
  for (const menu of flatMenus) {
    const parent = menuCodeMap.get(menu.parent)
    if (parent) parent.children.push(menu)
    else result.push(menu)
  }
  return sortMenuTree(result)
}

/**
 * 树形结构菜单排序
 * @param menus 树形结构菜单
 * @returns
 */
export function sortMenuTree(menus: ZiMuAuth.Menu[]) {
  if (!menus.length) return []
  menus.sort((pre, next) => Number(pre.sort) - Number(next.sort))
  for (const menu of menus) {
    if (menu.children) sortMenuTree(menu.children)
  }

  return menus
}

流程图中有一点没有展示,就是菜单排序 sortMenuTree,通过菜单实体定义部分可以看出,创建菜单时可配置排序属性 sort,即允许用户按照业务需求,自定义菜单的顺序,因此排序步骤十分必要。

路由控制

路由控制的处理相对麻烦一些,我们采用静态路由方式,即前端维护完整的路由表,再通过与权限菜单进行匹配,将匹配结果添加到最终路由中。

image.png

我们将路由分为两类,一类是始终需要展示的固定路由 constantVueRoutes,如 登录页、错误页(404、403 等)等;另一类是通过权限配置的动态路由 matchedRoutes,如菜单管理、用户管理等。

路由匹配需遵循规则:菜单的编码(code)需要与路由的 name 属性一致

综上,匹配成功的路由需满足以下两个条件:

  • 路由配置项未定义 name 属性(通常不允许该情况)或 name 属性的值在权限菜单内。
  • 路由配置不是固定路由配置(即无需权限配置便可访问的路由,通常包括登录页错误页等)。

以下是匹配路由的核心函数:

/**
 * 通过菜单匹配生效的路由配置
 * @param menus 权限菜单
 */
export function matchRoutesByAuthMenus(
  vueRoutes: RouteRecordRaw[],
  menus: ZiMuAuth.Menu[]
) {
  if (!menus.length || !vueRoutes.length) return [...constantVueRoutes]
  const menuCodes = menus.map(m => m.code)
  const matchRoutes = (routes: RouteRecordRaw[]) => {
    // 系统固定路由不参与匹配,直接允许访问
    const target: RouteRecordRaw[] = []
    for (const route of routes) {
      /**
       * 匹配条件:
       * 1. route 未定义 name 属性,通常不存在该情况 或 route.name 在权限菜单内
       * 2. route 不是固定路由配置(即无需权限配置,即可访问的路由,通常包括 登录页,错误页,首页等)
       */
      const matched =
        (!route.name || menuCodes.includes(route.name as string)) &&
        !isConstantRoute(route)
      if (matched) {
        if (route.children?.length) {
          route.children = matchRoutes(route.children)
        }
        // 若 target 中不存在该 route,则加入 target
        target.push(route)
      }
    }

    return target
  }

  const matchedRoutes: RouteRecordRaw[] = matchRoutes(vueRoutes)
  const allRoutes = [...constantVueRoutes, ...matchedRoutes]

  return allRoutes
}

matchRoutesByAuthMenus 函数的执行结果,便是最终要生效的路由,调用 Vue Router addRoute api 循环添加到路由中即可。

for (const route of matchedRoutes) {
  router.addRoute(route)
}

到这,从权限数据到前端控制的整个流程就算完成啦,如果说还差一点什么,可能就是菜单、角色等模块的管理页面及创建、分配等操作,不过这些都是很基本的增删改查,且代码量较多,就不在文中体现了。

结语

本文重点介绍了 RBAC 权限管理的实现流程,从服务端实体类定义、权限接口定义到前端导航菜单及路由的控制等,比较详尽的讲述了整个实现过程。旨在帮助同学们加深对于 RBAC 权限管理从设计到实现整个过程的理解。希望对您有所帮助。相关代码已上传至 GitHub,欢迎 star

如您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。

技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。


往期推荐