「安全认证」基于Shiro前后端分离的认证与授权(三.前端篇)

前两篇我们整合了SpringBoot+Shiro+JWT+Redis实现了登录认证,接口权限控制,接下来将要实现前端 Vue 的动态路由控制。

1. 前端权限控制思路(Vue)

前端的权限控制,不同的权限对应着不同的路由,同时菜单也需根据不同的权限,异步生成。
先回顾下整体流程:

  • 登录: 提交账号和密码到服务端签发token,拿到token之后存入浏览器,再携带token(一般放在请求头中)再去获取用户的详细信息(包括用户权限等信息)。
  • 权限验证:通过用户权限信息 构建 对应权限的路由,通过router.addRoutes动态挂载这些路由。

接下来将基于 Vue 开源后台模板vue-admin-template来演示具体流程,这里只演示重要代码,完整项目移步文章末尾获取源码。

2. 登录

  1. 先准备基础的静态路由(src/router/index.js)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export const constantRoutes = [
// 登陆页面
{
path: '/login',
component: () => import('@/views/login/index'),
hidden: true
},
// 首页
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index'),
meta: { title: '首页', icon: 'dashboard' }
}]
},
{
path: '/404',
component: () => import('@/views/404'),
hidden: true
}
]
  1. 登录页面(src/views/login/index.vue)click事件触发登录操作
1
2
3
4
5
this.$store.dispatch('user/login', this.loginForm).then(() => {
this.$router.push({ path: '/' }); //登录成功之后重定向到首页
}).catch(err => {
this.$message.error(err); //登录失败提示错误
});
  1. 登录逻辑(src/store/modules/user.js)action:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const actions = {
// user login
login({ commit }, userInfo) {
const { account, password } = userInfo
return new Promise((resolve, reject) => {
// 这里的login调用api接口请求数据
login({ account: account.trim(), password: password }).then(response => {
const { data } = response
commit('SET_TOKEN', data)
setToken(data)
resolve()
}).catch(error => {
reject(error)
})
})
},
// get user info
getInfo({ commit, state }) {
return new Promise((resolve, reject) => {
// 这里的getInfo调用api接口请求数据
getInfo(state.token).then(response => {
const { data } = response
if (!data) {
reject('Verification failed, please Login again.')
}
const { nickname, avatar, roles, permissions } = data
// 全局储存用户信息
commit('SET_NAME', nickname)
commit('SET_AVATAR', avatar)
// 角色信息
commit('SET_ROLES', roles)
// 指令权限信息
commit('SET_PERMISSIONS', permissions)
resolve(data)
}).catch(error => {
reject(error)
})
})
},
}
  1. /login/getInfo接口与返回的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
POST (localhost:8282/login)
请求参数: {"username":"admin","password":"123456"}
响应: {
"code":0,
"msg":"登录成功",
"data":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhZG1pbiIsInVpZCI6MSwiZXhwIjoxNTgwOTk4MTIzfQ.6jgqt_opjnosASlJ2oSIYZn1Sb2BQO-eUo_6OVTHv50"
}
// ------------getInfo--------------
GET (localhost:8282/user/info)
Headers: {"X-Token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhZG1pbiIsInVpZCI6MSwiZXhwIjoxNTgwOTk4MTIzfQ.6jgqt_opjnosASlJ2oSIYZn1Sb2BQO-eUo_6OVTHv50"}
响应: {
"code": 0,
"msg": "获取成功",
"data": {
"account": "admin",
"nickname": "超级管理员",
"roles": ["admin"],
"permissions": ["user:list","user:add","user:delete","user:update"]
}
}
  1. 获取用户信息(src/permission.js)
    • 用户登录成功之后,我们会在全局钩子router.beforeEach中拦截路由,判断是否已获得token,在获得token之后我们就要去获取用户的基本信息 并且根据用户角色动态挂载路由。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const whiteList = ['/login'] // 白名单
router.beforeEach(async(to, from, next) => {
// 判断是否已获得token
const hasToken = getToken()
if (hasToken) {
if (to.path === '/login') {
next({ path: '/' })
} else {
const hasRole = store.getters.role
if (hasRole) {
next()
} else {
try {
// 获取用户角色 ['admin'] 或,['developer','editor']
const { roles } = await store.dispatch('user/getInfo')
// 动态根据 角色 算出其对应有权限的路由
const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
// 动态挂载路由
router.addRoutes(accessRoutes)
// addRouter是让挂载的路由生效,但是挂载后'router.options.routes'并未刷新(应该是个bug)
// 所以还需要手动将路由加入'router.options.routes'
router.options.routes = constantRoutes.concat(accessRoutes)
next()
} catch (error) {
await store.dispatch('user/resetToken')
Message.error(error || 'Has Error')
next(`/login?redirect=${to.path}`)
}
}
}
} else {
/* has no token*/
if (whiteList.indexOf(to.path) !== -1) {
next()
} else {
next(`/login?redirect=${to.path}`)
}
}
})

3. 动态挂载路由

主要思路,前端会有一份包含所有路由的路由表。创建Vue实例时会先挂载登录等公共路由;当用户登录之后,通过getInfo(token)获取用户的角色(roles),动态根据用户的roles算出其对应有权限的路由,再通过router.addRoutes动态挂载路由;使用vuex管理路由表,根据vuex中可访问的路由渲染菜单。但这些控制都只是页面级的,后端接口也需要做权限验证。

  1. 改造一下路由表,添加异步路由列表,将角色添加到元数据meta中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// 动态路由
export const asyncRoutes = [
{
path: '/user',
component: Layout,
redirect: '/user/list',
name: 'User',
meta: { title: '用户管理', icon: 'example' },
children: [
{
path: 'list',
name: 'UserList',
component: () => import('@/views/user/list'),
meta: { title: '用户列表', icon: 'nested' }
},
{
path: 'edit',
name: 'UserEdit',
component: () => import('@/views/user/form'),
meta: { title: '添加用户', icon: 'form' }
}
]
},
{
path: '/admin',
component: Layout,
children: [
{
path: 'index',
name: 'Form1',
component: () => import('@/views/test/index'),
meta: { title: '管理员角色测试', icon: 'form', roles: ['admin'] }
}
]
},
{
path: '/editor',
component: Layout,
children: [
{
path: 'index',
name: 'Form2',
component: () => import('@/views/test/index'),
meta: { title: '编辑角色测试', icon: 'form', roles: ['editor'] }
}
]
},
{
path: '/form',
component: Layout,
children: [
{
path: 'index',
name: 'Form3',
component: () => import('@/views/test/index'),
meta: { title: '用户角色测试', icon: 'form', roles: ['user'] }
}
]
},
{
path: '/nested',
component: Layout,
redirect: '/nested/menu3',
name: 'Nested',
meta: { title: '子菜单权限测试', icon: 'form' },
children: [
{
path: 'menu1',
component: () => import('@/views/test/index'),
name: 'Menu1',
meta: { title: '管理员可见', roles: ['admin'] }
},
{
path: 'menu2',
component: () => import('@/views/test/index'),
name: 'Menu1',
meta: { title: '编辑者可见', roles: ['editor'] }
},
{
path: 'menu3',
component: () => import('@/views/test/index'),
name: 'Menu1',
meta: { title: '普通用户可见', roles: ['user'] }
}
]
},
  1. 根据前面获取用户信息的代码可发现,通过store.dispatch('permission/generateRoutes',roles)来获得有权限的路由,新建src/store/modules/permission.js如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { asyncRoutes, constantRoutes } from '@/router'
/** 判断用户是否拥有此路由的权限 */
function hasPermission(roles, route) {
if (route.meta && route.meta.roles) {
return roles.some(role => route.meta.roles.includes(role))
} else {
return true
}
}
/** 递归组装路由表,返回符合用户角色权限的路由列表 */
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
}

4. axios 拦截器

通过request拦截器在每个请求头里面塞入token,好让后端对请求进行权限验证;代码位置:src/utils/request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import axios from 'axios'
import store from '@/store'
import { getToken } from '@/utils/auth'
// create an axios instance
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
withCredentials: false, // send cookies when cross-domain requests
timeout: 5000 // request timeout
})
service.interceptors.request.use(
config => {
if (store.getters.token) {
// 登陆后将token放入headers['X-Token']中
config.headers['X-Token'] = getToken()
}
return config
},
error => {
return Promise.reject(error)
}
)
export default service

5. 指令权限

可以使用全局权限判断函数,实现按钮级别的权限判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<el-button v-if="checkPermission('user:add')">添加</el-button>
<el-button v-if="checkPermission('user:delete')">删除</el-button>
<el-button v-if="checkPermission('user:update')">修改</el-button>
<el-button v-if="checkPermission('user:list')">查看</el-button>
</template>
<script>
import checkPermission from '@/utils/permission' // 权限判断函数
export default {
methods: {
checkPermission(value) {
return checkPermission(value)
},
},
}
</script>

src/utils/permission.js:

1
2
3
4
5
import store from '@/store'
export default function checkPermission(value) {
const permissions = store.getters.permissions
return permissions.indexOf(value) > -1
}

6. 效果演示:

注:搭建到这里的代码在github源码tagV3.0中。
源码地址: https://github.com/chaooo/springboot-vue-shiro.git