前端主流权限控制设计

文摘   2024-11-25 12:27   北京  

关注公众号 前端界,回复“加群

加入我们一起学习,天天进步

前端权限控制

简介

其实在早期web应用中,权限控制跟我们切图仔是没有半毛钱关系的,作为前端,写好样式和特效就是主要任务,大部分时间和精力都花在hack和jq上面了。

直到三大框架流行,单页应用开始兴起,前端有了路由的概念。同时,前后端分离,前端代码再也不用写到php,jsp,asp里面。前后端虽然是分离了,所以需要一个东西告诉彼此是自己人,token鉴权慢慢成为主流。自此,前端权限控制也有了概念,这个时候的前端权限控制,更多的是token鉴权和根据角色id去过滤路由。

在随后的发展中,前端越来越复杂,也承担了越来越多的任务,权限控制这块也相应提出了更多的需求,比如:细化颗粒度(控制到按钮),动态可配置(别写死路由和按钮)。

内容

前端权限控制目前来说,主要控制两大块:路由和按钮,以及一个token鉴权。

token鉴权

从前端开发工作量排序,token这块其实最简单。


这里的token,不单指登录之后获取的用户凭证token本身,还包含诸如用户ID,租户ID,客户端ID等等信息,具体看项目实际情况中后端需要哪些内容。

处理token的步骤基本也已经标准化,这里只介绍其中三个关键步骤:本地化,请求拦截,清除本地化

  1. token本地化

在拿到token后,除了要放到全局状态管理器中去以外,token还需要做本地化处理以应对F5页面刷新等情况。这里一般可以存放在localStorage或者sessionStorage里面。两者的区别在于,是否在于关闭页面后,是否判定为一次退出。(如果不判定为退出,会有重新打开会直接进入系统的情况,在公共电脑环境下还是不安全的)

  1. 请求拦截

把token带给后端,有几种处理方式。

放参数里面:这里要排出url参数(太蠢了),只能是放body参数里面,这个对于后端来说就不是很友好了,需要等请求进入之后,才能做鉴权。

放cookie里:这个在之前有很大一部分的方案都是这样玩的,众所周知chrome将在2025年彻底启用cookie。

放header里:应该是目前最优解了,后端可以在封装公共方法,在请求进入之前,拦截到头部的token进行校验。

具体代码以axios举例:

import axios from 'axios'

const service = axios.create({
  baseURL'/',
  withCredentialsfalse// 是否携带cookie
  timeout30000 // 超时响应
})

// 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
  1. 退出清除

退出情况一般包含三种:关闭页面后退出,请求后端主动退出,服务器故障无法请求后端的退出。

关闭页面后退出,需要把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, replacetrue })
    }
  } else {
    next({ path'/login' })
  }
})

动态角色,固定路由判断的就不是角色ID,而是路由的ID了。这里路由的ID也不需要另外单独取一个字段,可以用具有唯一标识性质的path或者name字段。另外,在系统中,需要增加一个权限配置页面。

image.png

固定路由时,这个权限树的数据在本地维护。获取到用户权限信息用,匹配当前路径path或者路由名称name是否存在于后端返回的权限集合里面。

到了动态角色,动态路由的情况,权限树就需要在线上维护了。

image.png

动态路由

  1. 第一页面

因为是动态路由,没有固定首页的概念,所以无法确定进入应用后应该跳转到哪个页面。所以有两个思路:

  • 基座新增欢迎页面当作固定页面,做一个不涉及权限的页面,随便写一些欢迎之类的内容,固定跳转到这个页面即可。唯一的要求是欢迎页面尽量做好看一点。
  • 获取到动态路由之后,通过递归方法查找路由树种的具体页面,拿到第一个就跳转。
// 获取第一个类型为菜单的页面(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
}

除此之外,也可以在权限配置的时候,指定跳转到应用进入的第一个页面,略微有点麻烦。

  1. 白名单

白名单页面则不需要鉴权就可以直接进入,通常包括登录页,欢迎页面,无权限页面,错误页面,禁止访问页面等。

登录页面需要加判断,进入之后清除token,路由权限和按钮权限在全局状态和本地化的信息,以保证用户信息安全。

欢迎页或其他固定内页,不需要太多处理。

无权限页面相对于前两者,自身不能直接写在本地路由里面,需要动态路由加载完成之后,再动态插入,否则会因为路由没有匹配到而直接进入无权限页面。

// 先加载动态路由
flattenTree(this.routes).forEach(item => {
  router.addRoute('layout', item)
})
// 后加载无权限页面
router.addRoute({
  path'/:pathMatch(.*)',
  name'404',
  redirect'/404',
  component() => import('@/views/Error/NotFound.vue')
})
  1. 微前端+外链

当项目为微前端时,组件并不是存在应用中,而是类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'
    visibletrue
  }
}

总结

前端权限控制没有固定的玩法,需要根据项目需要和业务需求去具体分析。就saas项目来说,token鉴权,动态路由控制页面,自定义指令控制按钮这一套基本已经最大众的玩法。

本文转载于稀土掘金技术社区,作者:怪大叔9527

原文链接:https://juejin.cn/post/7433072306679119924



最后

大家好,我是芝士,最近创建了一个低代码/前端工程化/高级前端面试交流群,欢迎加我微信,拉你进群,互相监督学习进步等!



关注福利,关注公众号后,在首页:

  • 回复「简历获取精选的简历模板

  • 回复「思维图」获取完整 JavaScript 相关思维图

  • 回复「电子书」可下载我整理的大量前端资源,包含面试、Vue实战项目、CSS和JavaScript电子书等。

  • 回复「Node」获取简历制作建议

最后不要忘了点个赞再走噢

前端界
高质量文章分享、实践干货、技术前沿、学习资料, 你感兴趣的都在前端界
 最新文章