Skip to content

权限管理

介绍

在项目中经常有的场景是不同的用户的权限不同,通常有如下场景:

  • 不同的用户在页面中可以看到的元素和操作不同
  • 不同的用户对页面的访问权限不同

中后台常见的前端权限控制大概可概括为以下场景:

  1. 菜单权限控制,针对某个菜单/页面进行权限管理,有则能看到此页面,否则将展示无权限。
  2. 针对某页面中的某触发器进行权限管理,例如对列表页的某一条数据进行删除操作。有权限情况下则展示删除按钮。

整体流程

  1. 登录:当用户填写完账号和密码后向服务端验证是否正确,验证通过之后,服务端会返回一个 token
  2. 获取用户信息:使用 token 获取用户的详细信息(如用户权限,用户名等)。
  3. 生成权限路由:通过获取的用户权限信息,生成对应权限的路由表。
  4. 动态挂载路由:通过 router.addRoutes 动态挂载这些路由。
  5. 生成侧边栏:通过权限路由表生成对应的侧边栏

权限静态枚举

我们默认提供了一个AccessEnum的静态枚举在src/utils/constant.ts中,如果你需要扩展自定义的枚举信息,也可以在这里面进行自定义的扩展。

我们下面所有描述AccessEnum的部分均是从这个文件夹中引用。

用户权限

我们在src/stores/user.ts中增加了一个新的属性roles,用于控制我们当前登录用户的权限,权限信息的获取是从后端接口中的用户信息中取得的。

我们在用户信息中也增加了一个roles属性,它返回的是一个数组,我们采用的是一个用户对应多个不同的权限的方式来开发的,所以我们需要在后端接口中返回一个数组,比如:

json
{
  "id": 1,
  "username": "admin",
  "nickname": "管理员",
  "avatar": "https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png",
  "roles": ["admin", "editor"] // 权限返回一个数组,如果当前用户只有一个权限,返回的就是['admin']
}

路由控制

对于路由权限的控制,我们分为了两种情况,第一种就是采用前端静态路由的配置方式,我们为这种方式在meta中提供了一个access属性,你可以通过它传入权限信息,我们获取到与用户权限相匹配的信息后会自动进行展示。如下:

{
  path: '/access/admin',
  name: 'AccessAdmin',
  component: () => import('~/pages/access/admin.vue'),
  meta: {
    title: '管理员',
    access: [AccessEnum.ADMIN],// 配置只允许管理员访问
  },
}

组合式API控制(推荐⭐️)

我们还给大家提供了一个组合格式的API来控制权限方式,我们推荐使用这种方式来进行控制权限,它的使用方式如下:

vue
<script setup lang="ts">
import { AccessEnum } from '~@/utils/constant'

const { hasAccess, roles } = useAccess()
</script>

<template>
  <div>
    <a-button v-if="hasAccess([AccessEnum.USER, AccessEnum.ADMIN])">
      普通用户
    </a-button>
    <a-button v-if="hasAccess(AccessEnum.ADMIN)" type="primary">
      管理员
    </a-button>
  </div>
</template>

组件权限控制(推荐)

为了方便对权限进行控制,我们增加了一个Access的组件来对权限进行控制,这种方式是基于组合式api的形式实现的。

它接收一个参数access,我们可以将当前能访问的权限信息传递给Access,由它来控制当前组件是否需要显示。

使用方式如下:

vue
<script setup lang="ts">
import { AccessEnum } from '~@/utils/constant'
</script>

<template>
  <Access :access="[AccessEnum.USER, AccessEnum.ADMIN]">
    <a-button>普通用户</a-button>
  </Access>

  <Access :access="AccessEnum.ADMIN">
    <a-button type="primary">
      管理员
    </a-button>
  </Access>
</template>

使用指令控制

我们还支持了使用指令的方式进行控制权限,这种方式也是基于组合式api的形式实现的。

但是需要注意的是,确保你绑定到的组件是一个有根节点的组件参考vue自定义指令,如果不存在根节点,自定义指令将会失效,所以我们不推荐使用这种方式进行控制。

使用方式如下:

vue
<script setup lang="ts">
import { AccessEnum } from '~@/utils/constant'
</script>

<template>
  <div>
    <a-button v-access="[AccessEnum.USER, AccessEnum.ADMIN]">
      普通用户
    </a-button>
    <a-button v-access="AccessEnum.ADMIN" type="primary">
      管理员
    </a-button>
  </div>
</template>

菜单权限管理

针对菜单及路由权限控制,可以在 路由配置项 中,对某项增加 roles 参数即可。(如果不加,默认为拥有权限)

roles 可以根据自己的业务进行定义。

ts
export default {
  path: 'dashboard',
  name: 'dashboard',
  component: () => import('@/views/dashboard/index.vue'),
  meta: {
    locale: 'menu.dashboard',
    requiresAuth: true,
    icon: 'icon-dashboard',
  },
  children: [
    {
      path: 'workplace',
      name: 'workplace',
      component: () => import('@/views/dashboard/workplace/index.vue'),
      meta: {
        locale: 'menu.dashboard.workplace',
        requiresAuth: true,
        roles: ['*'], // * 表示通配权限。提示:为了少写点代码,也可以不定义这个字段。
      },
    },
    {
      path: 'monitor',
      name: 'monitor',
      component: () => import('@/views/dashboard/monitor/index.vue'),
      meta: {
        locale: 'menu.dashboard.monitor',
        requiresAuth: true,
        roles: ['admin'],
      },
    },
  ],
}

实现

路由权限管理

Pro 提供对应的权限管理钩子。可以自定义业务的权限需求。

#src/hooks/permission.ts

ts
import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/store'

export default function usePermission() {
  const userStore = useUserStore()
  return {
    accessRouter(route: RouteLocationNormalized | RouteRecordRaw) { // 判断当前用户是否有该路由的权限
      return (
        !route.meta?.requiresAuth
        || !route.meta?.roles
        || route.meta?.roles?.includes('*')
        || route.meta?.roles?.includes(userStore.role)
      )
    },
    // You can add any rules you want
  }
}

设置路由守卫,在路由守卫中对用户的页面进出进行管理。例如 当前用户是否已经登录、当前用户是否有页面权限。

ts
router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  async function crossroads() {
    const Permission = usePermission()
    if (Permission.accessRouter(to)) {
      await next()
    }
    else {
      const destination = Permission.findFirstPermissionRoute(
        appRoutes,
        userStore.role,
      ) || {
        name: 'notFound',
      } // 前往首个有权限的页面或者404。
      await next(destination)
    }
  }
  if (isLogin()) {
    // 判读用户是否登录
    if (userStore.role) {
      // 有角色信息表示当前用户已经登录且获取过用户信息
      crossroads()
    }
    else {
      try {
        await userStore.info() // 获取用户角色信息后再进行后续跳转处理
        crossroads()
      }
      catch (error) {
        next({
          name: 'login',
          query: {
            redirect: to.name,
            ...to.query,
          } as LocationQueryRaw,
        })
      }
    }
  }
  else {
    // 如果未登录则重定向到登录页面
    if (to.name === 'login') {
      next()
      return
    }
    next({
      name: 'login',
      query: {
        redirect: to.name,
        ...to.query,
      } as LocationQueryRaw,
    })
  }
})

自定义权限指令

ts
import { DirectiveBinding } from 'vue'
import { useUserStore } from '@/store'

function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
  const { value } = binding
  const userStore = useUserStore()
  const { role } = userStore

  if (Array.isArray(value)) {
    if (value.length > 0) {
      const permissionValues = value
      // 对当前用户的角色权限和传入指令的权限类型进行比对。如果当前用户无权限则会执行节点删除操作。
      const hasPermission = permissionValues.includes(role)
      if (!hasPermission && el.parentNode)
        el.parentNode.removeChild(el)
    }
  }
  else {
    throw new TypeError(`need roles! Like v-permission="['admin','user']"`)
  }
}

export default {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    checkPermission(el, binding)
  },
  updated(el: HTMLElement, binding: DirectiveBinding) {
    checkPermission(el, binding)
  },
}
ts
/**
 * 格式化 后端 结构信息并递归生成层级路由表
 * @param routerMap
 * @param parent
 */
export function generateRoutes(routerMap, parent?): any[] {
  return routerMap.map((item) => {
    const currentRoute: any = {
      // 路由地址 动态拼接生成如 /dashboard/workplace
      path: `${(parent && parent.path) ?? ''}/${item.path}`,
      // 路由名称,建议唯一
      name: item.name ?? '',
      // 该路由对应页面的 组件
      component: item.component,
      // meta: 页面标题, 菜单图标, 页面权限(供指令权限用,可去掉)
      meta: {
        ...item.meta,
        label: item.meta.title,
        icon: constantRouterIcon[item.meta.icon] || null,
        permissions: item.meta.permissions || null,
      },
    }

    // 为了防止出现后端返回结果不规范,处理有可能出现拼接出两个 反斜杠
    currentRoute.path = currentRoute.path.replace('//', '/')
    // 重定向
    item.redirect && (currentRoute.redirect = item.redirect)
    // 是否有子菜单,并递归处理
    if (item.children && item.children.length > 0) {
      // 如果未定义 redirect 默认第一个子路由为 redirect
      !item.redirect
      && (currentRoute.redirect = `${item.path}/${item.children[0].path}`)
      // Recursion
      currentRoute.children = generateRoutes(item.children, currentRoute)
    }
    return currentRoute
  })
}

RBAC

RBAC (Role-based access control)

实现原理: 在前端固定写死路由的权限,指定路由有哪些权限可以查看。只初始化通用的路由,需要权限才能访问的路由没有被加入路由表内。在登陆后或者其他方式获取用户角色后,通过角色去遍历路由表,获取该角色可以访问的路由表,生成路由表,再通过 router.addRoutes 添加到路由实例,实现权限的过滤。

缺点: 权限相对不自由,如果后台改动角色,前台也需要跟着改动。适合角色较固定的系统

ts
import type { Router, RouteRecordRaw } from 'vue-router'

import { PageEnum } from '/@/enums/pageEnum'

import { RootRoute } from '/@/router/routes'
import { PAGE_NOT_FOUND_ROUTE } from '/@/router/routes/basic'

import { usePermissionStoreWithOut } from '/@/store/modules/permission'

import { useUserStoreWithOut } from '/@/store/modules/user'

const LOGIN_PATH = PageEnum.BASE_LOGIN

const ROOT_PATH = RootRoute.path

const whitePathList: PageEnum[] = [LOGIN_PATH]

export function createPermissionGuard(router: Router) {
  const userStore = useUserStoreWithOut()
  const permissionStore = usePermissionStoreWithOut()
  router.beforeEach(async (to, from, next) => {
    if (
      from.path === ROOT_PATH
      && to.path === PageEnum.BASE_HOME
      && userStore.getUserInfo.homePath
      && userStore.getUserInfo.homePath !== PageEnum.BASE_HOME
    ) {
      next(userStore.getUserInfo.homePath)
      return
    }

    const token = userStore.getToken

    // Whitelist can be directly entered
    if (whitePathList.includes(to.path as PageEnum)) {
      if (to.path === LOGIN_PATH && token) {
        const isSessionTimeout = userStore.getSessionTimeout
        try {
          await userStore.afterLoginAction()
          if (!isSessionTimeout) {
            next((to.query?.redirect as string) || '/')
            return
          }
        }
        catch {}
      }
      next()
      return
    }

    // token does not exist
    if (!token) {
      // You can access without permission. You need to set the routing meta.ignoreAuth to true
      if (to.meta.ignoreAuth) {
        next()
        return
      }

      // redirect login page
      const redirectData: { path: string, replace: boolean, query?: Recordable<string> } = {
        path: LOGIN_PATH,
        replace: true,
      }
      if (to.path) {
        redirectData.query = {
          ...redirectData.query,
          redirect: to.path,
        }
      }
      next(redirectData)
      return
    }

    // Jump to the 404 page after processing the login
    if (
      from.path === LOGIN_PATH
      && to.name === PAGE_NOT_FOUND_ROUTE.name
      && to.fullPath !== (userStore.getUserInfo.homePath || PageEnum.BASE_HOME)
    ) {
      next(userStore.getUserInfo.homePath || PageEnum.BASE_HOME)
      return
    }

    // get userinfo while last fetch time is empty
    if (userStore.getLastUpdateTime === 0) {
      try {
        await userStore.getUserInfoAction()
      }
      catch (err) {
        next()
        return
      }
    }

    if (permissionStore.getIsDynamicAddedRoute) {
      next()
      return
    }

    const routes = await permissionStore.buildRoutesAction()

    routes.forEach((route) => {
      router.addRoute(route as unknown as RouteRecordRaw)
    })

    router.addRoute(PAGE_NOT_FOUND_ROUTE as unknown as RouteRecordRaw)

    permissionStore.setDynamicAddedRoute(true)

    if (to.name === PAGE_NOT_FOUND_ROUTE.name) {
      // 动态添加路由后,此处应当重定向到fullPath,否则会加载404页面内容
      next({ path: to.fullPath, replace: true, query: to.query })
    }
    else {
      const redirectPath = (from.query.redirect || to.path) as string
      const redirect = decodeURIComponent(redirectPath)
      const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect }
      next(nextData)
    }
  })
}

前端会维护一份路由表,路由表中的路由大致可以分为以下三种

  • constantRoutes:不需要动态判断权限的路由,如登录页或通用页面。
  • asyncRoutes:基于 BasicLayout,通常需要登录或权限认证的路由。
  • errorPage:例如 404。

通过获取当前用户的权限去比对路由表,动态生成当前用户具有权限可访问的路由表,通过 router.addRoutes 动态挂载到 router 上,并动态生成侧边栏。

登录获取 token

用户登录成功后,获取到 token,需要将这个 token 做持久化存储, 保证刷新页面后能记住用户登录状态不用再次重新登录, js-cookie 可很好的做到这点

安装 js-cookie

bash
yarn add js-cookie

新建src/utils/auth.js文件,做些简单的封装,便于调用

js
import Cookies from 'js-cookie'

const TokenKey = 'Admin-Token'

export function getToken() {
  return Cookies.get(TokenKey)
}

export function setToken(token) {
  return Cookies.set(TokenKey, token)
}

export function removeToken() {
  return Cookies.remove(TokenKey)
}

登录获取 token, src/store/modules/user.js

js
import { login } from '@/api/user'
import { getToken, setToken } from '@/utils/auth'

const state = {
  token: getToken(),
}

const mutations = {
  SET_TOKEN: (state, token) => {
    state.token = token
  },
}

const actions = {
  // 用户登录
  login({ commit }, userInfo) {
    const { username, password } = userInfo
    return new Promise((resolve, reject) => {
      login({ username: username.trim(), password })
        .then((response) => {
          const { data } = response
          commit('SET_TOKEN', data.token)
          setToken(data.token)
          resolve()
        })
        .catch((error) => {
          reject(error)
        })
    })
  },
}

export default {
  namespaced: true,
  state,
  mutations,
  actions,
}

将用户名和密码传递给登录接口(出于安全考虑,还需要将密码进行 md5 加密再传输),获取到 token 并存放到 state 中,同时使用封装的setToken(data.token)token 持久化存储,刷新浏览器时不会丢失 token

获取用户信息

获取到 token 之后,使用 token 获取用户的详细信息,如: 用户权限,用户名等。

js
import { getInfo, login, logout } from '@/api/user'
import router, { resetRouter } from '@/router'
import { getToken, removeToken, setToken } from '@/utils/auth'

const state = {
  token: getToken(),
  name: '',
  avatar: '',
  introduction: '',
  roles: [],
}

const mutations = {
  SET_TOKEN: (state, token) => {
    state.token = token
  },
  SET_INTRODUCTION: (state, introduction) => {
    state.introduction = introduction
  },
  SET_NAME: (state, name) => {
    state.name = name
  },
  SET_AVATAR: (state, avatar) => {
    state.avatar = avatar
  },
  SET_ROLES: (state, roles) => {
    state.roles = roles
  },
}

const actions = {
  // 获取用户信息
  getInfo({ commit, state }) {
    return new Promise((resolve, reject) => {
      getInfo(state.token)
        .then((response) => {
          const { data } = response

          if (!data)
            reject(new Error('Verification failed, please Login again.'))

          const { roles, name, avatar, introduction } = data

          // roles 必须是非空数组
          if (!roles || roles.length <= 0)
            reject(new Error('getInfo: roles must be a non-null array!'))

          commit('SET_ROLES', roles)
          commit('SET_NAME', name)
          commit('SET_AVATAR', avatar)
          commit('SET_INTRODUCTION', introduction)
          resolve(data)
        })
        .catch((error) => {
          reject(error)
        })
    })
  },

  // 删除 token
  resetToken({ commit }) {
    return new Promise((resolve) => {
      commit('SET_TOKEN', '')
      commit('SET_ROLES', [])
      removeToken()
      resolve()
    })
  },
}

生成路由

获取到了用户权限,比对用户权限和路由元信息,判断用户是否拥有路由权限(可以根据公司当前的权限数据结构进行设计)

创建store/permission.js

js
/**
 * Use meta.role to determine if the current user has permission
 * @param roles
 * @param route
 */
function hasPermission(roles, route) {
  if (route.meta && route.meta.roles)
    return roles.some(role => route.meta.roles.includes(role))

  else
    return true
}

递归过滤掉没有权限的路由

js
/**
 * Filter asynchronous routing tables by recursion
 * @param routes asyncRoutes
 * @param roles
 */
export function filterAsyncRoutes(routes, roles) {
  const res = []

  routes.forEach((route) => {
    const tmp = { ...route }
    if (hasPermission(roles, tmp)) {
      if (tmp.children)
        tmp.children = filterAsyncRoutes(tmp.children, roles)

      res.push(tmp)
    }
  })

  return res
}

获取到用户权限后调用 store.dispatch('permission/generateRoutes', roles); 生成权限路由

js
import { asyncRoutes, constantRoutes } from '@/router'

const state = {
  routes: [],
  addRoutes: [],
}

const mutations = {
  SET_ROUTES: (state, routes) => {
    state.addRoutes = routes
    state.routes = constantRoutes.concat(routes)
  },
}

const actions = {
  generateRoutes({ commit }, roles) {
    return new Promise((resolve) => {
      const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)

      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  },
}

export default {
  namespaced: true,
  state,
  mutations,
  actions,
}

路由守卫

permission

通过是否有 token 判断当前用户是否已经登录,

js
import { getToken } from '@/utils/auth' // get token from cookie
import router from './router'

import store from './store'

// 白名单
const whiteList = ['/login', '/auth-redirect']

router.beforeEach(async (to, from, next) => {
  // 确定用户是否已经登录
  const hasToken = getToken()

  // 未登录 重定向到登录页面
  if (!hasToken) {
    if (whiteList.includes(to.path)) {
      //   访问白名单内个路由直接跳转
      next()
    }
    else {
      // other pages that do not have permission to access are redirected to the login page.
      next(`/login?redirect=${to.path}`)
    }
    return
  }

  //   如果已经登录重定向到首页
  if (to.path === '/login')
    next({ path: '/' })

  try {
    // 获取用户信息
    const { roles } = await store.dispatch('user/getInfo')

    // generate accessible routes map based on roles
    const accessRoutes = await store.dispatch(
      'permission/generateRoutes',
      roles,
    )

    // 动态添加可访问路由
    router.addRoutes(accessRoutes)

    // hack method to ensure that addRoutes is complete
    // set the replace: true, so the navigation will not leave a history record
    next({ ...to, replace: true })
  }
  catch (error) {
    // 接口没有返回权限信息
    // 删除 token 跳转到登录页重新登录
    await store.dispatch('user/resetToken')
    Message.error(error || 'Has Error')
    next(`/login?redirect=${to.path}`)
  }
})

这里需要注意以下几点

  • 未登录时是可以访问登录页面,登录后访问登录页需要从定向到首页
  • 用户接口需要分会不为空的权限信息数组,若果返回空则清空 token 重新登录
  • 白名单里的页面无需登录就可以访问

参考文档