
简介一套基于Spring Boot、Spring Security、JWT、Redis与Vue的前后端分离权限管理系统以MySQL存储业务数据通过MyBatis完成数据持久化适合Java全栈学习者用于课程设计、毕业设计或快速搭建权限基础模块。资源共127个文件压缩后约132KB主体为98个Java源码文件覆盖控制器、服务层、安全配置、Redis工具与日志切面另含18个XML映射文件、properties与yml环境配置、SQL初始化脚本、README说明及PDF指引文档结构清晰解压后即可按目录阅读。目前已有125人学习/下载属于轻量、可直接操作的课设参考。整体采用前后端分离架构认证流程以JWT搭配Redis实现无状态令牌管理权限模型按RBAC设计可在安全配置与菜单权限模块中快速定位关键逻辑。项目中的安全配置、Redis工具、菜单权限等核心模块可帮助理解Spring Security过滤链、JWT无状态认证、Redis令牌缓存以及SQL语句拦截机制随附的SQL文件和部署文档还能节省环境搭建与数据初始化时间适合需要完整权限系统源码并希望快速运行二次开发的学习者。1. 为什么这个权限系统组合值得拆一遍在前后端分离的课设或生产项目中权限控制往往比业务逻辑更先遇到瓶颈后端接口能被直接调用前端菜单只是“看不见”不等于“进不去”。这个基于 Spring Boot Spring Security JWT Redis Vue 的权限系统核心并不是造轮子而是把认证、授权、会话状态三件事拆开处理JWT 负责无状态身份凭证Redis 接管可变状态和缓存Vue Router 做前端路由级守卫MySQL 存用户与菜单关系。适合正在做课设、想搞懂权限链路或者准备把老旧 session 登录改造成 token 方案的读者。项目的源码结构里能看到 SecurityConfig、RedisUtil、OSysUserServiceImpl 这类典型文件正好可以按一条主线完整复盘用户登录后拿到 JWT请求携带 JWT 进入 Spring Security 过滤器链Redis 缓存权限与在线状态Vue 前端根据用户角色渲染菜单和按钮。下面按这条链路逐个拆。2. 认证授权链路与 Token 的实际落地2.1 从会话到令牌JWT 解决了什么问题传统单体应用用 HttpSession 保存登录态客户端拿到 JSESSIONID每次请求后端去 Session 里查用户。这个方案在前后端分离和集群部署下会遇到两个问题一是前后端不同域名时 Cookie 跨域处理麻烦二是后端多实例部署时 Session 需要共享要么引入 Spring Session Redis要么做粘滞会话成本和复杂度都不低。JWT 的做法正好相反用户登录成功后服务器生成一段自包含的 JSON 令牌返回给前端。令牌里含用户标识、过期时间、角色等信息后端用密钥验签通过后就信任它不需要在服务端保存会话。需要注意JWT 分三种不签名的 JWS、加密的 JWE 和裸 Token。大多数项目用的其实是 JWS也就是Header.Payload.Signature三段式结构Payload 里的内容 Base64 后可读所以密码、手机号这类敏感信息绝不能放进去。我一般只在 Payload 里放 userId、username、roles过期时间放在exp里。2.2 Security 过滤器链与 SecurityConfig 配置Spring Security 的核心不是写在 Controller 里的判断而是一条过滤器链。请求进入后依次经过SecurityContextPersistenceFilter、UsernamePasswordAuthenticationFilter、FilterSecurityInterceptor等过滤器最后才到达 Controller。项目里的SecurityConfig.java将JwtAuthenticationFilter插入到UsernamePasswordAuthenticationFilter之前让每个请求先解析 Token再走后续的授权判断。下面的配置是课设和中小型项目最常见的写法改自项目中的 SecurityConfigConfiguration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Resource private JwtAuthenticationFilter jwtAuthenticationFilter; Resource private RestAuthenticationEntryPoint authenticationEntryPoint; Resource private RestAccessDeniedHandler accessDeniedHandler; private static final String[] WHITE_LIST { /auth/login, /auth/captcha, /doc.html, /webjars/**, /swagger-resources/**, /v3/api-docs/** }; Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .cors().and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(WHITE_LIST).permitAll() .anyRequest().authenticated() .and() .exceptionHandling() .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler); http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }这段配置里几个参数很关键csrf().disable()是因为 JWT 放在 Header 里CSRF 攻击的前提是 Cookie 自动携带所以可以关闭SessionCreationPolicy.STATELESS强制 Spring Security 不再创建 HttpSession否则它会默认生成会话等于白改 token 方案authorizeRequests()里的白名单路径必须包含登录接口和验证码接口不然用户还没登录就被拦在门外。exceptionHandling()配置了两个处理器authenticationEntryPoint处理未登录访问受保护资源的情况accessDeniedHandler处理登录了但角色权限不足的情况两者返回的 JSON 结构要统一定义方便前端 Axios 拦截器识别。需要提醒的是WebSecurityConfigurerAdapter在 Spring Security 5.7 之后被标记弃用如果项目基于 Spring Boot 3.x应改为定义SecurityFilterChainBean 的方式Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeRequests(auth - auth.antMatchers(WHITE_LIST).permitAll().anyRequest().authenticated()) .exceptionHandling(e - e.authenticationEntryPoint(authenticationEntryPoint).accessDeniedHandler(accessDeniedHandler)); http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }两种写法的过滤器链逻辑一致源码里如果是旧写法项目在升级 Spring Boot 时需要注意这块编译报错。2.3 登录接口与 Token 生成完整流程OSysUserServiceImpl在项目里的职责是加载用户并校验密码。核心逻辑如下登录接口收到用户名和密码后先查数据库拿到用户信息再用BCryptPasswordEncoder比对密码比对通过后生成 JWT 返回。Service public class OSysUserServiceImpl implements UserDetailsService { Resource private SysUserMapper sysUserMapper; Resource private RedisUtil redisUtil; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user sysUserMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } ListString roles sysUserMapper.selectRolesByUserId(user.getId()); return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .roles(roles.toArray(new String[0])) .build(); } public String login(String username, String password, String captchaKey, String captchaCode) { String cachedCaptcha redisUtil.get(captcha: captchaKey, String.class); if (!captchaCode.equalsIgnoreCase(cachedCaptcha)) { throw new BusinessException(验证码错误); } SysUser user sysUserMapper.selectByUsername(username); if (user null || !BCryptPasswordEncoder.matches(password, user.getPassword())) { throw new BusinessException(用户名或密码错误); } String token createJwt(user); redisUtil.set(login:token: token, user.getId(), 120 * 60); return token; } }这段代码体现了 JwtAuthenticationFilter 要做的核心事从请求头Authorization里取出Bearer xxx解析 Token 拿到用户名再调用loadUserByUsername加载用户和角色放进SecurityContextHolder。之后UserDetails在本次请求内可以被PreAuthorize(hasRole(ADMIN))这类注解直接使用。有一点常被忽略每次请求都查一次数据库性能上不如把用户权限缓存到 Redis这也是下一章放缓存的原因。关于 JWT 与 Token 的关系很多文章混着写。JWT 是一种具体的 Token 编码格式Token 可以是随机字符串存 Redis也可以是 JWT。两者没有优劣只有取舍JWT 自带过期时间和签名适合无状态场景随机 Token 可以主动踢下线、续签、撤销适合需要严格会话控制的系统。该项目把 JWT 和 Redis 都用上了属于“无状态认证 有状态控制”的混合门路这也是企业里比较常见的折中。3. Redis 在权限系统中的双重身份3.1 为什么有了 JWT 还要引入 Redis从前面的登录流程可以看到项目并没有把 JWT 当作完全无状态的东西而是同时把login:token:{token}写进了 Redis。原因有两个一是 JWT 签发后无法主动失效用户改密码或者被踢下线时旧 Token 在到期前依然有效用 Redis 存一个白名单过滤器里查一下就能实现主动失效二是用户权限、菜单列表、验证码这些可变数据频繁查 MySQL 开销太大Redis 作为缓存层能把读压力挡在数据库前面。另一个常见用途是分布式锁。比如同一用户短时间内重复提交权限修改或者课设里做抢单、秒杀类的并发操作RedisUtil里往往封装了setIfAbsent实现的分布式锁接口。网上很多关于 Redis 分布式锁的资料都在源码里对应的RedisUtil.java中出现不过要小心不要把锁和缓存混在一个方法里锁需要单独设置过期时间防止死锁。3.2 RedisUtil 和 RedisCache 的设计细节项目中出现的RedisUtil.java和RedisCache.java其实是一个手工封装、一个基于 Spring Cache 注解的方式。先看手动封装的简化版Component public class RedisUtil { Resource private StringRedisTemplate stringRedisTemplate; public void set(String key, Object value, long timeout) { stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(value), timeout, TimeUnit.SECONDS); } public T T get(String key, ClassT clazz) { String json stringRedisTemplate.opsForValue().get(key); if (json null) { return null; } return JSON.parseObject(json, clazz); } public boolean hasKey(String key) { return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key)); } public void delete(String key) { stringRedisTemplate.delete(key); } public boolean tryLock(String lockKey, String requestId, long expireSeconds) { return Boolean.TRUE.equals( stringRedisTemplate.opsForValue().setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS) ); } }这里的StringRedisTemplate和RedisTemplate的差异值得说。Spring Boot 默认注入的RedisTemplate使用 JDK 序列化存进 Redis 的值像\xAC\xED\x00\x05既占空间又没法用 Redis Desktop Manager 直观查看。用StringRedisTemplate配合 Fastjson 或 Jackson 手动序列化存进去的就是普通 JSON 字符串排错时一眼能看出 key 是否过期。RedisCache.java一般是注解方式的封装比如CacheConfig(cacheNames sys:menu) Service public class OSysMenuServiceImpl implements OSysMenuService { Resource private SysMenuMapper sysMenuMapper; Cacheable(key #userId) Override public ListSysMenu selectMenusByUserId(Long userId) { return sysMenuMapper.selectMenusByUserId(userId); } CacheEvict(key #userId) Override public void updateUserMenu(Long userId) { sysMenuMapper.updateUserMenu(userId); } }Cacheable会在方法执行前查缓存存在就直接返回不存在才执行方法并写入缓存。CacheEvict在菜单修改后清掉对应用户的缓存防止下一次登录拿到旧菜单。这里要注意 key 不能只写字符串#userId是 SpEL 表达式如果项目里有多租户key 还得拼上租户 ID否则数据会串。3.3 Redis 在 Token 续签和登出控制上的具体做法JWT 自身过期时间如果设得太短用户用着用着就被迫重新登录设得太长泄露后的风险窗口又太大。常见的解决方案是滑动过期Redis 里保存剩余有效期每次请求刷新。public String refreshIfNecessary(String token) { String key login:token: token; Long ttl redisUtil.getExpire(key); if (ttl null) { return null; } if (ttl 30 * 60) { redisUtil.expire(key, 120 * 60); } return token; }这段逻辑在 JwtAuthenticationFilter 中调用相当于用户只要活跃Token 就会继续续期连续两小时不活跃Redis 里的 key 过期Token 即使还没到 JWT 自身过期时间也会被判无效。登出则更直接删除 Redis 里的 key那这个 JWT 立即失效。这种“JWT 只做凭证Redis 做状态”的模式比单纯使用 JWT 多一层控制力也是 jwt 实现 token 续签最常见的方式。Redis key 的设计要规范下面是项目里常见的命名方式Key 模式用途过期时间captcha:{uuid}登录验证码5 分钟login:token:{jwt}登录态白名单120 分钟user:info:{userId}用户基本信息30 分钟user:menu:{userId}用户菜单和权限60 分钟lock:user:{userId}用户维度的分布式锁按业务场景可以看到Redis 里存的值建议统一用 JSONkey 带冒号分层方便后续用redis-cli --scan --pattern user:menu:*批量查询。还有一个容易忽略的点如果 Redis 没配置maxmemory-policy allkeys-lru缓存写多了会撑爆内存Redis 下载安装后默认是不淘汰策略这一点部署时一定要改。4. Vue 侧权限控制路由守卫与按钮级指令4.1 Axios 拦截器与 Token 携带前端拿到 JWT 后存在哪里直接决定了刷新页面之后登录态丢不丢。兼容老浏览器的项目会把 Token 存localStorage但这样遇到 XSS 攻击时容易被窃取。安全要求高的场景可以用 HttpOnly Cookie代价是后端要做 CSRF 防护。课设和大多数管理后台里我习惯存到localStorage同时开启 Vue 项目的严格 CSP降低 XSS 注入风险。Axios 请求拦截器统一把 Token 塞进请求头axios.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers[Authorization] Bearer token } return config }) axios.interceptors.response.use( response response.data, error { if (error.response.status 401) { MessageBox.confirm(登录状态已过期是否重新登录, 提示) .then(() { router.push(/login) }) localStorage.removeItem(access_token) } return Promise.reject(error) } )这段代码有两个细节。401 意味着 Token 不存在、过期或 Redis 白名单里没有前端收到 401 后不能直接跳登录页要给出提示防止用户正在填写表单时被强制弹走。后端统一返回 401 的前提是前面提到的RestAuthenticationEntryPoint把响应码设为 401如果后端返回 200 带错误码前端拦截器要把逻辑改成判断code 40100这类业务码。4.2 动态路由与路由守卫的实现权限系统的前端路由不应把所有页面写死在静态路由里而是根据后端返回的菜单列表动态添加。先定义基础路由再在路由守卫里判断用户权限const constantRoutes [ { path: /login, component: Login }, { path: /404, component: NotFound }, { path: /, redirect: /dashboard, component: Layout } ] let dynamicRoutes [] router.beforeEach(async (to, from, next) { if (getToken()) { if (to.path /login) { next({ path: /dashboard }) } else { const hasRoutes store.state.permission.routes.length 0 if (hasRoutes) { next() } else { try { const menuList await store.dispatch(permission/getMenus) const routes buildRoutes(menuList) routes.forEach(route router.addRoute(route)) dynamicRoutes routes next({ ...to, replace: true }) } catch (error) { store.dispatch(user/resetToken) next(/login?redirect${to.path}) } } } } else { if (whiteList.includes(to.path)) { next() } else { next(/login?redirect${to.path}) } } })路由守卫的核心思想是刷新页面后请求/dashboard发现 Vuex 里还没有动态路由就去后端拉取菜单用router.addRoute挂载后重新next。这样每个用户看到的菜单都是自己角色对应的。buildRoutes里需要把后端的菜单实体转换成 Vue Router 的路由结构常见的坑是后端component字段返回字符串前端要配置好组件映射表否则显示不了页面。4.3 v-permission 指令实现按钮级权限菜单级权限解决了入口问题但页面里“新增”“删除”这类按钮同样要控制。手写v-ifhasPerm(sys:user:add)能实现但代码重复度高更优雅的方式是注册一个自定义指令import { usePermissionStore } from /store/permission function hasPermission(value) { const required Array.isArray(value) ? value : [value] const userPerms usePermissionStore().perms return required.some(perm userPerms.includes(perm)) } Vue.directive(permission, { mounted(el, binding) { if (!hasPermission(binding.value)) { el.parentNode el.parentNode.removeChild(el) } } })使用时在按钮上写v-permissionsys:user:add没有该权限就直接移除 DOM 节点。指令的优先级很高但要注意不能先渲染再移除否则会有闪烁。更稳的办法是在组件渲染前用函数式组件包装或者在路由守卫加载菜单时一并把按钮权限存入 Pinia/Vuex这样模板里还能配合v-if做更灵活的空态展示。5. 权限系统落地时的验证方法与常见坑5.1 用 CURL 快速验证接口权限后端接口写好授权注解后不需要打开前端页面用 curl 就能验证# 登录获取 token假设验证码固定为 0000 curl -X POST http://localhost:8080/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:admin123,captchaKey:test,captchaCode:0000} # 携带 token 访问受保护接口 curl -X GET http://localhost:8080/sys/user/list \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... # 不带 token 访问预期返回 401 curl -X GET http://localhost:8080/sys/user/list验证完后查看 Redis 里的 keyredis-cli keys login:token:*。如果登录后 Redis 中没有这个 key说明拦截器或登录服务里忘记写白名单如果带 token 访问仍然返回 401优先怀疑 JWT 密钥不匹配或者 Token 前缀没去掉Bearer后面的空格。5.2 几个常见的隐蔽问题第一个是 Redis 序列化问题登录后 Redis Desktop Manager 里看到\xAC\xED开头的乱码说明代码里用的不是StringRedisTemplate或者RedisCache里没配置GenericJackson2JsonRedisSerializer。解决办法是统一使用 JSON 序列化。第二个问题是 Security 白名单不生效。检查SecurityConfig里是否用了多个WebSecurityConfigurerAdapter或SecurityFilterChain时配置被覆盖。更常见的坑是后端接口路径带上下文路径antMatchers(/auth/login)匹配不上实际请求/api/auth/login需要在路径前加/api/**。第三个问题是按钮权限不生效。前端v-permission移除 DOM 后Vue Router 的缓存页面再次进入时指令不会重新执行导致权限变更后按钮状态不刷新。解决方案是在路由切换时重新调用nextTick触发更新或者在 store 里存一个version字段watch到变化后强制重新渲染。5.3 把项目跑起来的最后一步拿到项目源码后最先做的事不是看代码而是按顺序完成三步先执行sql/oldx_permission.sql初始化数据库再修改application.yml里的 MySQL 账号、Redis 地址最后分别启动后端mvn spring-boot:run和前端npm install npm run dev。如果前端启动后接口请求 404检查 Vite 或 vue.config.js 里的代理配置把/api代理到http://localhost:8080同时后端也把项目根路径设置为/api两边保持一致即可。整个链路验证通了之后再按第二、三、四章的顺序去改业务心里会清晰很多。本文还有配套的精品资源点击获取