前端权限控制的基本实现

Liu Bowen

Liu Bowen / 2019, 六月, 25

在一般的中后台管理平台项目实现中,存在必不可少的权限控制需求。现阶段,在 Github 上有多种权限控制实现,本文将介绍一种依据后台的服务权限表 services api access 进行前端路由过滤形成全局私有路由表的一种权限控制实现。全文是作者在公司内部开发的一套内部管理平台的 亲身开发经历 总结而来。本文将主要讲述 强制性路由级别 权限控制和 可选性路由级别 权限控制与 按钮级权限控制

TL;DR

以后端对当前用户的账号密码进行验证,获取当前用户在后台各个模块系统的权限,在前端获取个服务模块的权限表后,然后前端对该权限表做形如 key-value 映射结构处理,以形成利于前端使用的 access map。基于此 access map 前端可借助遍历未初始化全局私有路由表(后文将详细介绍该私有路由表)组合形成当前登陆用户的私有路由表。 基于 access mapvue 实例的全局方法 $_hasAccess 和自定义指令 v-hasAccess 实现 按钮级权限控制

本实现中主要 核心原理 涉及 vue-routerbeforeEach 前置导航守卫和映射数据结构(key & value 结构)实现。

实现特性

最终本文实现将实现以下特性:

  1. 路由的强制性权限和可选性权限实现。

  2. 全局动态私有路由创建和删除。

    • 此时的全局路由表包含公有的公共路由表和针对当前用户的权限表的私有路由表
  3. **按钮(元素)**级别权限控制。

    • 通过一个全局的权限验证函数实现 $_hasAccess

    • 通过自定义指令实现 v-access 以用于 <template> 模板中的权限验证。

预设的未初始化全局私有路由表

在进行一系列的路由转换映射,全局私有路由创建之前,首先对服务端响应的每一个服务模块的权限的名称做以下约定:

js
'service.read'

// 形如 '服务模块名称.权限'

后续的一系列权限映射,路由创建和动态路由验证都 依赖 于此名称,它将作为 key-value 数据结构中的 key 值。值得注意的是,你可以根据自己业务的实际情况创建自己的权限名。以上不做强制性约束,仅仅为一个约定而已。

对单个私有路由做出以下约定:

js
export default [
  // force access without children route
  {
    path: '/sample-route',
    component: () => import(
      /* webpackChunkName: route-sample */ 'PAGES/sample'
    ),
    meta: {
      title: 'SampleTitle',
      access: [
        'sample.read'
        'sample.write'
      ]
    }
  },
  // force access with children route
  {
    path: '/example-route',
    component: () => import(
      /* webpackChunkName: route-example */ 'PAGES/example'
    ),
    meta: {
      title: 'ExampleTitle'
    },
    children: [
      {
        path: 'child'
        component: () => import(
          /* webpackChunkName: route-example-child */ 'PAGES/example/child'
        ),
        meta: {
          title: 'ExampleChildTitle',
          access: [
            'example.read'
            'example.write'
          ]
        }
      }
    ]
  }
  // more access routes
]

在每一个私有路由中,定义一个用于验证权限的字段 access 来用于后续 私有路由创建动态 验证。同样地,你可以结合自身业务的情况创建自己的目标检测字段。

js
meta[自定义的权限验证字段]
// 自定义的权限验证字段形如上文示例代码中的 access 属性

另外当有可选的权限验证需求,即只需要满足其中一项条件,即可通过路由验证的需求时,可约定以下私有路由结构:

js
export default [
  {
    path: '/route-with-optional-access',
    component: () =>
      import(
        /* webpackChunkName: route-with-optional-access */ 'PAGES/optional'
      ),
    meta: {
      title: 'OptionalAccess',
      optionalAccess: ['optional.read', 'optional.write', 'optional.import']
    }
  }
]

映射服务端权限表

为了便于后期对当前用户的权限表进行查询,这里将采用 key-value 键值对的 map 结构来存储服务端响应的当前用户的各个服务权限表。

ts
// 服务端返回的单个 access 数据结构如下
interface Access {
  id: string
  access: string
  [extraKey: string]: any
}

async function createAccessMap(username: string, password: string) {
  try {
    const { access }: { access: Access[] } = await fetch(url.LOGIN, {
      method: 'POST',
      body: JSON.stringify({ name, password })
    }).then(res => res.json())

    return access.reduce((map, serviceAccess) => {
      // 以 access 的名称作为键名,access 对象本身作为键值
      map[serviceAccess.access] = serviceAccess
      return map
    }, {})
  } catch (e) {
    console.error(e)
  }
}

以上代码是借助 普通对象 来实现数据的 map 化,当然这里也可以使用 ES6 map 对象 实现同样的效果。在后续创建当前用户的私有路由表时,仅需像以下形式即可查询是否具有对应的权限。

ts
if (accessMap[accessName]) {
  // 存在名为 accessName 的权限
} else {
  // 不存在名为 accessName 的权限
}

beforeEach 的职责

在根据服务端响应的各个功能服务的权限创建对应的 accessMap 后,那么如何根据这个 权限映射 表来创建当前用户的私有路由表呢?这里首先谈谈前文所述的 beforeEach 全局路由前置导航守卫在当前应用中的职责,或理解为我给 beforeEach 在前端权限控制的定位:

  1. 路由初始化:在未初始化当前用户的全局私有路由时,向服务端请求当前用户的服务权限表,类似 serviceSample.readserviceSample.write 等表示对某一功能服务模块的读取,写入等权限所构成的权限表。在获取到当前用户的权限表后,通过遍历权限表,并与预设的未初始化的全局私有路由表进行对比过滤,形成最终用户可访问的全局路由表。

    未初始化的全局私有路由表,表示一种由所有用户可访问的私有路由形成的私有路由表。当某一用户登陆时,只能访问其中的某一部分路由,其由当前用户的服务权限表决定。

  2. 路由动态验证:在每一次的路由切换时,验证当前用户在全局路由表中的 to 路由是否具有访问权限。即实现 动态的 路由权限验证。

路由级权限控制

在实现对服务端的权限表进行 map 化存储和未初始化的预设私有路由表做出约定后。接下来将进行全局私有路由的初始化流程。

登陆验证

beforeEach 全局前置导航守卫中首先对登陆行为进行限制。

js
if (WHITELIST.includes(to.path)) return next()

try {
  assert(token.getItem(), 'User has logout')

  // fetching and store user access collection

  // create global routes map

  // optional step: real-time access control
} catch (err) {
  console.warn(err.message)
  next({
    path: `/login?redirect=${to.path}`,
    replace: true
  })
}

以上表明,在未检测到 token 存储时,将重定向至 login 页。另在第一行需将重定向目标页加入到白名单中,以防止无限重定向。

获取并存储用户权限表

js
if (WHITELIST.includes(to.path)) return next()

try {
  assert(token.getItem(), 'User has logout')

  // fetching and store user access collection
  if (
    // 情景一:用户登陆后,未获取权限表时
    !store.getters['login/accesses'].length ||
    // 情节二:用户在已登录的情况下,另打开一个 tab,将导致 accessToken 为空
    !store.getters['login/accessToken']
  ) {
    try {
      // 向服务端获取当前用户的 access 列表,并临时存储于 store 中
      await store.dispatch('login/fetchUserAccess', token.getItem())
    } catch (err) {
      // 对错误的抽象化处理,非此处的重点
      errorHandler(e, next, to.path)
    }
  }

  // create global routes map

  // optional step: real-time access control
} catch (err) {
  console.warn(err.message)
  next({
    path: `/login?redirect=${to.path}`,
    replace: true
  })
}

在获取并临时存储用户的服务权限表时,应该要考虑到两种情况:

  1. 当用户首次登陆时,或因 token 过期重登陆时,此时的 accesses 用户权限表的容器是为空的。此时,需要从服务端实时拉取当前登陆用户的 access 表。

  2. 另外在当前用户已经登陆,且存在 accesses 用户权限表时,用户另外打开一个浏览器 tab 标签时,在新的 tab 标签中,accesses 用户权限表将恢复默认值——一个空列表。此时无法判断上一个 tab 标签中的信息是否是实时有效的,故作者在此处实现为重新拉取当前用户的 accesses 权限表。

创建当前用户的 accessMap 映射

在上一步存储 accesses 表后,可在对应的 store modulegetters 中将 accesses 列表转换为当前用户的 accessMap 权限映射。这一步也是整个权限控制的 核心 之一。

js
// your getters file
export default {
  accessMap({ accesses }) {
    return accesses.reduce(
      (map, access) => ({
        ...map,
        [access['access']]: access
      }),
      {}
    )
  }
  // other getters ...
}

在构建完成 accessMapgetters 后,我们就可在整个应用的任意可引入 store 的位置实时得到 accessMap 映射。另外,这里是使用 key-value 的映射结构存储当前用户的 access 表。这么做的目的在于可以避免另外实现权限的搜索。我们可以直接通过调用映射的 key 值得到当前用户的某一权限。

  • 当你将 accessMap 存储在全局下的 store 时,你可以进行以下调用代码获取当前用户的某一权限:

    js
    store.getters.accessMap[targetAccessName]
    
  • 当你将 accessMap 存储在名为 moduleNamestore 模块下时,你可以通过以下调用代码获取当前用户的某一权限;

    js
    store.getters[`${moduleName}/accessMap`][targetAccessName]
    

创建私有路由表

创建私有路由表的思路是对预设的私有路由表进行递归,验证每一个预设私有路由是否能通过当前用户的权限验证。存在子路由时,继续递归子路由。示例代码如下:

js
export default function createPrivateRoutes(accessMap, routes = privateRoutes) {
  return routes.reduce((filteredRoutes, route) => {
    const routeCopy = { ...route } // shallow copy

    if (validateAccess(route, accessMap)) {
      if (routeCopy.children) {
        // filter children routes recursively
        routeCopy.children = createPrivateRoutes(accessMap, routeCopy.children)
      }

      if (!(routeCopy.children && !routeCopy.children.length)) {
        filteredRoutes.push(routeCopy)
      }
    }
    return filteredRoutes
  }, [])
}

此时我们,仅仅另外完成单个预设私有路由的验证,并结合上述代码就可实现当前用户的私有路由表的创建。因为之前我们已经对当前用户的所有服务权限做了 key-value 映射,那么我们可以很容易地得出以下权限验证实现:

js
export function validateAccess(route, accessMap) {
  const { meta } = route
  if (meta && meta.access) {
    return meta.access.every(access => !!accessMap[access])
  }

  if (meta && meta.optionalAccess) {
    return meta.optionalAccess.some(access => !!accessMap[access])
  }
}

上述代码中,值得注意的一个细节是,以上实现是默认单个预设私有路由的强制性权限验证的优先级高于可选性权限验证的优先级。

创建全局路由表并存储

在之前根据当前用户的服务权限表创建完成特有的私有路由后,我们此时就可实现动态地将私有路由和原本的静态公有路由进行合并,得到最终的当前用户全局路由表。

js
import router from 'ROUTER' // vue-router instance

function createRouteMap(to, next) {
  const privateRoutes = createPrivateRoutes(store.getters['login/accessMap'])

  store.commit(`login/${loginTypes.SET_ALL_ROUTES}`, [
    ...publicRoutes,
    ...privateRoutes
  ])

  router.addRoutes(privateRoutes)
  HAS_ROUTES_ADDED = true
  return next({ ...to, replace: true })
}

// ...

router.beforeEach(async (to, from, next) => {
  // ...

  // optional step: real-time access control
  if (store.getters['login/accesses'].length && !HAS_ROUTES_ADDED) {
    return createRoutesMap(to, next)
  }

  // ...
})

这里存储全局路由表的目的是为了后期的管理平台的侧边栏动态渲染,如果你有特别的需求,亦可于此处存储当前用户的私有路由表。

更多关于动态向 vue-router 实例添加路由的细节可参考 addRoutes

至此,以上全部的小章节就是全局路由初始化的实现思路。总结一下,就是通过当前用户的权限列表进行合理的格式化,并通过当前用户的权限表来确定当前用户的私有路由,并最终借助 addRoutes 完成全局路由表的创建。

全局静态路由的动态权限验证

另外,如果我们需要对一个静态路由进行权限验证该如何实现呢?其实,动态权限验证的本质还是和之前的预设私有路由的权限验证的本质是一致的。示例代码如下:

js
if (!to.meta.access || validateAccess(to, store.getters['login/accessMap'])) {
  next()
} else {
  next({
    path: `/401?redirect=${to.path}`,
    replace: true
  })
}

通过一个 if 语句判断是否通过当前静态路由的权限判断,在通过之后将继续导航至目标页,否则将强制跳转至 401 Unauthorized 错误页。

路由权限控制实现

以下示例代码,即是前端的 路由级 的权限控制实现。

js
import store from 'STORE'
import router from 'ROUTER'
import presetPrivateRoutes from '/path/to/your/controller/'
import publicRoutes from '/path/to/your/public/routes'

let HAS_ROUTES_INITIALIZE = false
const WHITELIST = ['/login']

router.beforeEach(async (to, from, next) => {
  // 此处为了防止无限重定向,必须将重定向的目的页加入到白名单中
  if (WHITELIST.includes(to.path)) return next()

  try {
    assert(token.getItem(), 'User has logout')

    // fetching and store user access collection
    if (
      // 情景一:用户登陆后,未获取权限表时
      !store.getters['login/accesses'].length ||
      // 情节二:用户在已登录的情况下,另打开一个 tab,将导致 accessToken 为空
      !store.getters['login/accessToken']
    ) {
      try {
        // 向服务端获取当前用户的 access 列表,并临时存储于 store 中
        await store.dispatch('login/fetchUserAccess', token.getItem())
      } catch (err) {
        // 对错误的抽象化处理,非此处的重点
        errorHandler(e, next, to.path)
      }
    }

    // 用户在登陆后,初始化初始路由表,并使得导航 resolved,结束导航守卫的回调执行
    if (store.getters['login/accesses'].length && !HAS_ROUTES_ADD) {
      return createRoutesMap(to, next)
    }

    /**
     * @description 可选步骤:每次导航时,都执行实时的路由权限验证
     * 适用场景是,存在一个未经过路由过滤的静态路由,需要对其进行动态的路由权限验证
     */
    try {
      assert(
        !to.meta.access || validateAccess(to, store.getters['login/accessMap']),
        '401 Access Deny'
      )
      next()
    } catch (e) {
      next({
        path: `401?redirect=${to.path}`,
        replace: true
      })
    }
  } catch (err) {
    console.warn(err.message)
    next({
      path: `/login?redirect=${to.path}`,
      replace: true
    })
  }

  // 本地存储中是否存在 token
  if (token.getItem()) {
    if (
      // 用户在已登录的情况下,另打开一个 tab,将导致 accessToken 为空
      !store.getters['login/accessToken'] ||
      !store.getters['login/accesses'].length
    ) {
      try {
        // 向服务端获取当前用户的 access 列表,并临时存储于 store 中
        await store.dispatch('login/fetchUserAccess', token.getItem())
      } catch (err) {
        // 对错误的抽象化处理,非此处的重点
        errorHandler(e, next, to.path)
      }
    }

    // create global routes map
    // 用户在登陆后,初始化初始路由表,并使得导航 resolved,结束导航守卫的回调执行
    if (store.getters['login/accesses'].length && !HAS_ROUTES_ADD) {
      return createRoutesMap(to, next)
    }

    /**
     * optional step: real-time access control
     * @description 可选步骤:每次导航时,都执行实时的路由权限验证
     * 适用场景是,存在一个未经过路由过滤的静态路由,需要对其进行动态的路由权限验证
     */
    try {
      assert(
        !to.meta.access || validateAccess(to, store.getters['login/accessMap']),
        '401 Access Deny'
      )
      next()
    } catch (e) {
      next({
        path: `401?redirect=${to.path}`,
        replace: true
      })
    }
  } else {
    // 不存在 token 时,将强制重定向至 login 页
    return next({
      path: `/login?redirect${to.path}`,
      replace: true
    })
  }
})

按钮(元素)级权限控制

之前在完成路由级权限控制之后,接下来将主要介绍 按钮(元素)级权限控制 的实现。

  • $_hasAccess 通过 Vue.js 插件 的方式向 vue 原型挂载一个用于权限验证的原型方法,使得可在任意 Vue 实例中进行权限验证。

  • v-accessvue.js 自定义指令 的方式实现权限验证功能。

全局权限控制方法实现

这里可直接复用之前路由权限验证核心的验证逻辑,示例代码如下:

js
// ACCESS/controller/access.js
import store from 'STORE'

function hasAccess(access, accessMap = store.getters['login/accessMap']) {
  return !!accessMap[access]
}

function hasEveryAccess(
  accesses,
  accessMap = store.getters['login/accessMap']
) {
  if (Array.isArray(accesses)) {
    return accesses.every(access => !!accessMap[access])
  }
  throw new Error(`[hasEveryAccess]: ${accesses} should be a array !`)
}

function hasSomeAccess(accesses, accessMap = store.getters['login/accessMap']) {
  if (Array.isArray(accesses)) {
    return accesses.some(access => !!accessMap[access])
  }
  throw new Error(`[hasSomeAccess]: ${accesses} should be a array !`)
}

export default {
  install(Vue) {
    Vue.prototype.$_hasAccess = hasAccess
    Vue.prototype.$_hasEveryAccess = hasEveryAccess
    Vue.prototype.$_hasSomeAccess = hasSomeAccess
  }
}

在实现以上 Vue 原型方法后,直接使用 Vue.use 方法实现挂载:

js
import hasAccess from 'ACCESS/controller/hasAccess'
import Vue from 'vue'

Vue.use(hasAccess)

之后在应用的任意非函数式组件内通过以下调用实现权限检测:

js
// 检测是否具有 device 的读取权限
this.$_hasAccess('device.read')

若是 Vue 的函数式组件,那么可通过以下方式实现实时的权限检测:

js
// 检测是否具有 device 的读取权限
parent.$_hasAccess('device.read')

其中 parent 表示对父级非函数式组件实例的引用。

  • 同时检测多个权限可通过以下方式实现

    js
    // 同时检测多个权限
    this.$_hasEveryAccess(['device.read', 'device.write'])
    

    或者

    js
    SomeAccessList.every(access => this.$_hasAccess(access))
    
  • 检测是否具有某一个权限

    js
    this.$_hasSomeAccess(['device.read', 'device.write'])
    

    或者

    js
    SomeAccessList.some(access => this.$_hasAccess(access))
    

使用指令实现权限控制

使用自定义指令实现权限控制的基本原理同样是基于之前文章中介绍到的权限检测核心——当前用户的权限映射。经过自定义指令及指令的修饰符可以使得开发者在 <template> 模板中快速便捷地实现权限控制。实现如下:

js
// ACCESS/controller/access.js

// ...

export default {
  install(Vue) {
    Vue.prototype.$_hasAccess = hasAccess
    Vue.prototype.$_hasEveryAccess = hasEveryAccess
    Vue.prototype.$_hasSomeAccess = hasSomeAccess

    /**
     * @description Support .some .every directive modifiers
     * @usage
     *    <element v-access="admin.device.read" />
     *    <element v-access.some="['admin.device.read']" />
     *    <element v-access.every="['admin.device.read']" />
     */
    Vue.directive('access', {
      inserted: function (el, { value, modifiers }) {
        if (value === undefined)
          throw new Error('[v-access]: should input an access list.')
        let isVerified = hasAccess(value)

        if (modifiers.some) {
          isVerified = hasSomeAccess(Array.isArray(value) ? value : [value])
        }

        if (modifiers.every) {
          isVerified = hasEveryAccess(Array.isArray(value) ? value : [value])
        }

        if (!isVerified) {
          el.parentNode && el.parentNode.removeChild(el)
        }
      }
    })
  }
}

在以上实现中,借助了 指令修饰符 来实现对单个、某一个、多个权限的同时检测功能。

  • 单个权限检测

    html
    <vue-component v-access="admin.device.read" />
    

    以上代码仅当用户的权限表中包含对应的 admin.device.read 才会显示该元素。

  • 可选性权限检测

    html
    <vue-component v-access.some="['user.device.read', 'user.device.write]" />
    

    以上代码仅仅需要用户的权限表包含给定的权限列表中的 任意一项 就会通过权限检测。

  • 强制性多个权限检测

    html
    <vue-component v-access.every="['user.device.read', 'user.device.write]" />
    

    以上代码仅当用户的权限表中包含给定的权限列表中 所有 的项才会显示该元素。

至此,以上就是对按钮(元素)级权限验证的实现。

总结

在整个实现过程中,核心的地方在于如何 高效地 实现权限搜索,本实现是将原有的权限列表 转换 为权限 映射,结合映射数据结构的特点可快速实现权限的判断。在完成权限的搜索实现后,结合搜索实现可实现权限的验证模块。通过权限验证模块,可结合自身的业务需要,决定如何将预设的私有路由表经过权限模块的过滤,最终实现对当前用户的私有路由的生成。