SpringBoot3+SpringSecurity6前后端分离JWT权限认证实战 前后端分离项目做到权限这块十个人有八个会直接选中SpringSecurity。但真上手用SpringBoot3去集成你会发现网上大量的教程还停留在SpringSecurity5的写法——包名全是javax开头配置类继承WebSecurityConfigurerAdapter一抄就报错。加上SpringBoot3强制要求JDK17起步整个体系的变化比想象中大得多。这篇文章我把SpringBoot3 SpringSecurity6在前后端分离架构下的完整落地思路拆开讲从依赖引入、SecurityFilterChain配置、JWT集成到接口鉴权每一步都给出现成代码和踩坑记录保证你照着能跑通。先交代一下我的项目背景一个典型的管理后台前端Vue3后端SpringBoot3.3.x接口走RESTful风格登录成功返回JWT令牌。这套方案跑下来最大的感受是SpringSecurity6的配置模型比5清爽很多但自定义过滤器的坑也更多稍不注意就会被默认行为背刺。1. 拆解需求前后端分离项目到底需要什么样的安全管理1.1 从Session到Token为什么前后端分离不能再靠Session传统单体Web项目里SpringSecurity默认做的事是登录成功后把用户信息塞进Session并写入Cookie给浏览器。浏览器下次请求自动带上CookieSecurity在服务端查Session就知道你是谁。这套机制在服务端渲染时代非常好用因为你基本不需要考虑跨域、不需要考虑移动端。但前后端分离之后情况完全变了前端跑在DevServer比如Vite的5173端口后端跑在8080端口两者已经跨域了。即使你配置了CORS让Cookie可以跨域携带但麻烦依然很多不同的客户端网页、小程序、App对Cookie的支持不一样集群部署时Session要额外引入Redis共享前后端分开部署之后CSRF等安全模型的成本也变高了。所以前后端分离项目的通用做法是摒弃Session和Cookie改成Token机制。用户登录成功后后端签一个JWT返回给前端前端把它存起来localStorage或者Pinia每次请求在HTTP头里带上Authorization: Bearer token。后端通过过滤器解析Token拿到里面的用户名和权限信息构建SecurityContextSpringSecurity的授权机制照常运转。这套方案最大的好处是天然无状态后端不用存任何会话信息对水平扩展非常友好。1.2 SpringBoot3带来的变化SpringSecurity6有哪些坑位要重新对齐先理清版本关系。SpringBoot3.0之后官方把javax.*包迁移到了jakarta.*包同时SpringSecurity升级到了6.x。很多人跟着老教程写代码一引入SpringBoot3就发现Security相关的类全部报红原因就在这里。还有一点SpringSecurity6.0把WebSecurityConfigurerAdapter这个类废弃了以前那种“继承适配器然后重写configure”的写法直接失效。现在主流的做法是把SecurityFilterChain声明成一个Bean然后用HttpSecurity的lambda风格去配置。这不算特别难但从旧版本过来的人确实需要适应一阵。另外一个需要知道的点SpringBoot3最低要求JDK17如果项目还在用JDK8或JDK11要么先升级JDK要么老老实实退回SpringBoot2.x。我个人的建议是新项目直接用JDK21LTS版本 SpringBoot3.3.xSpringSecurity6.2以上这套组合用起来非常稳定。2. 项目结构与安全链路设计2.1 工程目录怎么规划如果你打算在项目里引入SpringSecurity建议从一开始就把结构理清楚不要全部塞在一个类里面。我常用的一种分层方式是这样的com.example.demo ├── common # 公共模块 │ ├── result # 统一返回体 │ └── exception # 全局异常 ├── config # 配置类 │ ├── SecurityConfig.java │ └── CorsConfig.java ├── security # 安全相关 │ ├── JwtUtil.java │ ├── JwtAuthenticationFilter.java │ ├── RestAuthenticationEntryPoint.java │ ├── RestAccessDeniedHandler.java │ └── LoginUser.java ├── controller ├── service ├── mapper └── entity这样拆分的好处是认证、授权、工具、异常处理互不干扰后面排查问题的时候非常快。尤其是自定义过滤器、认证入口点、拒绝处理器这些类单独放到security包里看起来一目了然。2.2 一次登录请求完整走一遍安全链路在写代码之前建议先把整个请求的流转链路在脑子里过一遍。一个登录请求加一个访问受限资源的请求大致是这样用户提交用户名和密码到/login接口过滤器链把这个请求放行因为没有配置白名单拦截Controller里的LoginService收到请求调用AuthenticationManager.authenticate()做认证认证通过后用UserDetails里的用户ID和角色信息生成JWT返回给前端前端后续请求带上Authorization头JwtAuthenticationFilter从请求头中取出Token解析并验证有效性把用户信息塞进SecurityContextHolder请求继续走过滤器链如果访问的接口需要特定角色SpringSecurity根据SecurityContext中拿到的权限做判断权限足够就放行到Controller权限不足就走AccessDeniedHandler返回403Token无效或缺失就走AuthenticationEntryPoint返回401。这套链路你理解透了后面所有配置都是往这个骨架里填细节不会乱。2.3 明文密码不可取BCrypt接入密码存储这个事很多人容易忽略。正常情况下用户注册或修改密码时服务端绝对不允许存明文密码。SpringSecurity默认提供了BCryptPasswordEncoder它是BCrypt强哈希算法的实现自带盐值每次加密结果都不一样破解成本高是目前最推荐的密码编码器。在SpringSecurity里面把它注册成Bean然后在配置里指定它为PasswordEncoder后面的用户校验就都交给它处理了Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }在注册时加密BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); String encodedPassword encoder.encode(rawPassword);登录校验时AuthenticationManager会自动用这个Encoder去做密码比对。千万不要自己写equals判断那等于没做加密。3. 核心配置SecurityFilterChain怎么搭3.1 基础配置代码SpringSecurity6的核心就是SecurityFilterChain这个Bean它决定了哪些请求需要认证、哪些放行、用什么样的认证机制、CORS怎么处理。我直接给一份能跑通的配置作为起点Configuration EnableWebSecurity public class SecurityConfig { Resource private JwtAuthenticationFilter jwtAuthenticationFilter; Resource private RestAuthenticationEntryPoint authenticationEntryPoint; Resource private RestAccessDeniedHandler accessDeniedHandler; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .cors(cors - {}) // 启用cors具体由CorsConfigurationSource配置 .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/auth/login, /auth/captcha, /doc.html, /webjars/**, /v3/api-docs/**).permitAll() .anyRequest().authenticated() ) .exceptionHandling(ex - ex .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这里面的几个关键点csrf.disable()前后端分离项目不用SessionCSRF本身的意义已经变弱而且JWT方案天然不怕CSRF所以直接关闭。sessionManagement策略设为STATELESS告诉Security不要创建HttpSession这是无状态认证的核心。authorizeHttpRequests这一段是授权配置白名单接口放行其余全部要求认证。exceptionHandling自定义401和403的返回否则默认返回的是HTML错误页面前端根本没法处理。addFilterBefore把JWT过滤器加在用户名密码认证过滤器之前确保Security在做认证之前我们已经把Token解析好了。3.2 白名单里的门道白名单是配置里最容易出问题的地方。很多人配了requestMatchers(/login).permitAll()结果发现还是被拦截多半是路径对不上。SpringSecurity的requestMatchers匹配的是Servlet路径不走spring.mvc.servlet.path这个前缀设置这是很多坑的根源。还有一个非常实际的场景如果项目里用了knife4j接口文档组件你需要把它的路径也放行掉否则一打开文档页面就401非常烦人。knife4j在SpringBoot3里至少要放行这些路径.requestMatchers( /doc.html, /webjars/**, /v3/api-docs/**, /favicon.ico ).permitAll()如果knife4j版本比较老还有可能额外访问/swagger-resources建议一并加上。通常我的做法是把所有需要放行的路径集中到一个常量数组里方便统一管理private static final String[] WHITE_LIST { /auth/login, /auth/logout, /doc.html, /webjars/**, /v3/api-docs/**, /favicon.ico };3.3 CORS必须排在前面前后端分离项目CORS配置是绕不开的。SpringSecurity会在过滤链里处理CORS但前提是你必须显式启用它并且提供一个CorsConfigurationSource。常见做法是单独写一个配置类允许指定来源或全部来源允许所有方法暴露Authorization响应头Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); config.addExposedHeader(Authorization); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }注意allowCredentials(true)和addAllowedOriginPattern()是可以同时使用的但用addAllowedOrigin()就不行浏览器会直接拒绝。如果你设置了Credentials来源必须具体到域名或用Pattern去匹配。在SecurityConfig里还要保证CORS在SpringSecurity的过滤器链最前面。上面的配置里.cors(cors - {})用空lambda它就会自动获取CorsConfigurationSource这个Bean。至于顺序Security框架内部会优先处理CORS预检请求不需要我们自己调整。4. JWT认证完整实现4.1 JwtUtil的编写JWT的核心就是生成和解析。我一般用jjwt库0.11.5或0.12.x并且把必要的参数放到application.yml里。先加依赖dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency注意0.11.5版本的SecretKey要求长度至少256位。不要拿一串短的字符串去签名否则启动就会抛异常。我的做法是生成一个至少32字节的密钥然后放到配置里。下面这个工具类把生成、解析、判断过期都封装好了Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) // 单位小时 private Long expire; private SecretKey getKey() { byte[] keyBytes secret.getBytes(StandardCharsets.UTF_8); return Keys.hmacShaKeyFor(keyBytes); } public String generateToken(String username, Long userId, ListString roles) { Date now new Date(); Date expireDate new Date(now.getTime() expire * 3600 * 1000); MapString, Object claims new HashMap(); claims.put(userId, userId); claims.put(roles, roles); return Jwts.builder() .setClaims(claims) .setSubject(username) .setIssuedAt(now) .setExpiration(expireDate) .signWith(getKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getKey()) .build() .parseClaimsJws(token) .getBody(); } }如果用的是jjwt 0.12.x版本反射API略有不同但整体思路一样。核心要点是Token里缓存了用户名、用户ID和角色列表解析时验证签名和过期时间任何一步失败都会抛出异常交给上层过滤器处理。有一点我要特别提醒JWT一旦签发在过期前是无法主动失效的。如果你需要处理用户下线、改密码踢人、管理员禁号这类场景必须引入Token黑名单或Redis管理机制。做得简单一点存一份用户名和Token版本号请求时比对版本号即可。4.2 自定义JwtAuthenticationFilter过滤器是整条认证链路的核心。SpringSecurity通过过滤器链来处理每个请求我们在这里拦截请求头中的Token解析并构建Authentication对象放进SecurityContextHolder这样后面的授权判断才有依据。完整代码如下Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Resource private JwtUtil jwtUtil; Resource private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null SecurityContextHolder.getContext().getAuthentication() null) { try { Claims claims jwtUtil.parseToken(token); String username claims.getSubject(); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // token解析失败不做处理后续过滤器会返回401 SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (StringUtils.hasText(bearer) bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }有一个细节容易被忽略为什么每次请求都从数据库loadUserByUsername因为我们需要拿到最新的角色和权限信息否则用户在服务端修改了角色旧Token在有效期内还是会带着旧权限访问。这样做缺点是每次请求都查一次库性能会有损耗。如果项目并发很高可以考虑把角色和权限缓存到JWT里只在关键操作时再校验这是一种性能与安全之间的trade-off。还有一次我踩过的坑JwtAuthenticationFilter里如果忘记判断SecurityContextHolder里已有认证信息重复设置Authentication会导致后续的授权判断异常而且排查起来非常隐蔽。记得先判断再设置。4.3 认证失败与权限不足的统一返回默认的401和403返回是一大段HTML前端根本没法解析。我们必须把这两个出口替换成JSON。分别实现AuthenticationEntryPoint和AccessDeniedHandler并用统一返回体包装。参考实现Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(JSON.toJSONString(Result.error(401, 未登录或登录已过期))); } }Component public class RestAccessDeniedHandler implements AccessDeniedHandler { Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(JSON.toJSONString(Result.error(403, 没有权限访问该资源))); } }这里的核心逻辑其实很简单不给重定向不给HTML直接往Response里写JSON。最后在SecurityConfig中把这两个Bean绑定到exceptionHandling里就是上一节写的代码。5. 授权控制与接口权限5.1 基于角色的接口限制认证解决了“你是谁”的问题授权解决的是“你能干什么”的问题。在实际项目里最常用的是基于角色的控制。比如管理后台管理员可以删除用户普通运营人员只能查看数据这就需要给不同角色分配不同接口权限。在SpringSecurity中最简单的做法是在SecurityFilterChain里直接配置.authorizeHttpRequests(auth - auth .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/user/**).hasAnyRole(ADMIN, USER) .anyRequest().authenticated() )注意这里的hasRole(ADMIN)默认要求用户拥有的权限是ROLE_ADMIN而不是ADMIN。所以你在构建用户权限时如果用的是角色要么在权限列表里存ROLE_ADMIN要么用hasAuthority(ADMIN)来匹配。我见过太多初学SpringSecurity的人在这里栽跟头JWT里写的是roles:admin然后Security匹配hasRole(ADMIN)大小写不一致永远403。我的建议是角色一律大写权限带上ROLE_前缀这样配置最顺。登录成功给用户构建权限时在UserDetails里统一处理SimpleGrantedAuthority authority new SimpleGrantedAuthority(ROLE_ role);5.2 注解开权限除了在配置中心集中管理更灵活的方式是在Controller方法上使用注解。SpringSecurity支持PreAuthorize、PostAuthorize、Secured等注解配合EnableMethodSecurity一起使用Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { }然后在接口方法上GetMapping(/user/{id}) PreAuthorize(hasRole(ADMIN) or hasAnyAuthority(user:view)) public ResultUserVO getUser(PathVariable Long id) { return Result.success(userService.getUserById(id)); }注解方式的优势是权限和接口写在一起逻辑直观。缺点是权限规则散落在各个Controller里后期梳理起来比较费劲。具体选哪种要看团队习惯。就我自己的项目而言核心模块我会用集中配置个别特殊接口再用注解补充。5.3 动态权限的扩展思路更复杂一点的场景是权限规则经常变化甚至要做到“每个人看到的菜单和接口都不一样”。这种时候固定写死在配置里就不够灵活了。SpringSecurity支持动态权限思路是自定义AccessDecisionManager或利用Spring Data的动态数据源把权限-资源对应关系存到数据库里每次请求时动态判断。这个方案实现成本比较高一般项目用不上。如果项目确实需要我建议先基于注解角色控制撑住前期版本后期再逐步演进。6. 前端对接与常见问题排查6.1 axios请求头怎么带Token后端搞完之后前端对接也是要做一些工作的。最核心的一点每次发请求都要带上Authorization头。常规做法是在axios请求拦截器里统一处理service.interceptors.request.use( config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }, error Promise.reject(error) );注意Bearer后面有一个空格这是JWT认证的标准格式。如果不加Bearer后端解析的时候容易被自己的逻辑坑到。Backend那边resolveToken方法是先判断startsWith(Bearer )如果前端只放了token没带Bearer就解析不到。还有一点经验如果存在跨域且后端CORS配置了addExposedHeader(Authorization)前端就可以在登录成功的响应里通过getResponseHeader(Authorization)读到新的Token适合做Token自动续期的场景。6.2 401/403的全局处理前端遇到401一般意味着登录过期旧Token已经失效。这时候最合理的操作是清除本地用户状态跳转回登录页。如果你是SPA项目可以用路由守卫配合axios响应拦截器来做。service.interceptors.response.use( response response, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(error); } );403则是“你有登录但没权限”一般提示无权限访问即可不需要强制跳登录。这两种状态码的含义最好和前端同学明确对齐避免后端返回501或200code这类怪异的约定。6.3 6个高频问题排查实录最后把这些常见问题集中整理一下都是我实际用这套方案时踩过或帮别人排查过的问题现象根本原因解决方案启动报错找不到Bean提示PasswordEncoder没有在配置类里注册PasswordEncoder添加Bean方法返回BCryptPasswordEncoder每次请求都返回401日志里没有错误JwtAuthenticationFilter里Token解析失败被catch吞掉且未处理在catch块打日志确认密钥与签发时一致确认前端确实带了Authorization头登录成功但POST请求被拒绝CSRF没关闭在HttpSecurity里显式.csrf(AbstractHttpConfigurer::disable)接口文档页面打开401knife4j的路径没加入白名单放行/doc.html、/webjars/**等路径Redis或其他后端服务正常但前端请求一直跨域失败CORS预检请求没有在Security里放行配置CorsConfigurationSource并在HttpSecurity里启用cors有权限却始终403角色前缀不匹配确认hasRole(ADMIN)对应权限是ROLE_ADMIN确认JWT里存的角色名大小写一致补充一个更隐蔽的问题如果配了Spring事务代理然后在同一个类内部调用PreAuthorize方法权限注解是不生效的。因为内部调用不走代理Spring安全的方法拦截器没有被触发。解决办法是拆到不同的Service或者在外部调用。还有一点很多人在开发阶段为了方便把所有请求都permitAll上线前急着收紧权限结果漏了几个接口把自己锁在外面。我的建议是开发阶段就按生产标准配置白名单只放行必要的路径其余fallback到authenticated这样能提前暴露问题。个人实操小经验这套方案我在SpringBoot3.3.3 SpringSecurity6.2.4的组合上完整落地过稳定性没问题。如果你是从老的SpringBoot2项目迁移过来最需要留意的就是javax到jakarta的包迁移还有WebSecurityConfigurerAdapter的退化问题这两个先搞定其他的按SpringSecurity6的lambda风格重新组织就行。建议第一次跑通的时候把日志级别调到DEBUG观察一次401请求的过滤器链输出能非常直观地看到安全过滤器的执行顺序。很多时候权限问题不是配置写错了而是过滤器顺序和预期不一致这种问题看日志比看代码更高效。前后端分离加SpringSecurity这套组合核心思路总结起来就是无状态认证 JWT承载用户信息 自定义过滤器注入上下文 配置白名单和授权规则。只要你把链路走通后面无论是加验证码、集成第三方登录还是做权限分级都是在链路上加环节不会动到骨架。