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 是一个全局前置守卫,它会在每次路由切换之前被调用。
-
检查 Token: 首先,我们检查用户是否已登录,即是否存在
token。 -
已登录状态处理: 如果已登录,并且尝试访问
/login页面,则重定向到首页。否则,检查用户是否已经获取了角色信息。 -
未获取角色信息: 如果尚未获取角色信息,则调用
store.dispatch('user/getInfo')从后端获取用户信息,包括角色信息。然后,调用store.dispatch('permission/generateRoutes', roles)根据角色动态生成可访问的路由,并使用router.addRoutes添加到路由表中。 -
未登录状态处理: 如果未登录,并且访问的页面不在白名单中,则重定向到登录页面。
-
动态路由:
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');
});
在这个例子中:
-
登录接口:
POST /login接收用户名和密码,验证通过后生成 JWT (JSON Web Token) 并返回给客户端。 -
鉴权中间件:
authenticate中间件用于验证 JWT。它从请求头中获取Authorization字段,解析 JWT,如果 JWT 无效或过期,则返回 401 或 403 错误。如果 JWT 验证通过,则将用户信息存储在req.user中。 -
权限校验中间件:
authorize中间件用于检查用户是否具有访问特定资源的权限。它接收一个角色列表,如果用户的角色列表中包含其中任何一个角色,则允许访问,否则返回 403 错误。 -
受保护的 API:
GET /articles和POST /articles接口都使用了authenticate和authorize中间件,确保只有经过身份验证且具有相应权限的用户才能访问。
六、前后端权限同步的策略
实现前后端权限同步的关键在于,确保前后端使用相同的权限规则和数据源。以下是一些常用的策略:
- 统一的权限配置: 将权限配置存储在数据库或配置文件中,前后端都从这里读取权限信息。
- 统一的权限验证逻辑: 将权限验证逻辑封装成公共函数或服务,前后端都调用这些函数或服务进行权限验证。
- 使用相同的角色定义: 前后端使用相同的角色名称和含义。
- 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
}
在这个例子中:
asyncRoutes包含了需要根据角色进行过滤的路由。hasPermission函数用于判断用户是否具有访问某个路由的权限。filterAsyncRoutes函数用于根据用户角色过滤asyncRoutes,生成用户可以访问的路由表。generateRoutesaction 用于根据用户角色生成可访问的路由,并将其存储在 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精英技术系列讲座,到智猿学院