Vue Router与后端权限系统的协调:实现客户端导航守卫与服务端拦截器的同步

Vue Router与后端权限系统的协调:实现客户端导航守卫与服务端拦截器的同步

大家好,今天我们来深入探讨一个在现代Web应用开发中至关重要的话题:Vue Router与后端权限系统的协调,以及如何实现客户端导航守卫与服务端拦截器的同步。权限控制是任何复杂应用的核心组成部分,它确保只有授权用户才能访问特定的资源和功能。前端的导航守卫和后端的拦截器是实现权限控制的两种主要方式,而如何让它们协同工作,保证一致性和安全性,是我们需要解决的关键问题。

一、权限控制的必要性与挑战

首先,为什么我们需要权限控制?

  • 数据安全: 防止未授权用户访问敏感数据,例如用户个人信息、财务数据等。
  • 功能限制: 限制用户只能使用其被授权的功能,避免误操作或恶意操作。
  • 合规性: 满足法律法规对数据访问和使用的要求,例如GDPR等。

然而,实现一个可靠的权限控制系统并非易事。我们面临以下挑战:

  • 一致性: 前端和后端的权限规则必须保持一致,避免出现前端允许访问,后端拒绝访问,或者反过来的情况。
  • 安全性: 权限控制逻辑必须安全可靠,防止被绕过或篡改。
  • 性能: 权限验证过程不能过于耗时,影响用户体验。
  • 可维护性: 权限规则应该易于管理和维护,方便适应业务变化。

二、前端导航守卫与后端拦截器的作用

为了应对这些挑战,我们通常会同时使用前端导航守卫和后端拦截器:

  • 前端导航守卫: Vue Router提供的导航守卫机制,允许我们在路由切换前后执行自定义逻辑。我们可以利用它来检查用户是否已登录、是否具有访问目标路由的权限,从而决定是否允许跳转。

  • 后端拦截器: 在后端服务中,拦截器(或中间件)可以拦截所有请求,检查用户身份和权限,然后决定是否允许请求访问目标资源。

三、基于角色的访问控制 (RBAC)

在开始代码实现之前,我们需要定义权限模型。最常见的模型之一是基于角色的访问控制 (RBAC)。RBAC将权限分配给角色,然后将角色分配给用户。这种模型具有以下优点:

  • 简化权限管理: 易于管理大量用户和权限。
  • 灵活性: 可以根据需要创建和修改角色。
  • 可扩展性: 容易适应业务变化。

例如,我们可以定义以下角色:

角色 权限
管理员 拥有所有权限,可以访问所有资源。
编辑 可以创建、编辑和删除文章。
读者 只能阅读文章。

四、前端导航守卫的实现

首先,我们需要在Vue Router中配置导航守卫。以下是一个简单的示例:

import Vue from 'vue'
import VueRouter from 'vue-router'
import store from './store' // Vuex store
import { getToken } from '@/utils/auth' // 获取 token
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style

Vue.use(VueRouter)

const routes = [
  {
    path: '/login',
    component: () => import('@/views/login/index'),
    hidden: true
  },
  {
    path: '/',
    component: () => import('@/views/layout/index'),
    redirect: '/dashboard',
    children: [{
      path: 'dashboard',
      name: 'Dashboard',
      component: () => import('@/views/dashboard/index'),
      meta: { title: 'Dashboard', icon: 'dashboard' }
    }]
  },
  {
    path: '/article',
    component: () => import('@/views/layout/index'),
    children: [{
      path: 'list',
      name: 'ArticleList',
      component: () => import('@/views/article/list'),
      meta: { title: '文章列表', icon: 'list', roles: ['admin', 'editor'] } // 只有 admin 和 editor 角色才能访问
    }]
  },
  // 404 page must be placed at the end !!!
  { path: '*', redirect: '/404', hidden: true }
]

const router = new VueRouter({
  mode: 'history', // require service support
  scrollBehavior: () => ({ y: 0 }),
  routes
})

NProgress.configure({ showSpinner: false }) // NProgress Configuration

const whiteList = ['/login'] // no redirect whitelist

router.beforeEach(async(to, from, next) => {
  // start progress bar
  NProgress.start()

  // determine whether the user has logged in
  const hasToken = getToken()

  if (hasToken) {
    if (to.path === '/login') {
      // if is logged in, redirect to the home page
      next({ path: '/' })
      NProgress.done() // hack: https://github.com/PanJiaChen/vue-element-admin/pull/2939
    } else {
      // determine whether the user has obtained his roles through vuex
      const hasRoles = store.getters.roles && store.getters.roles.length > 0
      if (hasRoles) {
        next()
      } else {
        try {
          // get user info
          // note: roles must be a object array! such as: ['admin'] or ,['developer','editor']
          const { roles } = await store.dispatch('user/getInfo')

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

          // dynamically add accessible routes
          router.addRoutes(accessRoutes)

          // hack method to ensure that the addRoutes is complete
          // set the replace: true, so the navigation will not leave a history record
          next({ ...to, replace: true })
        } catch (error) {
          // remove token and go to login page to re-login
          await store.dispatch('user/resetToken')
          console.log(error)
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      }
    }
  } else {
    /* has no token*/

    if (whiteList.indexOf(to.path) !== -1) {
      // in the free login whitelist, go directly
      next()
    } else {
      // other pages that do not have permission to access are redirected to the login page.
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  // finish progress bar
  NProgress.done()
})

export default router

在这个例子中,router.beforeEach 是一个全局前置守卫,它会在每次路由切换之前被调用。

  1. 检查 Token: 首先,我们检查用户是否已登录,即是否存在 token

  2. 已登录状态处理: 如果已登录,并且尝试访问 /login 页面,则重定向到首页。否则,检查用户是否已经获取了角色信息。

  3. 未获取角色信息: 如果尚未获取角色信息,则调用 store.dispatch('user/getInfo') 从后端获取用户信息,包括角色信息。然后,调用 store.dispatch('permission/generateRoutes', roles) 根据角色动态生成可访问的路由,并使用 router.addRoutes 添加到路由表中。

  4. 未登录状态处理: 如果未登录,并且访问的页面不在白名单中,则重定向到登录页面。

  5. 动态路由: store.dispatch('permission/generateRoutes', roles) 根据用户的角色动态生成可访问的路由。这部分逻辑通常会根据后端返回的权限数据,过滤掉用户无权访问的路由。

五、后端拦截器的实现

后端拦截器的实现方式取决于你使用的后端框架。这里以 Node.js + Express + JWT 为例:

const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();

// 密钥,用于签名和验证 JWT
const secretKey = 'your-secret-key';

// 模拟用户数据
const users = {
  'admin': { id: 1, username: 'admin', roles: ['admin'] },
  'editor': { id: 2, username: 'editor', roles: ['editor'] },
  'reader': { id: 3, username: 'reader', roles: ['reader'] }
};

// 模拟文章数据
const articles = {
  1: { id: 1, title: '文章1', content: '只有管理员和编辑才能访问', roles: ['admin', 'editor'] },
  2: { id: 2, title: '文章2', content: '所有人都可以访问', roles: [] } // 空数组表示所有人都可以访问
};

// 登录接口
app.post('/login', (req, res) => {
  const { username, password } = req.body;

  // 模拟登录验证
  const user = users[username];
  if (user) {
    // 生成 JWT
    const token = jwt.sign({ id: user.id, username: user.username, roles: user.roles }, secretKey, { expiresIn: '1h' });
    res.json({ token });
  } else {
    res.status(401).json({ message: '用户名或密码错误' });
  }
});

// 鉴权中间件
const authenticate = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ message: '未提供 token' });
  }

  jwt.verify(token, secretKey, (err, user) => {
    if (err) {
      return res.status(403).json({ message: '无效的 token' });
    }

    req.user = user;
    next();
  });
};

// 权限校验中间件
const authorize = (roles) => {
  return (req, res, next) => {
    if (!req.user || !req.user.roles) {
      return res.status(403).json({ message: '无权访问' });
    }

    const hasPermission = roles.some(role => req.user.roles.includes(role));

    if (!hasPermission) {
      return res.status(403).json({ message: '无权访问' });
    }

    next();
  };
};

// 获取文章列表接口
app.get('/articles', authenticate, (req, res) => {
  res.json(articles);
});

// 获取特定文章接口
app.get('/articles/:id', authenticate, (req, res) => {
  const articleId = parseInt(req.params.id);
  const article = articles[articleId];

  if (!article) {
    return res.status(404).json({ message: '文章未找到' });
  }

  // 如果文章定义了访问角色,则进行权限校验
  if (article.roles && article.roles.length > 0) {
    if (!req.user || !req.user.roles) {
      return res.status(403).json({ message: '无权访问' });
    }

    const hasPermission = article.roles.some(role => req.user.roles.includes(role));

    if (!hasPermission) {
      return res.status(403).json({ message: '无权访问' });
    }
  }

  res.json(article);
});

// 创建文章接口,需要管理员或编辑权限
app.post('/articles', authenticate, authorize(['admin', 'editor']), (req, res) => {
  // 创建文章的逻辑
  res.json({ message: '文章创建成功' });
});

app.listen(3000, () => {
  console.log('Server listening on port 3000');
});

在这个例子中:

  1. 登录接口: POST /login 接收用户名和密码,验证通过后生成 JWT (JSON Web Token) 并返回给客户端。

  2. 鉴权中间件: authenticate 中间件用于验证 JWT。它从请求头中获取 Authorization 字段,解析 JWT,如果 JWT 无效或过期,则返回 401 或 403 错误。如果 JWT 验证通过,则将用户信息存储在 req.user 中。

  3. 权限校验中间件: authorize 中间件用于检查用户是否具有访问特定资源的权限。它接收一个角色列表,如果用户的角色列表中包含其中任何一个角色,则允许访问,否则返回 403 错误。

  4. 受保护的 API: GET /articlesPOST /articles 接口都使用了 authenticateauthorize 中间件,确保只有经过身份验证且具有相应权限的用户才能访问。

六、前后端权限同步的策略

实现前后端权限同步的关键在于,确保前后端使用相同的权限规则和数据源。以下是一些常用的策略:

  • 统一的权限配置: 将权限配置存储在数据库或配置文件中,前后端都从这里读取权限信息。
  • 统一的权限验证逻辑: 将权限验证逻辑封装成公共函数或服务,前后端都调用这些函数或服务进行权限验证。
  • 使用相同的角色定义: 前后端使用相同的角色名称和含义。
  • API驱动的权限控制: 后端API返回用户具有的权限列表,前端根据这些权限动态渲染页面和控制路由。

在上面的代码示例中,我们使用了API驱动的权限控制:

  • 后端: 在登录成功后,后端API返回包含用户角色信息的JWT。
  • 前端: 前端解析JWT,获取用户角色信息,并根据这些角色信息动态生成可访问的路由。

七、动态路由的实现

动态路由是实现灵活权限控制的关键。在前端,我们可以根据用户角色动态生成路由表。以下是一个简单的示例:

// store/modules/permission.js
import { asyncRoutes, constantRoutes } from '@/router'

/**
 * 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))
  }

  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
}

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 => {
      let accessedRoutes
      if (roles.includes('admin')) {
        accessedRoutes = asyncRoutes || []
      } else {
        accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
      }
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

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

在这个例子中:

  1. asyncRoutes 包含了需要根据角色进行过滤的路由。
  2. hasPermission 函数用于判断用户是否具有访问某个路由的权限。
  3. filterAsyncRoutes 函数用于根据用户角色过滤 asyncRoutes,生成用户可以访问的路由表。
  4. generateRoutes action 用于根据用户角色生成可访问的路由,并将其存储在 Vuex store 中。

八、常见问题与解决方案

  • 前端权限绕过: 即使前端隐藏了某个功能,用户仍然可以通过直接访问API来绕过权限控制。解决方案: 后端必须进行严格的权限验证,确保只有授权用户才能访问API。
  • 权限信息不同步: 用户角色发生变化后,前端权限信息没有及时更新。解决方案: 在用户角色发生变化时,强制刷新页面或重新获取用户信息。
  • 权限规则过于复杂: 权限规则过于复杂,难以维护和管理。解决方案: 尽量使用简单的RBAC模型,并使用清晰的命名规范。

九、代码演示:一个完整的例子

为了更好地理解前后端权限同步的实现,我们提供一个完整的例子。

前端 (Vue.js):

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

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

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
  },
  SET_EMAIL: (state, email) => {
    state.email = email
  }
}

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

  // get user info
  getInfo({ commit, state }) {
    return new Promise((resolve, reject) => {
      getInfo(state.token).then(response => {
        const { data } = response

        if (!data) {
          reject('Verification failed, please Login again.')
        }

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

        // roles must be a non-empty array
        if (!roles || roles.length <= 0) {
          reject('getInfo: roles must be a non-null array!')
        }

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

  // user logout
  logout({ commit, state, dispatch }) {
    return new Promise((resolve, reject) => {

      commit('SET_TOKEN', '')
      commit('SET_ROLES', [])
      removeToken()
      resetRouter()

      // reset visited views and cached views
      dispatch('tagsView/delAllViews', null, { root: true })

      resolve()
    })
  },

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

  // dynamically modify permissions
  async changeRoles({ commit, dispatch }, role) {
    const token = role + '-token'

    commit('SET_TOKEN', token)
    setToken(token)

    const { roles } = await dispatch('getInfo')

    resetRouter()

    // generate accessible routes map based on roles
    const accessRoutes = await dispatch('permission/generateRoutes', roles, { root: true })
    // dynamically add accessible routes
    router.addRoutes(accessRoutes)

    // reset visited views and cached views
    dispatch('tagsView/delAllViews', null, { root: true })
  }
}

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

后端 (Node.js + Express):

const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const app = express();

app.use(bodyParser.json());

const secretKey = 'your-secret-key';

const users = {
  'admin': { id: 1, username: 'admin', roles: ['admin'], name: '管理员', avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', introduction: '我是管理员', email: '[email protected]' },
  'editor': { id: 2, username: 'editor', roles: ['editor'], name: '编辑', avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', introduction: '我是编辑', email: '[email protected]' },
  'reader': { id: 3, username: 'reader', roles: ['reader'], name: '读者', avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', introduction: '我是读者', email: '[email protected]' }
};

app.post('/api/login', (req, res) => {
  const { username, password } = req.body;

  const user = users[username];
  if (user) {
    const token = jwt.sign({ id: user.id, username: user.username, roles: user.roles }, secretKey, { expiresIn: '1h' });
    res.json({ data: { token } });
  } else {
    res.status(401).json({ message: '用户名或密码错误' });
  }
});

const authenticate = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ message: '未提供 token' });
  }

  jwt.verify(token, secretKey, (err, user) => {
    if (err) {
      return res.status(403).json({ message: '无效的 token' });
    }

    req.user = user;
    next();
  });
};

app.get('/api/user/info', authenticate, (req, res) => {
  const user = users[req.user.username];
  if (user) {
    res.json({ data: { roles: user.roles, name: user.name, avatar: user.avatar, introduction: user.introduction, email: user.email } });
  } else {
    res.status(404).json({ message: '用户未找到' });
  }
});

app.listen(3000, () => {
  console.log('Server listening on port 3000');
});

十、总结

通过以上讨论和代码示例,我们了解了如何协调Vue Router与后端权限系统,实现客户端导航守卫与服务端拦截器的同步。关键在于理解权限控制的必要性、前端导航守卫和后端拦截器的作用、以及前后端权限同步的策略。通过统一的权限配置、验证逻辑和角色定义,我们可以构建一个安全、可靠且易于维护的权限控制系统。

更多IT精英技术系列讲座,到智猿学院

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注