Skip to content
目录

Vue3后台管理系统(四):认证与权限系统实现

一、认证系统设计

1.1 认证流程概述

基于JWT(JSON Web Token)实现,完整的认证流程如下:

  1. 用户输入账号密码,前端加密后发送到服务器
  2. 服务器验证成功后返回access_token和refresh_token
  3. 前端存储token,并在后续请求中携带
  4. 访问需要权限的资源时,服务端验证token有效性和权限
  5. token过期时,使用refresh_token获取新的access_token

1.2 Token管理

通过封装TokenService来管理token的存储和验证:

javascript
// src/utils/token.js
/**
 * @description Token服务类
 * @Author Wangkaibing
 * @Date 2025-05-06
 *
 * Usage:
 * import { TokenService } from '@/utils/token'
 *
 * // 获取token
 * TokenService.getToken()
 *
 * // 设置token
 * TokenService.setTokens(accessToken, refreshToken, expiresIn)
 *
 * // 清除token
 * TokenService.clearTokens()
 */

import { setCookie, getCookie, removeCookie } from '@/utils/cookie'

export class TokenService {
	static TOKEN_KEY = 'xxxx'
	static REFRESH_TOKEN_KEY = 'xxxx'

	static getToken() {
		return getCookie(this.TOKEN_KEY)
	}

	static getRefreshToken() {
		return getCookie(this.REFRESH_TOKEN_KEY)
	}

	static setTokens(accessToken, refreshToken = '', expiresIn = 7200) {
		if (accessToken) {
			setCookie(this.TOKEN_KEY, accessToken, expiresIn)
		}
		if (refreshToken) {
			setCookie(this.REFRESH_TOKEN_KEY, refreshToken, expiresIn)
		}
	}

	static clearTokens() {
		removeCookie(this.TOKEN_KEY)
		removeCookie(this.REFRESH_TOKEN_KEY)
	}

	static isAuthenticated() {
		return !(!this.getToken() && !this.getRefreshToken())
	}
}

二、登录功能实现

2.1 登录组合式API

使用组合式API(Composition API)封装登录相关功能:

javascript
/**
 * @description 用户登录管理hook
 * @Author Wangkaibing
 * @Date 2025-04-13
 *
 * Usage:
 * import { useLogin } from '@/composables/useLogin'
 *
 * const { login, logout } = useLogin()
 * await login(loginForm)
 */

import { ref, reactive, computed, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { useGet, usePost } from '@/api/request'
import { useAuthStore } from '@/stores/auth'
import { useUser } from '@/composables/useUser'
import { usePermissionStore } from '@/composables/usePermission'
import { useEncryption } from '@/composables/useEncryption.js'
import { useRouter } from 'vue-router'
import { TokenService } from '@/utils/token.js'
import { invalidateCache } from 'alova'

/**
 * 登录管理hook
 * 登录、退出、获取验证码等功能
 * @returns {Object} 登录控制对象
 */
export function useLogin() {
	const router = useRouter()
	const authStore = useAuthStore()
	const { getUserInfo } = useUser()
	const permissionStore = usePermissionStore()
	const { encryptLoginData } = useEncryption()

	const loading = ref(false)
	const loginState = reactive({
		client: '',
		codeImage: '',
	})

	/**
	 * 获取验证码
	 * @param {boolean} isInit - 是否是初始化调用
	 */
	const getCaptcha = async (isInit = false) => {
		try {
			const params = {
				width: 100,
				height: 42,
			}
			if (!isInit && loginState.client) {
				params.client = loginState.client
			}
			const res = await useGet('/api/xxxx', params, {
				loading: false,
			})
			if (res.image) {
				loginState.client = res.client
				loginState.codeImage = res.image
			}
		} catch (error) {
			console.log('🚀 ~ [error]', error)
		}
	}

	/**
	 * 用户登录
	 * @param {Object} loginForm - 登录表单数据
	 */
	const login = async (loginForm) => {
		if (loading.value) return
		try {
			loading.value = true
			const loginData = encryptLoginData({
				username: loginForm.username,
				password: loginForm.password,
				verificationCode: loginForm.captcha,
				client: loginState.client,
			})
			const loginRes = await usePost('/api/xxxxx', loginData)
			if (!loginRes?.data) {
				throw new Error(loginRes?.message || '登录失败')
			}
			// 清除接口缓存
			invalidateCache()
			// 保存token
			const { token_type, access_token, refresh_token, expires_in } = loginRes
			TokenService.setTokens(token_type + ' ' + access_token, refresh_token, expires_in * 1000)
			// 获取用户信息
			await getUserInfo()
			// 生成路由
			const accessRoutes = await permissionStore.generateRoutes(authStore.state.roles)
			// 添加动态路由
			accessRoutes.forEach((route) => {
				router.addRoute(route)
			})
			ElMessage({
				message: '登录成功:欢迎回来',
				type: 'success',
				plain: true,
			})
			await nextTick()
			await router.replace('/')
		} catch (error) {
			console.log('🚀 ~ [error]', error)
			// 发生错误时重置状态
			authStore.reset()
			permissionStore.resetRoutes()
			getCaptcha(false)
			throw error
		} finally {
			loading.value = false
		}
	}

	/**
	 * 退出登录
	 */
	const logout = async () => {
		try {
			// 清除接口缓存
			invalidateCache()
			await router.push('/login')
			authStore.reset()
			permissionStore.resetRoutes()
		} catch (error) {
			console.log('🚀 ~ [error]', error)
			authStore.reset()
			permissionStore.resetRoutes()
			// 使用location作为备用方案
			window.location.href = '/login'
		}
	}

	return {
		loading,
		codeImage: computed(() => loginState.codeImage),
		getCaptcha,
		login,
		logout,
	}
}

三、权限系统实现

3.1 基于角色的权限控制

使用组合式API封装了权限控制逻辑:

javascript
/**
 * @description 路由权限管理hook
 * @Author Wangkaibing
 * @Date 2025-04-13
 *
 * Usage:
 * import { usePermissionStore } from '@/composables/usePermission'
 *
 * const permissionStore = usePermissionStore()
 * await permissionStore.generateRoutes(roles)
 */

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { constantRoutes, defaultRoutes, platform, catchAllRoute } from '@/router/routes'

export const usePermissionStore = defineStore('permission', () => {
	const routes = ref([...constantRoutes])
	const addRoutes = ref([])
	// 添加路由元数据缓存
	const routeMetaCache = ref(new Map())
	// 添加角色路由映射缓存
	const roleRoutesCache = ref(new Map())

	// 路由是否已经生成
	const isRoutesGenerated = computed(() => addRoutes.value.length > 0)

	/**
	 * 检查路由权限
	 * @param {Array} roles - 用户角色列表
	 * @param {Object} route - 路由对象
	 * @returns {boolean} - 是否有权限
	 */
	const hasPermission = (roles, route) => {
		// 生成缓存键
		const routePath = route.path
		const roleKey = roles.sort().join(',')
		const cacheKey = `${routePath}:${roleKey}`

		// 检查缓存中是否存在
		if (routeMetaCache.value.has(cacheKey)) {
			return routeMetaCache.value.get(cacheKey)
		}

		// 计算权限并缓存结果
		let hasAccess = true
		if (route.meta?.roles) {
			hasAccess = roles.some((role) => route.meta.roles.includes(role))
		}

		// 保存到缓存
		routeMetaCache.value.set(cacheKey, hasAccess)
		return hasAccess
	}

	/**
	 * 过滤异步路由
	 * @param {Array} routes - 路由配置数组
	 * @param {Array} roles - 用户角色列表
	 * @returns {Array} - 过滤后的路由
	 */
	const filterAsyncRoutes = (routes, roles) => {
		return routes
			.filter((route) => hasPermission(roles, route))
			.map((route) => {
				// 创建路由的浅拷贝
				const tmp = { ...route }
				// 递归子路由
				if (tmp.children) {
					tmp.children = filterAsyncRoutes(tmp.children, roles)
				}
				return tmp
			})
	}

	/**
	 * 生成可访问路由
	 * @param {Array} roles - 用户角色列表
	 * @returns {Promise<Array>} - 可访问路由列表
	 */
	const generateRoutes = (roles) => {
		// 生成角色缓存键
		const roleKey = roles.sort().join(',')

		// 如果角色路由缓存中已存在,直接返回缓存结果
		if (roleRoutesCache.value.has(roleKey)) {
			const cachedRoutes = roleRoutesCache.value.get(roleKey)
			addRoutes.value = cachedRoutes
			routes.value = [...constantRoutes, ...cachedRoutes]
			return Promise.resolve(cachedRoutes)
		}

		// 角色路由映射表,配置每种角色对应的路由生成方法
		const roleRouteMap = {
			'ROLE_ADMIN': () => [...defaultRoutes, ...platform, catchAllRoute],
			'ROLE_MANAGER': () => [...defaultRoutes, ...filterAsyncRoutes(platform, roles), catchAllRoute],
			'ROLE_SUPERVISOR': () => [...defaultRoutes, ...filterAsyncRoutes(platform, roles), catchAllRoute],
		}
		// 默认路由
		const defaultRouteFn = () => [...defaultRoutes, catchAllRoute]
		// 查找用户拥有的最高权限角色
		let routeGenerator = defaultRouteFn
		// 遍历角色,找到对应路由
		for (const role of roles) {
			if (roleRouteMap[role]) {
				routeGenerator = roleRouteMap[role]
				break // 找到第一个匹配的角色就停止
			}
		}
		// 生成路由
		const accessedRoutes = routeGenerator()

		// 更新状态
		addRoutes.value = accessedRoutes
		routes.value = [...constantRoutes, ...accessedRoutes]

		// 保存到角色路由缓存
		roleRoutesCache.value.set(roleKey, accessedRoutes)

		return Promise.resolve(accessedRoutes)
	}

	/**
	 * 重置路由配置
	 */
	const resetRoutes = () => {
		// 清空动态添加的路由
		addRoutes.value = []
		// 重置为初始路由
		routes.value = [...constantRoutes]
		// 清空路由元数据缓存
		routeMetaCache.value.clear()
		// 清空角色路由映射缓存
		roleRoutesCache.value.clear()
	}

	return {
		routes,
		addRoutes,
		isRoutesGenerated,
		generateRoutes,
		resetRoutes,
	}
})

3.2 访问控制指令

为了在页面中控制元素的显示,实现自定义指令:

javascript
/**
 * @description 权限访问控制hook
 * @Author Wangkaibing
 * @Date 2025-04-13
 *
 * Usage:
 * import { useAccess } from '@/composables/useAccess'
 *
 * const { hasRole, hasRoles } = useAccess()
 * hasRole('ROLE_ADMIN')
 */

import { computed } from 'vue'
import { defineStore } from 'pinia'
import { useAuthStore } from '@/stores/auth'

export const useAccessStore = defineStore('access', () => {
	const authStore = useAuthStore()

	// 计算属性:判断是否为管理员
	const isAdmin = computed(() => {
		return authStore.state.roles.includes('ROLE_ADMIN')
	})

	return {
		isAdmin,
	}
})

/**
 * 权限访问控制hook
 * 提供基于角色的权限验证功能
 */
export function useAccess() {
	const accessStore = useAccessStore()
	const authStore = useAuthStore()

	/**
	 * 判断是否拥有指定角色
	 * @param {string} role - 角色标识
	 * @returns {boolean} - 是否拥有该角色
	 */
	const hasRole = (role) => {
		if (!role || !authStore.state.roles.length) {
			return false
		}
		return authStore.state.roles.includes(role)
	}

	/**
	 * 判断是否拥有指定角色集合中的任意一个
	 * @param {string[]} roles - 角色标识数组
	 * @param {boolean} every - 是否需要满足所有角色,默认false
	 * @returns {boolean} - 是否满足角色要求
	 */
	const hasRoles = (roles, every = false) => {
		if (!roles || !roles.length || !authStore.state.roles.length) {
			return false
		}
		const verify = (role) => hasRole(role)
		return every ? roles.every(verify) : roles.some(verify)
	}

	// 角色指令
	const vRole = {
		mounted(el, binding) {
			const { value } = binding
			const hasPermission = Array.isArray(value) ? hasRoles(value) : hasRole(value)

			if (!hasPermission) {
				el.parentNode?.removeChild(el)
			}
		},
	}

	return {
		...accessStore,
		hasRole,
		hasRoles,
		vRole,
	}
}

自定义指令的注册:

javascript
/**
 * @description 角色权限指令
 * @Author Wangkaibing
 * @Date 2025-04-13
 * @Usage
 * // 单个角色
 * <button v-role="'ROLE_ADMIN'">管理员操作</button>
 *
 * // 多个角色(满足其一)
 * <button v-role="['ROLE_ADMIN', 'ROLE_MANAGER']">管理操作</button>
 */

import { useAccess } from '@/composables/useAccess'

/**
 * 注册全局角色权限指令
 * @param {Object} app - Vue应用实例
 */
export function setupPermissionDirective(app) {
	const { vRole } = useAccess()

	// 注册 v-role 指令
	app.directive('role', vRole)
}

3.3 路由守卫中的权限控制

在路由守卫中,实现基于token和角色的访问控制:

javascript
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { constantRoutes } from './routes'
import { ElNotification } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { useUser } from '@/composables/useUser'
import { usePermissionStore } from '@/composables/usePermission'
import NProgress from 'nprogress'
import '@/styles/nprogress.scss'
import { TokenService } from '@/utils/token.js'

// 配置 NProgress
NProgress.configure({
	easing: 'ease-out',
	speed: 400,
	showSpinner: false,
	trickleSpeed: 200,
	minimum: 0.3,
})

// 修复重复点击路由报错问题
const originalPush = createRouter.prototype.push
createRouter.prototype.push = function push(location) {
	return originalPush.call(this, location).catch((err) => err)
}

const router = createRouter({
	history: createWebHistory(),
	scrollBehavior: () => ({ top: 0 }),
	routes: [...constantRoutes],
})

// 异步组件加载失败
router.onError((error) => {
	const pattern = /Loading chunk (\d)+ failed/g
	const isChunkLoadFailed = error.message.match(pattern)
	if (isChunkLoadFailed) {
		window.location.reload()
	}
})

// 路由白名单
const whiteList = ['/login']

// 路由守卫
router.beforeEach(async (to, from, next) => {
	// 开启进度条
	NProgress.start()

	const authStore = useAuthStore()
	const { getUserInfo } = useUser()
	const permissionStore = usePermissionStore()

	// 白名单直接通过
	if (whiteList.includes(to.path)) {
		return next()
	}

	// URL中的token
	if (to.query.token) {
		const token = to.query.token
		TokenService.setTokens('Bearer ' + token)
		// 移除token参数,避免暴露在URL中
		const { token: _, ...query } = to.query
		return next({ path: to.path, query })
	}

	// 检查是否已登录
	if (!authStore.isAuthenticated) {
		ElNotification({
			title: '登录失败',
			message: '登录过期',
			type: 'error',
		})
		return next(`/login?redirect=${to.path}`)
	}

	try {
		// 只在首次加载或刷新页面时执行路由生成
		if (!permissionStore.isRoutesGenerated) {
			// 如果没有角色信息,先获取用户信息
			if (!authStore.hasRoles) {
				await getUserInfo()
			}
			// 生成动态路由
			const accessRoutes = await permissionStore.generateRoutes(authStore.state.roles)
			// 添加动态路由
			accessRoutes.forEach((route) => {
				router.addRoute(route)
			})
			// 重定向到原目标,确保路由已更新
			return next({ ...to, replace: true })
		}

		// 默认首页重定向到账户页
		if (to.path === '/') {
			return next({ path: '/my/account', replace: true })
		}

		next()
	} catch (error) {
		// 异常
		console.log('🚀 ~ [error]', error)
		authStore.reset()
		permissionStore.resetRoutes()
		ElNotification({
			title: '错误',
			message: error.message || '登录失败,请重新登录',
			type: 'error',
		})
		next('/login')
	}
})

// 路由后置守卫
router.afterEach(() => {
	// 结束进度条
	NProgress.done()
})

四、前端权限应用场景

4.1 基于角色的按钮权限

在实际业务中可以这样使用自定义指令:

vue
<template>
  <!-- 根据角色控制按钮显示 -->
  <el-button v-role="'ROLE_ADMIN'" type="primary">管理员专用按钮</el-button>
  
  <!-- 支持多个角色(满足一个即可显示) -->
  <el-button v-role="['ROLE_ADMIN', 'ROLE_MANAGER']">管理操作按钮</el-button>
</template>

Released under the MIT License.