关注公众号 前端界,回复“加群”
加入我们一起学习,天天进步
前端权限控制
简介
其实在早期web应用中,权限控制跟我们切图仔是没有半毛钱关系的,作为前端,写好样式和特效就是主要任务,大部分时间和精力都花在hack和jq上面了。
直到三大框架流行,单页应用开始兴起,前端有了路由的概念。同时,前后端分离,前端代码再也不用写到php,jsp,asp里面。前后端虽然是分离了,所以需要一个东西告诉彼此是自己人,token鉴权慢慢成为主流。自此,前端权限控制也有了概念,这个时候的前端权限控制,更多的是token鉴权和根据角色id去过滤路由。
在随后的发展中,前端越来越复杂,也承担了越来越多的任务,权限控制这块也相应提出了更多的需求,比如:细化颗粒度(控制到按钮),动态可配置(别写死路由和按钮)。
内容
前端权限控制目前来说,主要控制两大块:路由和按钮,以及一个token鉴权。
token鉴权
从前端开发工作量排序,token这块其实最简单。
这里的token,不单指登录之后获取的用户凭证token本身,还包含诸如用户ID,租户ID,客户端ID等等信息,具体看项目实际情况中后端需要哪些内容。
处理token的步骤基本也已经标准化,这里只介绍其中三个关键步骤:本地化,请求拦截,清除本地化
token本地化
在拿到token后,除了要放到全局状态管理器中去以外,token还需要做本地化处理以应对F5页面刷新等情况。这里一般可以存放在localStorage
或者sessionStorage
里面。两者的区别在于,是否在于关闭页面后,是否判定为一次退出。(如果不判定为退出,会有重新打开会直接进入系统的情况,在公共电脑环境下还是不安全的)
请求拦截
把token带给后端,有几种处理方式。
放参数里面:这里要排出url参数(太蠢了),只能是放body参数里面,这个对于后端来说就不是很友好了,需要等请求进入之后,才能做鉴权。
放cookie里:这个在之前有很大一部分的方案都是这样玩的,众所周知chrome将在2025年彻底启用cookie。
放header里:应该是目前最优解了,后端可以在封装公共方法,在请求进入之前,拦截到头部的token进行校验。
具体代码以axios举例:
import axios from 'axios'
const service = axios.create({
baseURL: '/',
withCredentials: false, // 是否携带cookie
timeout: 30000 // 超时响应
})
// request 请求头配置
service.interceptors.request.use(
config => {
// 处理头部信息:添加token
if (getToken()) {
config.headers.Authorization = `Bearer ${getToken()}`
config.headers.userId = getUserId()
config.headers.clientId = getClientId()
}
// 其他处理(url参数序列化,form表单,其他特殊处理等)
// ...
return config
},
error => {
console.error(error)
return Promise.reject(error)
}
)
// response 响应
service.interceptors.response.use(
response => {
// 拦截响应
},
error => {
return Promise.reject(error)
}
)
export default service
退出清除
退出情况一般包含三种:关闭页面后退出,请求后端主动退出,服务器故障无法请求后端的退出。
关闭页面后退出,需要把token用sessionStorage
本地化,本地token自动被清除。
请求后端主动退出,请求成功后,服务器会自动让本地的token失效。在服务器无法正常访问的情况下,就需要手动清除token等本地化信息了。
按钮权限
其次是路由和按钮,其中按钮的颗粒度更加精细,但是它的控制是相当简单的。
一般来说,获取路由和按钮信息是同一个接口,更多情况还会集成在getUserInfo
接口中。按钮信息是一个字符串组成的数组,拿到之后需要存在全局状态管理器中。
// 按钮标识,单个按钮标识为字符串,建议根据模块:子模块:功能组成,更加语义化
const buttons = ['system:role:add', 'system:role:edit', 'system:role:delete','system:user:add', 'system:user:edit', 'system:user:delete']
获取到按钮权限信息后,首先做好本地化和全局状态。其次是使用自定义指令去做权限控制,这里为了方便调试,设置了一个开关,如果是本地模式下,放开所有按钮权限。
import { useUserStore } from '@/pinia'
import { isLocal } from '@/utils/helper' // 本地模式开关
export default {
install(app) {
// 按钮权限:检测当前按钮标签是否包含在从后端获取的按钮权限集合里,没有则删除当前按钮的dom节点
app.directive('permission', {
mounted(el, binding) {
if (!binding.value || isLocal()) return false
const userStore = useUserStore()
if (!userStore.buttons.includes(binding.value)) {
el.parentNode.removeChild(el)
}
}
})
// 多个按钮权限:主要是为了应对表格中操作栏中按钮都不存在却留下操作栏的问题
app.directive('permissions', {
mounted(el, binding) {
if (!binding.value || isLocal()) return false
const userStore = useUserStore()
let flag = false
for (const item of binding.value) {
if (userStore.buttons.includes(item)) {
flag = true
return
}
}
if (!flag) el.parentNode.removeChild(el)
}
})
}
}
使用方式:
<el-table :data="table.data">
<el-table-column label="帐号" prop="account"></el-table-column>
<el-table-column label="角色" prop="role"></el-table-column>
<el-table-column v-permissions="['system:user:edit', 'system:user:delete']" label="操作" fixed="right" width="200">
<template #default="scope">
<el-button v-permission="'system:user:edit'" type="primary" link @click="editItem(scope.row)">
编辑
</el-button>
<el-button v-permission="'system:user:delete'" type="primary" link @click="delteItem(scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
路由权限
最复杂的可能就是路由了,需要考虑路由权限的获取和页面渲染时机,动态路由的配置和加载,白名单,无权限页面,禁止访问页面,错误页面等等。后续将重点围绕这里进行展开。
控制策略
前端权限控制很有很多种方式,需要针对不同项目需求或者项目团队配置来选择适合自己的最优解。
这里我就简单介绍一下我写过的几种前端权限控制策略。
固定角色,固定路由,根据角色id控制权限。 动态角色,固定路由,前端有自己的路由,根据路由标识来控制权限。 动态角色,动态路由,前端不存自己的路由,动态去加载应用路由信息。
固定角色,固定路由的策略其实比较简单,这里只做简单描述。首先需要在本地路由中配置meta信息,通过getUserInfo获取用户的角色ID进行匹配。匹配上了就可以进入当前页面,反之则跳转404或无权限页面。
// router
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
meta: {
title: '首页',
auth: ['admin', 'editor']
}
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
meta: {
title: '测试',
auth: ['admin']
}
},
]
})
export default router
import router from '@/router'
import webStorage from '@/utils/webStorage'
import { useUserStore } from '@/pinia'
import { isLocal } from '@/utils/helper' // 本地模式开关
// 白名单
const whiteList = ['/login']
// 是否已获取到用户权限信息
let isGetAuth = false
const loadRoute = async () => {
const userStore = useUserStore()
await userStore.getUserInfo(isLocal)
isGetAuth = true
}
router.beforeEach(async (to, from, next) => {
// 设置页面标题
if (to.meta.title) {
document.title = to.meta.title
}
// 跳转到登录页
if (to.path === '/login') {
isGetAuth = false
}
// 白名单内直接跳转
if (whiteList.includes(to.path)) {
next()
return
}
// 判断是否登录
if (webStorage.get('token') || isLocal) {
if (isGetAuth) {
// 获取到用户权限信息后,匹配当前路由是否包含用户角色ID
const userStore = useUserStore()
if (to.meta.auth.includes(userStore.roleId) {
next()
} else {
next('/404')
}
} else {
// 没有加载权限,则先加载权限
await loadRoute()
next({ ...to, replace: true })
}
} else {
next({ path: '/login' })
}
})
动态角色,固定路由判断的就不是角色ID,而是路由的ID了。这里路由的ID也不需要另外单独取一个字段,可以用具有唯一标识性质的path或者name字段。另外,在系统中,需要增加一个权限配置页面。
固定路由时,这个权限树的数据在本地维护。获取到用户权限信息用,匹配当前路径path或者路由名称name是否存在于后端返回的权限集合里面。
到了动态角色,动态路由的情况,权限树就需要在线上维护了。
动态路由
第一页面
因为是动态路由,没有固定首页的概念,所以无法确定进入应用后应该跳转到哪个页面。所以有两个思路:
基座新增欢迎页面当作固定页面,做一个不涉及权限的页面,随便写一些欢迎之类的内容,固定跳转到这个页面即可。唯一的要求是欢迎页面尽量做好看一点。 获取到动态路由之后,通过递归方法查找路由树种的具体页面,拿到第一个就跳转。
// 获取第一个类型为菜单的页面(resourceType === 2),跳转到第一个页面
const getFirstRoute = routes => {
let route = {}
const traverse = tree => {
for (let i = 0; i < tree.length; i++) {
const item = tree[i]
if (item.meta.resourceType === '2') {
route = item
break
} else {
if (Array.isArray(item.children)) {
traverse(item.children)
}
}
}
}
traverse(routes)
return route
}
除此之外,也可以在权限配置的时候,指定跳转到应用进入的第一个页面,略微有点麻烦。
白名单
白名单页面则不需要鉴权就可以直接进入,通常包括登录页,欢迎页面,无权限页面,错误页面,禁止访问页面等。
登录页面需要加判断,进入之后清除token,路由权限和按钮权限在全局状态和本地化的信息,以保证用户信息安全。
欢迎页或其他固定内页,不需要太多处理。
无权限页面相对于前两者,自身不能直接写在本地路由里面,需要动态路由加载完成之后,再动态插入,否则会因为路由没有匹配到而直接进入无权限页面。
// 先加载动态路由
flattenTree(this.routes).forEach(item => {
router.addRoute('layout', item)
})
// 后加载无权限页面
router.addRoute({
path: '/:pathMatch(.*)',
name: '404',
redirect: '/404',
component: () => import('@/views/Error/NotFound.vue')
})
微前端+外链
当项目为微前端时,组件并不是存在应用中,而是类iframe模式引用子应用页面。所以本地需要配置一个空白组件:
// eslint-disable-next-line vue/valid-template-root
<template></template>
同时路由需要更改为:
{
path: '/micro-vue/dashboard',
name: '子应用总览',
component: () => import('@/layout/EmptyComponent.vue'), // 固定使用空组件路径
meta: {
title: '子应用总览',
icon: 'iconfont icon-dashboard',
visible: true
}
}
总结
前端权限控制没有固定的玩法,需要根据项目需要和业务需求去具体分析。就saas项目来说,token鉴权,动态路由控制页面,自定义指令控制按钮这一套基本已经最大众的玩法。
本文转载于稀土掘金技术社区,作者:怪大叔9527
原文链接:https://juejin.cn/post/7433072306679119924
最后
关注福利,关注公众号后,在首页:
回复「简历」获取精选的简历模板
回复「思维图」获取完整 JavaScript 相关思维图
回复「电子书」可下载我整理的大量前端资源,包含面试、Vue实战项目、CSS和JavaScript电子书等。
回复「Node」获取简历制作建议
最后不要忘了点个赞再走噢