Spring Security 6 + JWT 前后端分离认证授权实战指南 不夸张地讲Spring Security 是 Java 生态里最让人头疼、但又最值得花时间吃透的框架之一。我在做一个前后端分离项目时技术栈选了 SpringBoot Vue安全这块要求必须支持 Token 登录、接口权限控制、返回统一 JSON最终用 Spring Security 全部实现。当时网上翻了一堆教程要么是老的WebSecurityConfigurerAdapter写法放到 Spring Security 6.x 上直接编译报错要么只讲单体应用里的 Session 登录和前后端分离场景完全对不上号。这篇文章把我完整的接入过程写出来从 SpringBoot 引入依赖、用户体系搭建、JWT 登录认证、自定义过滤器、统一 JSON 响应到方法级权限控制、CSRF / CORS 处理全部基于 Spring Security 6.x 的新写法让你能直接照着落地。适合刚开始接触 Spring Security 的开发者也适合被版本差异折磨过的同学。1. 先想明白前后端分离的登录认证到底和传统 Session 差在哪1.1 传统 Session 认证为什么撑不住前后端分离很多老项目的安全认证走的是 Session Cookie 这套玩法用户登录成功后服务端把用户状态存在 Session 里然后给浏览器返回一个 JSESSIONID 写进 Cookie。后面每次请求浏览器自动把 Cookie 带上去服务端拿着 JSESSIONID 查 Session能查到就说明你已登录。这套机制在单体应用、前后端不分离的时代完全够用但放到前后端分离的项目里就开始别扭了。核心原因有两个。第一前端可能是 Vue、React 或者小程序HTTP 客户端不再像浏览器那样自动管理 Cookie。原生小程序里你得手动处理 Cookie跨域的 axios 请求也得额外配置withCredentials而且现在很多项目前后端是不同域名甚至不同端口跨域场景下 Cookie 的SameSite策略会带来各种隐性拦截。与其强行把 Session 塞进现代前端架构里不如直接换成无状态的 Token 方案。第二Session 是有状态的。集群部署或者微服务架构下用户的登录状态存在某一台服务器的内存里下次请求被负载均衡转发到另一台Session 就丢了。虽然可以引入 Redis 统一存储 Session但你又得多维护一套会话存储的中间件。而 JWT 这类 Token 方案天然无状态服务端不保存用户会话用户信息全部编码在令牌里任何实例拿到合法 Token 都能完成认证这才是前后端分离项目该有的样子。1.2 选 Token 还是选 Session技术选型的底层逻辑看到这里你可能会问是不是所有前后端分离项目都该无脑上 JWT我个人的答案是没有银弹。如果项目对安全性要求极高、需要服务端随时吊销某个用户的所有会话那 Session Redis 依然是靠谱的选择。但如果你做的是一个普通的业务系统、管理后台或者接口需要被多端调用JWT 能帮你省掉一堆会话同步的麻烦。JWT 的结构本身就是三段式Header、Payload、Signature。Header 里放签名算法Payload 里放用户名、过期时间等声明信息Signature 用服务端密钥生成。正因为签名存在客户端拿到 Token 后并不能篡改里面的内容——一改签名立刻校验失败。常见的误区是以为 JWT 是加密的。其实 Payload 只是 Base64 编码别人拿到 Token 是可以直接解出里面的字段的。所以敏感信息一定不要放进去比如密码、手机号这些真要放也要自己额外加密。我的习惯是只在 Token 里放用户名和过期时间其余用户信息每次从数据库查或者放进 Redis 缓存这样既保证了无状态又不至于把鸡毛蒜皮的东西全塞进令牌里。安全这块Spring Security 本来就提供了非常完整的过滤器链我们只是把怎么判断一个请求是否已登录从 Session 替换成了 JWT 而已。2. 把 Spring Security 接进 SpringBoot 项目依赖、配置、用户体系搭建2.1 版本选择与 Maven 依赖先说版本这可以说是第一个大坑。现在主流的组合是 SpringBoot 3.x JDK 17 Spring Security 6.x。如果你还在用 SpringBoot 2.x很多写法还能用旧的WebSecurityConfigurerAdapter但升级到 3.x 之后这个类直接被移除了网上大量老教程统统失效。我的项目用的是 SpringBoot 3.2.xSpring Security 6.2.xJDK 17。如果不想被版本折磨核心思路是SpringBoot 3.x 就一定按 Spring Security 6 的写法来不要再纠结为什么不继承那个适配器类。Maven 依赖这样加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency 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 /dependencyJWT 这块我用的是io.jsonwebtoken的 jjwt 库0.11.5 稳定版。注意jjwt-impl和jjwt-jackson的 scope 设置为 runtime这样编译期不需要、运行期自动加载也符合官方推荐。再准备一个application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/security_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jwt: secret: YTJiM2M0ZDVlNmY3ZzhoOWlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODk expiration: 86400000jwt.secret我建议用一个 Base64 编码后的 256 位密钥。因为 jjwt 库要求 HMAC 签名密钥至少 256 位你如果直接写个短字符串是会启动报错的。生成方式很简单随便找一个 Base64 编码工具把一段随机字符串编码一下即可。2.2 最小可运行的安全配置Spring Security 引入后默认它会拦截所有请求并且自动生成一个随机密码用于表单登录。你如果不写任何配置启动项目后访问任意接口会跳到一个默认登录页。在前后端分离项目里这种行为肯定不能忍必须自己写配置。Spring Security 6 推荐用SecurityFilterChain的 Bean 来定义规则package com.example.securitydemo.config; import com.example.securitydemo.security.JwtAuthenticationFilter; import jakarta.annotation.Resource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; Configuration EnableWebSecurity public class SecurityConfig { Resource private JwtAuthenticationFilter jwtAuthenticationFilter; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .cors(Customizer.withDefaults()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/register).permitAll() .requestMatchers(/doc.html, /webjars/**, /v3/api-docs/**, /swagger-ui/**, /favicon.ico).permitAll() .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() .anyRequest().authenticated() ) .exceptionHandling(ex - ex .authenticationEntryPoint((request, response, authException) - { response.setContentType(application/json;charsetUTF-8); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write({\code\:401,\msg\:\未登录或Token已过期\}); }) .accessDeniedHandler((request, response, accessDeniedException) - { response.setContentType(application/json;charsetUTF-8); response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write({\code\:403,\msg\:\没有权限访问\}); }) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }几个关键点我展开说明一下。csrf(csrf - csrf.disable())不是让你放松警惕而是前后端分离项目里没有 Session / Cookie 这套状态机制CSRF 攻击依赖浏览器自动带上 Cookie这个前提。我们用 JWTToken 是放在 Authorization Header 里的CSRF 已经没有意义所以关闭。sessionManagement设为STATELESS意思是让 Spring Security 彻底不管 Session每个请求都独立验证这也是 Token 方案的核心。配置里那个requestMatchers(HttpMethod.OPTIONS, /**).permitAll()非常关键。前后端分离项目跨域时前端很多请求会先发一个 OPTIONS 预检请求这个请求没有 Authorization Header如果不放行预检直接被安全框架拦掉现实效果就是你前端怎么调接口都是 401。另外addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)是把我们后面要写的 JWT 过滤器插入到 Spring Security 过滤器链的指定位置。UsernamePasswordAuthenticationFilter 是表单登录的地方我们的 JWT 过滤器放在它之前意思就是先看 Token 认不认认完再走后面的授权判断。2.3 用户表设计、UserDetailsService 与密码加密Spring Security 里有个核心接口叫UserDetailsService它就是用来根据用户名把用户信息查出来的。只要把这个接口的实现注册成 BeanSpring Security 在认证时就会自动调它。这个接口的结构非常适合已有的业务系统你完全不用改自己的用户表结构只要写一个适配层把数据库里的用户转成框架认识的UserDetails即可。我这里的用户表很简单就三张核心表用户表、角色表、用户角色关联表。权限就粗粒度地挂在角色上。把你自己的表结构和下面这个适配逻辑对上就行。先写一个UserDetailsServiceImplpackage com.example.securitydemo.service.impl; import com.baomidou.mybatisplus.core.toolkit.StringUtils; import com.example.securitydemo.entity.SysUser; import com.example.securitydemo.mapper.SysUserMapper; import jakarta.annotation.Resource; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.List; import java.util.stream.Collectors; Service public class UserDetailsServiceImpl implements UserDetailsService { Resource private SysUserMapper sysUserMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser sysUserMapper.selectByUsername(username); if (sysUser null) { throw new UsernameNotFoundException(用户不存在); } ListString roles sysUserMapper.selectRoleCodesByUserId(sysUser.getId()); ListSimpleGrantedAuthority authorities roles.stream() .map(role - new SimpleGrantedAuthority(ROLE_ role)) .collect(Collectors.toList()); return new User( sysUser.getUsername(), sysUser.getPassword(), authorities ); } }注意我在角色编码前拼接了ROLE_前缀。这是 Spring Security 的约定用hasRole(ADMIN)判断时框架会自动在前面拼ROLE_所以数据库里角色编码只存ADMIN就行。密码加密毫无疑问用BCryptPasswordEncoder。BCrypt 是自适应 Hash 算法每次加密同一个密码得到的哈希串都不一样但校验时能匹配成功。这种方式能有效对抗彩虹表攻击。数据库里存的用户密码应该是前端明文密码经过passwordEncoder.encode(...)之后的结果。你千万不要为了一时方便存明文我见过太多练手项目这么干上线被脱库就是分分钟的事。3. 核心逻辑实现JWT 登录、过滤器串联与统一 JSON 响应3.1 JWT 令牌工具类JWT 这块我封装了一个工具类负责生成 Token、解析 Token、校验 Token 三个功能。package com.example.securitydemo.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Base64; import java.util.Date; Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expiration}) private Long expiration; private SecretKey getSigningKey() { byte[] keyBytes Base64.getDecoder().decode(secret); return Keys.hmacShaKeyFor(keyBytes); } public String generateToken(String username) { Date now new Date(); Date expiryDate new Date(now.getTime() expiration); return Jwts.builder() .setSubject(username) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public String getUsernameFromToken(String token) { Claims claims Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); return claims.getSubject(); } public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }这段代码有三个地方值得注意。生成 Token 时我用setSubject(username)把用户名放进了 JWT 的 subject 字段这是标准做法。setExpiration设置过期时间我从配置文件读的86400000是毫秒也就是 24 小时。校验时如果 Token 过期或者签名不对会抛出JwtException只要捕获到就返回 false。IllegalArgumentException处理的是 Token 为 null 或格式完全不合法的情况。Base64.getDecoder().decode(secret)是配合我在 yml 里配置的 Base64 密钥来的。这个设计是为了避免直接把原始字符串丢给Keys.hmacShaKeyFor时因为长度不够而启动报错。你换同样长度的密钥时业务代码完全不用改。3.2 登录接口实现登录接口的作用是接收用户名和密码交给 Spring Security 完成认证成功后返回 Token。它的标准写法如下package com.example.securitydemo.controller; import com.example.securitydemo.common.Result; import com.example.securitydemo.dto.LoginRequest; import com.example.securitydemo.security.JwtUtil; import jakarta.annotation.Resource; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/auth) public class AuthController { Resource private AuthenticationManager authenticationManager; Resource private JwtUtil jwtUtil; PostMapping(/login) public Result login(RequestBody LoginRequest request) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); SecurityContextHolder.getContext().setAuthentication(authentication); String token jwtUtil.generateToken(request.getUsername()); return Result.success(token); } }你可能好奇AuthenticationManager是哪来的。我们之前在 SecurityConfig 里只定义了PasswordEncoder和SecurityFilterChain并没有显式给AuthenticationManager注册 Bean。别急Spring Security 内部会构建一个AuthenticationManager但如果你要在 Controller 里注入它需要在配置类里手动暴露一下Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }authenticationManager.authenticate(...)这一行是整个登录流程的发动机。框架会自动调用我们写的UserDetailsServiceImpl去查用户然后用PasswordEncoder比对密码。比不过就抛BadCredentialsException比得过就返回一个认证成功的Authentication对象。拿到认证结果后我用SecurityContextHolder把认证信息放进线程上下文这样当前线程后面再取getAuthentication()就能拿到登录用户。如果是注册接口别忘了用passwordEncoder.encode(明文密码)存库。一个很常见的低级错误是注册时直接存明文或者把encode之后的密码再encode一遍存进去。只加密一次不要加二次。3.3 自定义 JWT 过滤器登录接口做完接下来就是让服务器认识Token这一步了。Spring Security 的过滤器链在每进来一个请求时会依次执行过滤器。我们写一个过滤器在授权判断之前先检查请求头里的Authorization是否带了合法的 Bearer Token如果合法就主动把认证信息塞给 SecurityContext。package com.example.securitydemo.security; import com.example.securitydemo.service.impl.UserDetailsServiceImpl; import jakarta.annotation.Resource; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Resource private JwtUtil jwtUtil; Resource private UserDetailsServiceImpl userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (StringUtils.hasText(authHeader) authHeader.startsWith(Bearer )) { String token authHeader.substring(7); if (jwtUtil.validateToken(token)) { String username jwtUtil.getUsernameFromToken(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); if (userDetails ! null SecurityContextHolder.getContext().getAuthentication() null) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } filterChain.doFilter(request, response); } }这里有几个容易踩坑的细节。OncePerRequestFilter是 Spring 提供的基类保证一个请求只执行一次过滤器。如果只实现Filter可能在转发场景下被多次执行没必要。authHeader.substring(7)是因为Bearer .length()刚好等于 7。很多人写成substring(6)下标越界不说你怎么调都不对。Token 校验通过后我去loadUserByUsername重新查了一次数据库。这一步看着多余其实是为了拿最新的角色权限信息。如果只从 Token 里拿角色用户角色中途被改了也不会生效得等 Token 过期。对安全敏感的系统这个每次请求查一次权限的成本是值得的。把authentication.setDetails(...)那行去掉也不会影响功能但它能让后续日志和审计里带上请求来源信息建议保留。最后如果 Token 无效或者没有带 Token这个过滤器什么都不做直接放行。放行后 Spring Security 后续的anyRequest().authenticated()会发现 SecurityContext 里没有认证信息于是触发我们之前配置的 401 处理器。这个设计也是对的过滤器只负责识别并填充身份真正拦截的活交给授权规则。3.4 认证失败与权限不足的处理在前后端分离项目里最烦的事情之一就是安全框架拦截之后返回一坨框架自带的 HTML 错误页面。比如 403 Forbidden 页面、或者跳转到/login。我们要做的是在exceptionHandling里把 401未认证和 403无权限分别替换成统一的 JSON。在 SecurityConfig 里我其实已经写了这两段 Lambda 表达式.authenticationEntryPoint((request, response, authException) - { response.setContentType(application/json;charsetUTF-8); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write({\code\:401,\msg\:\未登录或Token已过期\}); }) .accessDeniedHandler((request, response, accessDeniedException) - { response.setContentType(application/json;charsetUTF-8); response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write({\code\:403,\msg\:\没有权限访问\}); })实际项目中我会把这两段再提取成一个单独的类比如RestAuthenticationEntryPoint implements AuthenticationEntryPoint然后放到 IoC 容器里让配置类更干净。但是如果你项目就几十个接口写在配置类里也没毛病少一点文件和类反而更好维护。一个重要的点是区分 401 和 403 的应用场景。401 代表没有认证信息或认证失败用户应该去登录403 代表已经登录了但你没有权限看这个接口用户可能是个普通用户点了一个管理员按钮。前端拿到 401 要做的是跳转登录页拿到 403 要做的是弹窗提示没有权限。这两个状态码你要是在后端混着返回前端就要跟着猜非常坑。4. 权限控制实战方法级注解、RBAC 与前端对接细节4.1 开启方法级安全注解如果只在过滤器链里做粗粒度的 URL 拦截权限粒度永远到不了方法这一层。管理后台里最常见的就是同一个 Controller普通用户能查列表管理员才能删除。Spring Security 的方法级安全注解对这种需求几乎是零成本的。先在配置类上加一个注解Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { // ... }EnableMethodSecurity是 Spring Security 6 中的新注解替代了老版本里的EnableGlobalMethodSecurity。然后就可以在方法上直接声明权限规则RestController RequestMapping(/api/user) public class UserController { GetMapping(/list) PreAuthorize(hasRole(ADMIN)) public Result list() { // 只有 ADMIN 角色能访问 return Result.success(); } PostMapping(/add) PreAuthorize(hasAuthority(sys:user:add)) public Result add(RequestBody SysUser user) { // 只有拥有 sys:user:add 权限的能访问 return Result.success(); } }hasRole(ADMIN)和hasAuthority(sys:user:add)是两条不同的判断路径。hasRole会自动在前面补ROLE_前缀和我们UserDetailsServiceImpl里拼ROLE_是配套的hasAuthority则是完全精确匹配字符串适合细粒度权限码。项目刚起步的时候我建议直接用hasRole粗粒度搞定。等系统真的复杂到需要同一个角色在不同业务模块里有不同权限时再迁移到权限码体系。4.2 基于角色和权限的 RBAC 模型很多新手会有一个疑问角色和权限到底是不是同一个东西我的经验是角色是权限的集合权限是最小的操作单元。比如运营专员这个角色可以拥有sys:order:view和sys:order:export两个权限运营主管除了以上两个还有sys:order:delete。用户表、角色表、菜单/权限表、两张关联表这就是最经典的 RBAC 模型。落到数据库设计上我常用的核心表结构是表名作用关键字段sys_user用户表id, username, password, statussys_role角色表id, role_code, role_namesys_user_role用户角色关联表user_id, role_idsys_menu菜单权限表id, perm_code, menu_name, parent_idsys_role_menu角色菜单关联表role_id, menu_id我在这套结构下扩展了SysUserMapper让它能查出某个用户的所有角色编码select idselectRoleCodesByUserId resultTypejava.lang.String SELECT r.role_code FROM sys_user_role ur INNER JOIN sys_role r ON ur.role_id r.id WHERE ur.user_id #{userId} /select这样写之后UserDetailsServiceImpl里拼ROLE_前缀的roles就有了数据来源。如果你还想做到同一个 URL根据用户的权限码决定是否放行那就需要自定义AuthorizationManager或者用requestMatchers动态判断。但我强烈建议你先从方法级注解入手不要一上来就搞动态鉴权复杂度完全不在一个量级。等业务确实需要了再去研究FilterInvocationAuthorizationManager也不迟。4.3 前端 Vue 对接时的几个关键细节安全框架在后端做得再好前端对接时稍不注意整个链路还是跑不通。我自己在前端踩过几个坑特意记一下。登录时Vue 的 axios 请求要把 Token 存到localStorage或者 Pinia 的 store 里然后每个请求都要在请求拦截器里加上 Authorization 头。这段代码几乎是标配// axios 请求拦截器 service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config })注意我在后端过滤器里写的是authHeader.startsWith(Bearer )所以前端拼的这个Bearer前缀必须原样带上大小写、空格都不能错。响应拦截器要统一处理 401service.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } )一个容易被忽视的问题是Token 过期之后前端可能同时发出好几个请求每个都返回 401结果页面疯狂跳登录。比较稳妥的方案是引入一个是否正在刷新 Token / 是否已经跳转登录的开关变量保证同一时间只处理一次。如果项目没有做刷新 Token的功能最简单的办法是在 401 拦截里加一个布尔锁弹出一次登录跳转即可。5. 踩坑实录CSRF、CORS、版本差异与 401/403 排查5.1 CSRF 关闭与 CORS 配置每次讲 Spring Security总会有人问为什么要把 CSRF 关掉前后端分离项目关了真的安全吗CSRF 攻击的本质是用户已经在浏览器里登录了网站 A此时浏览器持有 A 的 Cookie。当用户访问恶意网站 B 时B 偷偷发起一个对 A 的请求浏览器会自动带上 A 的 Cookie于是攻击请求就被服务端误认为是用户本人发出的。但我们的系统用 JWTToken 放在 Authorization Header 里浏览器并不会自动在跨站请求时带上这个 Header。所以 CSRF 就失去了浏览器自动携带凭证的前提关掉它是合理的。为什么很多人还会在前后端分离项目里被 CSRF 坑多半是项目里同时也用了一点 Cookie 做其他用途比如记住登录状态、或者把 Token 也塞进 Cookie。只要 Token 在 Cookie 里CSRF 风险就又回来了这时候你就得保留 CSRF 防护或者把 Token 从 Cookie 里取出来放 Header。再说 CORS这也是前后端分离的必经之路。我在 SecurityConfig 里写了.cors(Customizer.withDefaults())然后还需要提供一个CorsConfigurationSourceBeanpackage com.example.securitydemo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; Configuration public class CorsConfig { Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; } }这里有个容易报错的组合addAllowedOrigin(*)和setAllowCredentials(true)不能同时使用。*表示任意来源但如果又允许携带 Cookie浏览器出于安全策略会直接拒绝。所以我用的是addAllowedOriginPattern(*)它允许搭配allowCredentialstrue。如果你的前端地址是确定的比如http://localhost:5173更推荐明确写死来源不要用通配符。线上环境尤其如此通配符 允许凭证的组合范围太大了。5.2 Spring Security 5 到 6 的写法迁移我见过太多人把 5.x 的代码直接复制到 6.x 项目里然后一脸懵地看着报错。核心差异其实就几条搞清楚之后迁移很快。WebSecurityConfigurerAdapter已经无了。Spring Security 6 里你必须用SecurityFilterChainBean 的方式来配置这也是我整篇文章采用的写法。那行经典的public class SecurityConfig extends WebSecurityConfigurerAdapter在新版本里直接报类已被移除别再用了。antMatchers改成了requestMatchers。在新版里authorizeHttpRequests之后的.antMatchers(/api/**)根本编译不过。这不仅仅是改名更是因为新版废弃了 AntPathMatcher内部改用了 PathPattern 解析器。and()链式调用的风格也别再写了。Spring Security 6 推荐用 Lambda DSL代码可读性更强也能避免很多链上对象类型推断不出来的编译问题。EnableGlobalMethodSecurity改成EnableMethodSecurity这个我在 4.1 已经提到。我整理了一个速查表功能Spring Security 5 旧写法Spring Security 6 新写法配置方式继承 WebSecurityConfigurerAdapter定义 SecurityFilterChain BeanURL 匹配antMatchers(/api/**)requestMatchers(/api/**)方法级安全EnableGlobalMethodSecurityEnableMethodSecurity自定义登录页formLogin().loginPage(...)配置 AuthenticationEntryPoint 或 formLogin(...)请求鉴权authorizeRequests()authorizeHttpRequests()这个问题没有绕过去的余地你一旦用了 SpringBoot 3就必须接受新写法。老教程可以当思路参考但代码别直接抄。5.3 排查问题的调试清单与经验最后把我实际调试中遇到的高频问题整理成一个清单你可以打印出来对照处理。第一类所有接口都返回 401。先确认登录接口本身是不是放行了。如果登录接口也被拦那就是requestMatchers的路径写错了比如路径是/api/auth/login你的配置里写成了/api/auth/**那没问题但如果你写的是/api/login就会拦。再检查前端请求是不是真的携带了 Authorization 头有时候浏览器 Network 面板里能清楚地看到请求头根本没带。第二类接口返回 403 而不是 401。这种情况多半是已经登录了但角色权限不满足。优先看日志Spring Security 的授权异常会打印当前用户的权限列表。如果你发现hasRole(ADMIN)一直失败检查一下数据库角色编码是不是没拼成ADMIN以及UserDetailsServiceImpl里是不是漏了ROLE_前缀。第三类前端传了 Token 但后端识别不了。打开浏览器的 Network 面板看请求头里 Authorization 的值是不是Bearer eyJhbGci...这种格式。很多人把substring(7)理解为取第八个字符开始但如果你前端发的就是Bearer加 Token这个逻辑是对的。如果你后端收到了但 validate 失败多半是密钥不匹配也就是生成 Token 的jwt.secret和解析 Token 的jwt.secret不是同一个。注意 Spring Boot 配置文件里那个Value注入值在测试环境或打包环境被覆盖的情况排查时先打印一下JwtUtil里的 secret 值。第四类CORS 配置了但前端还是报跨域。先确认 Security 配置里真的调用了.cors(Customizer.withDefaults())。很多人只在 MVC 层配了跨域忘了安全层。Spring Security 的过滤器链比 MVC 拦截器更靠前安全层不放行跨域预检MVC 层配置再全也没用。第五类明明没登录后端却认为你登录了。如果你手动设置了SecurityContextHolder.getContext().setAuthentication(...)记得在请求结束时清理上下文。SecurityContextHolder默认是ThreadLocal策略线程池复用时脏数据会串到下一个请求里。虽然在 Spring Security 的过滤器链里它有自动清理机制但如果你自己在业务代码里手动塞认证信息一定要确认是否处于过滤器链管理范围内。调试的时候有一个最笨但最有效的办法在JwtAuthenticationFilter开头打一行日志打印请求 URI 和 Authorization 头在授权判断后打一行日志打印当前认证对象的类名和权限列表。一个请求从走进来到走出过滤器链每个环节的状态都被日志记录下来问题基本一眼就能定位。我踩过滤掉器顺序的坑就是在日志里发现自定义过滤器压根没执行才想起来忘记在SecurityConfig里调addFilterBefore。我的个人体会是Spring Security 就像一个管道的管理者你要理解的不是某一段代码而是整个请求在管道里经过了哪些关卡、每一关负责什么、为什么顺序不能乱。搞懂了过滤器链前面那些版本差异、CSRF、CORS 的问题其实都能迎刃而解。按 WebSecurityConfigurerAdapter 老思路去套 Spring Security 6只会越来越痛苦按本文这套SecurityFilterChain JWT过滤器 方法级注解的思路走前期多花的几个小时后面会帮你省下大把时间。