权限管理
介绍
在项目中经常有的场景是不同的用户的权限不同,通常有如下场景:
- 不同的用户在页面中可以看到的元素和操作不同
- 不同的用户对页面的访问权限不同
中后台常见的前端权限控制大概可概括为以下场景:
- 菜单权限控制,针对某个菜单/页面进行权限管理,有则能看到此页面,否则将展示无权限。
- 针对某页面中的某触发器进行权限管理,例如对列表页的某一条数据进行删除操作。有权限情况下则展示删除按钮。
整体流程
- 登录:当用户填写完账号和密码后向服务端验证是否正确,验证通过之后,服务端会返回一个
token - 获取用户信息:使用
token获取用户的详细信息(如用户权限,用户名等)。 - 生成权限路由:通过获取的用户权限信息,生成对应权限的路由表。
- 动态挂载路由:通过
router.addRoutes动态挂载这些路由。 - 生成侧边栏:通过权限路由表生成对应的侧边栏
权限静态枚举
我们默认提供了一个AccessEnum的静态枚举在src/utils/constant.ts中,如果你需要扩展自定义的枚举信息,也可以在这里面进行自定义的扩展。
我们下面所有描述AccessEnum的部分均是从这个文件夹中引用。
用户权限
我们在src/stores/user.ts中增加了一个新的属性roles,用于控制我们当前登录用户的权限,权限信息的获取是从后端接口中的用户信息中取得的。
我们在用户信息中也增加了一个roles属性,它返回的是一个数组,我们采用的是一个用户对应多个不同的权限的方式来开发的,所以我们需要在后端接口中返回一个数组,比如:
{
"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来控制权限方式,我们推荐使用这种方式来进行控制权限,它的使用方式如下:
<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,由它来控制当前组件是否需要显示。
使用方式如下:
<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自定义指令,如果不存在根节点,自定义指令将会失效,所以我们不推荐使用这种方式进行控制。
使用方式如下:
<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 可以根据自己的业务进行定义。
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
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
}
}设置路由守卫,在路由守卫中对用户的页面进出进行管理。例如 当前用户是否已经登录、当前用户是否有页面权限。
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,
})
}
})自定义权限指令
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)
},
}/**
* 格式化 后端 结构信息并递归生成层级路由表
* @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 添加到路由实例,实现权限的过滤。
缺点: 权限相对不自由,如果后台改动角色,前台也需要跟着改动。适合角色较固定的系统
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
yarn add js-cookie新建src/utils/auth.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
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 获取用户的详细信息,如: 用户权限,用户名等。
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
/**
* 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
}递归过滤掉没有权限的路由
/**
* 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); 生成权限路由
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,
}路由守卫
通过是否有 token 判断当前用户是否已经登录,
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 重新登录
- 白名单里的页面无需登录就可以访问