☰
SpringBoot3+Vue3前后端分离安全实战:SpringSecurity与JWT全流程解析
2026/10/14 5:49:43 网站建设 项目流程

简介:面向全栈与前后端分离开发学习者的完整项目实例,后端基于Spring Boot 3整合Spring Security与JWT认证,前端由Vue.js搭配Element Plus构建,同时引入Redis缓存处理高并发数据访问,并覆盖文件上传下载场景,适合希望系统掌握安全认证、接口设计与前后端交互的初中级开发者。压缩包共2000个文件,合计22.59MB,以1811个JS前端脚本和37个Java后端类为主体,另含76个Markdown说明、53个JSON配置、17个XML配置、5个SQL脚本及1个properties属性文件,前端逻辑、后端服务与数据库表结构均有完整呈现。已有98人学习下载。项目中SecurityConfig安全配置、JwtFilter认证过滤器、JWTUtils令牌工具、RedisSerializer缓存序列化等核心实现一应俱全,还提供UserExcelListener导入监听和文件上传下载模块,可对照源码理解Spring Security与JWT在真实项目中的落地方式,直接复用其中的权限控制、缓存优化及文件处理设计思路。

1. 把 Vue 和 SpringBoot 拼在一起:前后端分离项目到底卡在哪

前后端分离现在几乎是新项目的默认姿势,但真正上手做过 springboot3+vue 的人都知道,难点根本不在写接口或者画页面,而在两端怎么对得上。这份资源把 SpringSecurity、JWT、Vue3 路由守卫、axios 拦截器串成了一条完整链路,从登录发 token 到前端存 token、再到接口带 token 访问受限资源,每一步都有对应代码。适合刚看完基础教程、准备独立做毕业设计或公司内部系统的开发者,也适合想把认证授权这块从"能用"改成"稳"的熟手。我拆过不少类似项目,多数翻车点不在功能实现,而在配置顺序、版本搭配和异常处理这些容易被忽略的细节。

2. 搭认证底座:SpringBoot3 与 SpringSecurity 的版本与配置思路

2.1 依赖选型:为什么 SpringBoot3 必须配 SpringSecurity6

SpringBoot3 是基于 Spring Framework6 构建的,底层用了 Jakarta EE 命名空间,这和旧版的 javax 包有本质区别。因此 Security 的依赖也必须跟着升级到 6.x,不能用老教程里的 5.x 配置方式,否则会出现类找不到或者方法签名不匹配的问题。常见做法是在 pom.xml 里引入 spring-boot-starter-security 和 spring-boot-starter-web,JWT 方面一般配 jjwt 的 api、impl、jackson 三个模块,版本要统一。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>

这里三个 jjwt 模块的分工很明确:api 是编译期用的接口定义,impl 是运行时实现,jackson 负责把 JWT 的 claims 做 JSON 序列化。scope 设为 runtime 是因为你不需要直接调用实现类的方法,只需要在代码里引用 api 的接口。如果你用的是 JDK8,注意 SpringBoot3 最低要求是 JDK17,这一点在创建项目时就要确认,免得本地环境跑不起来。

2.2 Security 配置类:三个关键 Bean 的定义与顺序

Security 配置是整个认证体系的核心,SpringBoot3 的写法相比旧版有变化,重点在于 SecurityFilterChain 的构建方式、密码加密器的声明和认证入口的定制。配置类本身只要标注 @Configuration 和 @EnableWebSecurity 即可,但如果你用了方法级权限校验,还需要加 @EnableMethodSecurity。

@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }

这段配置里有几个细节值得琢磨。csrf 禁用是因为前后端分离场景下用 token 认证,不再依赖 cookie 会话,CSRF 防护的意义不大,反而会拦截掉正常的 POST 请求。sessionManagement 设为 STATELESS 后,Spring 不会再创建 HttpSession,每个请求都是独立的,认证信息完全靠 JWT 携带。addFilterBefore 的顺序很关键,自定义过滤器必须放在 UsernamePasswordAuthenticationFilter 之前,这样才能在 Spring Security 尝试从 Session 取认证信息之前,先从请求头解析 JWT。

2.3 JWT 工具类:生成、解析与异常处理的边界

JWT 工具类通常封装成静态方法,负责生成 token、解析 token、从 token 中提取用户名和角色。生成的逻辑一般是把用户名和角色放进 claims,然后设置过期时间,最后用签名密钥做 HS256 签名。解析的逻辑则是反向操作,把 token 字符串还原成 Claims 对象。

public class JwtUtil { private static final String SECRET = "your-256-bit-secret-key-your-256-bit-secret-key"; private static final long EXPIRATION = 1000 * 60 * 60 * 24; public static String generateToken(String username, String role) { return Jwts.builder() .setSubject(username) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION)) .signWith(Keys.hmacShaKeyFor(SECRET.getBytes()), SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes())) .build() .parseClaimsJws(token) .getBody(); } }

SECRET 的长度是个隐藏约束,HS256 算法要求密钥至少 256 位,也就是 32 个字节,所以字符串不能太短,否则会直接抛 WeakKeyException。生产环境里这个密钥不应该写在代码里,而是通过配置中心的占位符注入,或者至少放在 application.yml 中。parseToken 方法在 token 过期或签名不对时会抛异常,调用方需要 catch 住并返回 401,而不是让异常直接冒到全局处理器。

3. Vue3 侧对接:axios 封装、路由守卫与状态管理一起落地

3.1 axios 实例:请求拦截器把 token 带上

前端这边的第一个关键点是 axios 实例的封装。如果不做封装,每个接口都要手动写 headers,页码一多就会漏,而且响应状态码的处理逻辑会在每个页面重复一遍。常见做法是创建一个 request.js 文件,统一导出配置好的 axios 实例。

import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, error => { return Promise.reject(error) }) export default request

请求拦截器的作用是在每个请求发出前,从 localStorage 里读取 token,然后拼到 Authorization 头里。这里有个小坑:localStorage 的读取要放在拦截器内部,不要在模块加载时读,因为用户可能在页面运行过程中才登录。超时时间设多长取决于你的接口耗时,开发环境 10 秒比较合理,如果接口里有批量导出之类的大任务,要单独给那个请求配置更长的超时。

3.2 路由守卫:不是所有页面都该放行

路由守卫的作用是拦截未登录用户。Vue Router 的 beforeEach 钩子里,你需要判断目标路由是否需要认证,再决定是放行还是跳转登录页。这里常见的错误是只判断有没有 token,而不判断 token 是否过期,导致用户带着一个失效的 token 进入页面,接口请求全部 401。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })

meta.requiresAuth 需要在路由定义时手动标注,比如在需要登录的页面上加 meta: { requiresAuth: true }。redirect 参数的作用是登录成功后跳回用户原本想访问的页面。如果你在 token 过期后清理了 localStorage,但没有触发路由跳转,用户会停留在当前页直到刷新,所以响应拦截器里处理 401 时要顺手做路由跳转。

3.3 登录页逻辑:拿到 token 之后做什么

登录接口返回的数据结构通常是 { code, msg, data },data 里包含 token 和用户信息。前端要做的不是简单地把 token 存进 localStorage 就完事,还要考虑刷新页面后用户信息如何恢复,以及退出登录时如何清理。

const handleLogin = async () => { try { const res = await request.post('/auth/login', { username: loginForm.username, password: loginForm.password }) if (res.data.code === 200) { localStorage.setItem('token', res.data.data.token) localStorage.setItem('userInfo', JSON.stringify(res.data.data.userInfo)) router.push(route.query.redirect || '/') } } catch (error) { ElMessage.error('用户名或密码错误') } }

用户信息存成 JSON 字符串,是为了后续在页面里展示昵称、头像时不需要再请求接口。但要注意的是,这里的用户信息只做展示用,权限判断不能完全依赖前端存的角色,后端接口必须也做校验,否则有人改了 localStorage 里的角色值就能绕过前端限制。

4. 别急着联调:先过一遍这七个高频坑

4.1 跨域配置写了还是报错,可能是过滤器顺序问题

现象:前端请求报 CORS 错误,后端 Security 配置里已经加了 cors().and() 或者 CorsConfigurationSource,但仍然不通。

原因:Spring Security 的过滤器链会先于普通的 WebMvc 拦截器执行,如果 Security 的 CORS 配置没有正确生效,跨域请求会在安全过滤器阶段就被拦截。另一个常见原因是你在 Controller 上单独加了 @CrossOrigin 注解,但 Security 层面的 CORS 没有配置,导致 OPTIONS 预检请求直接 403。

解决:在 SecurityConfig 里显式配置 CorsConfigurationSource 的 Bean,并在 filterChain 中调用 cors(),而不是依赖注解。预检请求要放行,通常 permitAll 路径要覆盖 OPTIONS 请求。

4.2 登录成功后访问受限接口仍然 401,问题出在过滤器没拿 token

现象:登录能拿到 token,请求头里也带了 Authorization,后端还是返回 401。

原因:JWT 过滤器没有把解析出的认证信息放进 SecurityContext,或者过滤器根本没有被执行。常见的有三种情况:过滤器类没有标注 @Component、没有在 SecurityConfig 里手动 new 出来加入过滤器链、解析出的 Authentication 对象权限不足。

解决:确认过滤器被 addFilterBefore 加入了链路,并且每解析成功一个 token 就要构建 UsernamePasswordAuthenticationToken 并 set 到 SecurityContextHolder 中。

4.3 退出登录后 token 还能用,这是一个设计缺陷

现象:用户点击退出,前端清掉了 localStorage,但把 token 复制出来手动请求接口仍然有效。

原因:JWT 是无状态的,后端没有记录 token 的状态,退出登录只是前端不再携带 token,不代表服务端废弃了这个 token。

解决:实现 token 黑名单机制。简单做法是在 Redis 里存一份已注销的 token 列表并设置过期时间,过滤器每次校验 token 时先查黑名单再解析。或者改用访问 token 加刷新 token 的双 token 方案,退出时在服务端清除刷新 token 的上下文,这样即使访问 token 还能用,也无法续期,时间一到自然失效。

4.4 角色变了但权限没变,前端角色缓存惹的祸

现象:管理员给用户改了角色,用户下次登录角色还是旧的。

原因:前端把 userInfo 存在 localStorage 里,只要不手动更新,它就一直是最初的版本。某些场景下用户不退出登录,就永远看不到角色变化。

解决:登录后不要直接把角色写死到 localStorage,页面加载时通过一个 /me 接口从后端拉取用户信息,更新到内存状态里。如果必须持久化,也要在每次拿到新的登录响应时覆盖旧的 userInfo。

4.5 密码加密:网上老教程的 MD5 别再用了

现象:照搬某篇旧文章里的 Md5PasswordEncoder,启动直接报类不存在。

原因:Spring Security5 之后移除了 Md5PasswordEncoder,默认推荐 BCryptPasswordEncoder。BCrypt 的特点是每次加密同一个密码得到的哈希值都不同,因为内部加了随机盐。

解决:使用 BCryptPasswordEncoder,密码校验用 matches 方法。如果你的数据库里已经存了 MD5 哈希,要么做一次性迁移,要么写一个兼容多种加密方式的 DelegatePasswordEncoder,不建议在新项目中继续用 MD5。

4.6 表单序列化把嵌套对象搞丢了,axios 提交 JSON 的姿势不对

现象:提交的数据里含对象或数组,后端收到的却是 null。

原因:默认情况下 axios 会把对象转成 JSON 字符串,如果你的 Content-Type 被设置成了 application/x-www-form-urlencoded,后端反序列化就会失败。或者你用了 FormData 但没设置正确的 headers。

解决:明确指定 Content-Type: application/json 并使用 JSON.stringify 序列化请求体。如果提交的是文件上传的 FormData,不要手动设置 Content-Type,让浏览器自动带 boundary 参数。

4.7 SpringBoot3 的路径匹配策略变了,静态资源被拦

现象:放行了 /api/auth/**,但访问其它接口时报 404 而非 401,静态资源也拿不到。

原因:SpringBoot3 默认不再匹配带斜杠结尾的路径,antMatchers 的写法也有变化。旧写法 security.ignored 或者 WebSecurityConfigurerAdapter 已经废弃,路径匹配要使用 requestMatchers。

解决:所有接口路径统一以 /api 开头,静态资源放到 static 目录并用 permitAll 显式放行。匹配规则用 requestMatchers 代替 antMatchers,表达式写法基本相似但要注意通配符语义。

5. 收尾补全:把权限码做进前端按钮,验证一套完整链路

前后端分离项目的最后一段路,通常是把接口级权限延伸到前端 UI 层。很多项目做完登录认证就算交差,但实际使用中发现,普通用户也能看到管理员的按钮,点击后才报 403,体验很差。后端已有 @PreAuthorize("hasRole('ADMIN')") 之类的校验,前端还需要把角色或者权限码存下来,用来控制按钮是否渲染。这里的实现思路是登录时后端在 JWT 里塞一个 permissions 列表,前端解析后存进状态管理,页面里封装一个 v-permission 指令做判断。

// 权限指令示例 const permission = { mounted(el, binding) { const requiredPermission = binding.value const userPermissions = JSON.parse(localStorage.getItem('permissions') || '[]') const hasPermission = userPermissions.some(p => p === requiredPermission || p === '*') if (!hasPermission) { el.parentNode?.removeChild(el) } } }

使用方式是给按钮或菜单加 v-permission="'user:delete'" 这样的标记。这个方案的优点是权限码和 URL 解耦,后端改了权限码前端不用改代码。但要注意两点:localStorage 里存的权限码要在登录后及时刷新,不能只在路由守卫里读一次;另外这个指令只是隐藏入口,真正的安全边界永远在后端的注解和过滤器里。拆完这个项目之后,我养成了一个习惯:每次做前后端联调之前,先强制走一遍"未登录访问受保护接口"和"登录后访问无权限接口"这两个用例,确认返回状态码和前端跳转逻辑都符合预期,再去调业务功能。这个习惯帮我提前发现了不少过滤器顺序和响应拦截器的问题,希望也能帮到你。

到这里,整条链路就通了。从项目结构、环境准备到用户模块和 JWT 生成、Security 全局配置,再到首页数据、动态表格 EditableTable、文件上传、富文本编辑器、邮件定时任务,每一步都会详细拆解,自己动手实践一遍以后,再利用这份资源对整个项目做二次改造。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询