SpringBoot 3.x + Spring Security 6.x + JWT鉴权实战:从升级踩坑到安全加固

发布时间:2026/10/7 4:16:39
SpringBoot 3.x + Spring Security 6.x + JWT鉴权实战:从升级踩坑到安全加固 1. 踩过SpringBoot 3.x升级的坑才敢写这篇JWT鉴权实战做Java后端的朋友应该都有体会SpringBoot从2.x升到3.x最痛的还不是JDK必须上17而是Spring Security 6.x那套完全重写的配置方式。网上能找到的JWT整合教程八成还在用WebSecurityConfigurerAdapter那套老写法拿来在3.x项目里直接跑不通。这篇文章我想把SpringBoot 3.x Spring Security 6.x JWT这套方案从零到一完整捋一遍不讲空理论全部是实测过的代码和踩坑记录适合刚接触微服务鉴权、或者正在把老项目往SpringBoot 3.x迁的同学参考。先说清楚这套方案能解决什么问题我们做的系统是前后端分离架构后端提供纯REST接口前端是Vue写的SPA应用。传统Session方案在分布式环境下要么得引入Redis做Session共享要么得开粘性会话都很麻烦。改用JWT后服务端不存任何登录状态用户登录成功返回一个签名的Token前端每次请求把这个Token放进Header里后端通过过滤器统一校验原生支持水平扩容。这套方案上线半年日均请求量在百万级稳定性没什么问题。这篇文章的所有代码基于SpringBoot 3.2.x、Spring Security 6.2.x、JDK 17JWT库用的是jjwt 0.11.5。文章会覆盖从依赖引入、Security配置、自定义过滤器、Token续签到常见漏洞防范的完整链路最后再把我在实际环境里碰到过的几个典型问题整理成排查手册。2. 为什么Spring Security 6.x不能再按老写法做2.1 从WebSecurityConfigurerAdapter到SecurityFilterChain的变迁现在还有很多教程在教这么写Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers(/api/auth/**).permitAll() .anyRequest().authenticated(); } }这段代码在SpringBoot 3.x里直接编译报错因为Spring Security 6.0起WebSecurityConfigurerAdapter被彻底移除了。官方的替代方式是注册SecurityFilterChain类型的Bean配合EnableWebSecurity注解使用。这个变化不是简单的API重命名整个配置模型的底层都有调整比如antMatchers换成了requestMatchersauthorizeRequests换成了authorizeHttpRequestscsrf、cors、session管理的配置入口也都有变化。老项目升级到SpringBoot 3.x时这些地方不改完Security根本起不来。为什么官方要做这么大的破坏性变更一个核心原因是原来的做法把HTTP安全和方法安全混在一个适配器类里职责不清晰。新的写法强调通过声明多个Bean来组合过滤器链每个过滤器链负责一组特定请求的规则灵活性大幅提升。同一个服务里可以定义多条SecurityFilterChain一条管REST API用无状态Token鉴权另一条管管理后台的页面登录互不干扰这是老写法很难优雅实现的。2.2 升级之后的核心配置骨架SpringBoot 3.x下最简的Security配置长这样。Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**).permitAll() .requestMatchers(/api/public/**).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这里有几个关键点必须理解透彻。第一无状态模式。.sessionManagement().sessionCreationPolicy(STATELESS)告诉Security我们不用Session每个请求都按无状态处理。这步不做的话Security内部还是会尝试读取Session里的认证信息虽然不一定出错但会给排查问题增加干扰。第二CSRF一定要关。CSRF防护是为浏览器传统表单提交设计的基于Cookie的会话天然存在CSRF风险。我们用Bearer Token走Header请求头里的Token第三方站点读不到也写不了不存在CSRF攻击面不开反而会挡住所有非GET请求。关了之后记得确认自己没有把身份信息放在Cookie里的设计。第三addFilterBefore注册自定义过滤器。JwtAuthenticationFilter是我们写的核心过滤器放在UsernamePasswordAuthenticationFilter之前执行保证进入授权逻辑之前请求上下文里已经填充了认证信息。这个顺序极其重要放错了位置Token解析了也是白搭。第四EnableMethodSecurity要加上。它替代了老版本的EnableGlobalMethodSecurity用来开启方法级别的鉴权比如PreAuthorize(hasRole(ADMIN))。不加的话Controller里写再多注解都不生效。3. JWT的原理和Token结构设计3.1 Token的三个部分到底存了什么JWT全称是JSON Web Token一个Token由三段组成用两个点分隔Header.Payload.Signature。这三段都是Base64URL编码的JSON第一段是签名算法和Token类型第二段是自定义的声明数据第三段是由前两段加密钥/私钥算出来的签名。Base64URL和普通Base64有两个区别一是URL安全的字符集把换成了-/换成了_二是不会在末尾补号。如果手写解析器要注意这个差异否则遇到特殊字符会出错。当然实际开发中直接用成熟的JWT库就行很少有人手写理解这个细节主要用于排查问题。Payload里的标准字段有这些需要知道。字段含义建议设置sub (Subject)主体通常存用户ID或用户名用户唯一标识iss (Issuer)签发者服务名称或域名exp (Expiration Time)过期时间戳按业务需求设置iat (Issued At)签发时间戳当前时间jti (JWT ID)唯一ID防重放UUID自定义字段根据自己的业务需求往Payload里塞比如用户角色、昵称、租户ID、部门ID都可以放进去。但有一个底线绝不能把密码、身份证号、手机号这类敏感信息放进Token。JWT的Payload只是Base64URL编码不是加密任何拿到Token的人都能直接解码看到明文。Token万一泄露等于同时泄露了这些敏感信息。3.2 签名算法选HS256还是RS256JWT支持多种签名算法最常用的是对称算法HS256和非对称算法RS256。HS256用同一个密钥既做签名又做验签实现简单性能好但代价是密钥必须保存在服务端任何能读到密钥的节点都同时拥有签发和验签的能力。RS256用私钥签名、公钥验签私钥只存在于认证服务资源服务只需要公钥。单体应用或者小规模微服务体系用HS256就够了一把密钥配置到所有服务简单直接。但如果你的系统里有独立的认证中心资源和权限校验分散在多个服务RS256是更合理的方案。认证服务持有私钥统一签发Token网关和业务服务只放公钥某个业务服务被攻破拿到公钥也无法伪造Token攻击面显著缩小。还有一个容易被忽略的细节是算法种类不要乱选。之前接手过一个老项目用的签名算法是HS384倒不是说不能用主要问题是很多第三方库对HS384支持不完善各种踩坑。实际项目里HS256和RS256是绝对的主流社区生态最成熟建议直接用。另外要特别注意服务端必须要能限制和解密签名算法防止通过篡改alg字段为none或修改算法名字绕过校验这个我们在后面漏洞章节专门讲。3.3 Token长度和过期时间的设计经验Token不是越短越好但过长确实会带来实际影响。Header和Signature是固定的变量都在Payload里的自定义声明。我见过有人把整个用户对象序列化进Payload一个Token奔着1KB去每次请求都拿这么长的字符串在Header里来回传在移动端弱网环境下性能影响非常明显。自定义声明只放必要的最小集用户ID、用户名、角色信息就够了。其他信息需要时根据用户ID查库或者走缓存。过期时间属于产品决策和技术权衡的结合。AccessToken设太短用户用着用着就掉线体验差设太长Token泄露的风险窗口大。我常用的配置是AccessToken有效期2小时RefreshToken有效期7天配合续签机制实现用户无感刷新。如果是内部系统对安全要求没那么高AccessToken可以放宽到8到12小时关键业务服务建议别超过2小时。4. 认证核心环节登录接口与JwtAuthenticationFilter实现4.1 登录接口的完整实现登录接口是整个鉴权链路的入口。它的任务很清晰校验用户名密码校验通过后生成AccessToken和RefreshToken返回给前端。下面给出一个完整的实现示例。RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private JwtTokenProvider jwtTokenProvider; Autowired private UserDetailsService userDetailsService; PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest loginRequest) { // 1. 通过AuthenticationManager执行认证 Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); // 2. 认证成功从认证结果中取出用户信息 UserDetails userDetails (UserDetails) authentication.getPrincipal(); // 3. 生成Token String accessToken jwtTokenProvider.generateAccessToken(userDetails); String refreshToken jwtTokenProvider.generateRefreshToken(userDetails); // 4. 返回给前端 return ResponseEntity.ok(new LoginResponse(accessToken, refreshToken, userDetails.getUsername())); } }AuthenticationManager的实例在使用SecurityFilterChain的新架构下需要在配置类里手动暴露。Bean AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); }这里有个要注意的点AuthenticationManager.authenticate调用时如果用户名不存在、密码错误或者账号被锁定都会抛出不同类型的AuthenticationException全局异常处理器要分别处理。比如BadCredentialsException返回“用户名或密码错误”DisabledException返回“账号已被禁用”不要把这些异常的堆栈直接暴露给前端一方面不友好另一方面是信息泄露。登录接口还有一个容易被忽略的优化空间。每次认证成功后都走一遍用户数据库查询加载权限信息。对于负载较高的登录场景可以引入本地缓存或Redis缓存用户的权限信息减少数据库压力。当然前提是用户的角色变更要能及时同步刷新缓存否则会出现权限跟新不及时的问题。4.2 JwtAuthenticationFilter的完整代码JwtAuthenticationFilter是这个方案的灵魂。它的核心逻辑分成三步从Header里取Token、解析并校验Token、把认证信息塞进SecurityContext。来看完整实现。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtTokenProvider jwtTokenProvider; Autowired private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 1. 从Header获取Token String token resolveToken(request); // 2. Token存在则校验并加载用户信息 if (token ! null jwtTokenProvider.validateToken(token)) { String username jwtTokenProvider.getUsernameFromToken(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 3. 写入SecurityContext SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken request.getHeader(HttpHeaders.AUTHORIZATION); if (StringUtils.hasText(bearerToken) bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); } return null; } }有几个细节值得展开说。第一次接触Spring Security的同学可能不理解SecurityContextHolder是做什么的我习惯把它类比成现场工牌。我们写完代码直接return了一个脱敏后的username并存储到本地保存环境中不只是给框架看的也是给后续的Controller方法、Service业务、PreAuthorize调用看的方便业务代码读取当前登录人。注意事项OncePerRequestFilter。这个父类保证过滤器在同一个请求里只执行一次避免因为转发导致重复执行。之前的经验是使用Spring MVC自定义filter时如果用了简单的Filter接口去实现在转发场景下会出问题因为这会让后续整个链路逻辑执行两遍。注意事项UserDetailsService的调用是必需的。也许有人会觉得Token本身已经合法了就直接用Token里的信息来构造认证对象不用去数据库加载。但这会有一个隐藏的问题用户角色如果已经被改了旧的Token还在有效期内如果不去加载最新权限那么被降权的用户依旧可以访问他本来不应该再访问的接口。每次请求都调loadUserByUsername看着好像开销大实际这个调用可以走Redis或本地缓存并且有效保证权限的新鲜度。安全敏感系统必须这样做。注意事项Token无效时不要直接抛异常。我们这里filter内部如果validateToken失败就什么都不做让请求继续往后走。后续的授权过滤器在SecurityContext里找不到认证对象会抛出AuthenticationException由我们的统一异常处理器转换成401 JSON返回给前端。如果我们在这里直接throw exception有些情况下异常不会走全局异常处理器导致返回一个默认的500或401页面前端拿到的不是约定好的JSON格式反而会出错。4.3 JwtTokenProvider的封装Token生成和解析的逻辑建议独立封装成一个组件不要散落在Controller或者Filter里。一个完善的JwtTokenProvider至少包含生成AccessToken、生成RefreshToken、解析Token、校验Token四个核心方法。Component public class JwtTokenProvider { Value(${jwt.secret}) private String secret; Value(${jwt.access-token-expiration}) private long accessTokenExpiration; // 毫秒 Value(${jwt.refresh-token-expiration}) private long refreshTokenExpiration; // 毫秒 private SecretKey getSigningKey() { byte[] keyBytes Decoders.BASE64.decode(secret); return Keys.hmacShaKeyFor(keyBytes); } public String generateAccessToken(UserDetails userDetails) { Date now new Date(); Date expiryDate new Date(now.getTime() accessTokenExpiration); return Jwts.builder() .setSubject(userDetails.getUsername()) .claim(roles, userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority).collect(Collectors.toList())) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public String generateRefreshToken(UserDetails userDetails) { Date now new Date(); Date expiryDate new Date(now.getTime() refreshTokenExpiration); return Jwts.builder() .setSubject(userDetails.getUsername()) .claim(tokenType, refresh) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public String getUsernameFromToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody() .getSubject(); } public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }密钥配置这里必须说清楚。HS256要求密钥长度至少256位也就是32字节。直接写一个短的字符串比如my-secret运行时会直接报错提示密钥强度不足。我习惯生成一个Base64编码的256位随机数放在application.yml的配置项里用下面的命令生成。# macOS / Linux 下生成32字节随机密钥并Base64编码 openssl rand -base64 32 | tr -d \n生成的key长这样类G0x3m9YvNk8sQ1FhT2bWc7LzQw5Rt6Uj然后配置到jwt.secret里。注意这个值在local环境和prod环境各生成一个不要复制同一把密钥到生产环境。密钥泄露等于整个鉴权系统被击穿这一点怎么强调都不过分。5. Token续签策略和登出的正确姿势5.1 双Token机制怎么设计才能既安全又无感上一节提到了AccessToken和RefreshToken分开设计。常见的设计是AccessToken有效期短2小时RefreshToken有效期长7天。前端在AccessToken过期后拿RefreshToken去调用刷新接口换一个新的AccessToken回来。这样用户不需要重新输入密码后端也不需要改用户登录状态。刷新接口的核心逻辑如下。PostMapping(/refresh) public ResponseEntity? refresh(RequestBody RefreshRequest request) { String refreshToken request.getRefreshToken(); // 1. 基础校验Token是否合法 if (!jwtTokenProvider.validateToken(refreshToken)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(刷新Token无效); } // 2. 额外校验必须是RefreshToken而不是AccessToken拿过来用 String tokenType jwtTokenProvider.getTokenTypeFromToken(refreshToken); if (!refresh.equals(tokenType)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Token类型错误); } // 3. 加载用户并生成新的AccessToken String username jwtTokenProvider.getUsernameFromToken(refreshToken); UserDetails userDetails userDetailsService.loadUserByUsername(username); String newAccessToken jwtTokenProvider.generateAccessToken(userDetails); String newRefreshToken jwtTokenProvider.generateRefreshToken(userDetails); return ResponseEntity.ok(new LoginResponse(newAccessToken, newRefreshToken, username)); }Token类型校验是很容易被忽视的一环。如果不校验AccessToken和RefreshToken的类型攻击者拿到用户的AccessToken可以自己冒充RefreshToken来刷新虽然AccessToken本身就代表了恶意但这会扩大攻击者的操作时间窗口。在RefreshToken里放入tokenType声明并校验就是一道额外保险。RefreshToken还有一个强制的安全要求应该实现吊销能力。JWT天生无状态服务端不存Token这意味着服务端无法主动让一个Token失效。想象这个场景用户点了退出登录前端丢掉了Token但攻击者之前已经截获了这个RefreshToken只要它没过期攻击者就能一直用它换新Token服务端完全感知不到。解决思路是把RefreshToken存入Redis用用户ID作为key每次刷新时对比Redis里的Token与请求带来的Token是否一致登出时删除这个key。// Redis存储RefreshToken的逻辑示意 public void storeRefreshToken(String username, String refreshToken) { String key auth:refresh: username; redisTemplate.opsForValue().set(key, refreshToken, Duration.ofMillis(refreshTokenExpiration)); } public boolean validateRefreshTokenInStore(String username, String refreshToken) { String key auth:refresh: username; String storedToken redisTemplate.opsForValue().get(key); return refreshToken.equals(storedToken); } public void revokeRefreshToken(String username) { String key auth:refresh: username; redisTemplate.delete(key); }实际上RefreshToken里携带用户ID的话用用户ID做Redis key直接做到单设备登录需求。用户A在手机登录用户B同账号在电脑登录后者覆盖前者的RefreshToken前者下次刷新时对不上Redis的值就会被强制下线。这是双Token方案在真实业务中最典型的玩法。5.2 AccessToken过期了但不想让用户重新登录怎么办有一种比较轻量的方案是滑动过期Sliding Expiration。每次请求携带有效Token时如果Token剩余有效期低于某个阈值就在响应头里下发一个全新的AccessToken前端拦截器接收到以后自动替换本地存储。这种方案不依赖Redis实现简单但安全性略低于RefreshToken方案因为刷新动作是由服务端在每次请求处理里隐式完成的不好做吊销控制。从实际落地效果看对于绝大多数To B内部系统来说滑动过期已经足够用。对于暴露在公网的To C应用RefreshToken加上Redis吊销才是最稳妥的方案。选择哪种取决于你的系统暴露面和安全等级要求没有绝对的对错。5.3 登出接口不要让前端白删Token登出接口如果只是返回200让前端把Token删掉那服务端完全没起到作用。前面说了无状态Token的问题是服务端无法主动失效所以服务端必须在登出的时候做点实质性的工作。PostMapping(/logout) public ResponseEntity? logout(HttpServletRequest request) { String token resolveToken(request); if (token ! null) { // 将AccessToken加入黑名单有效期设为剩余时间 long remainingTime jwtTokenProvider.getRemainingTime(token); if (remainingTime 0) { redisTemplate.opsForValue().set( auth:blacklist: token, 1, Duration.ofMillis(remainingTime) ); } // 删除RefreshToken String username jwtTokenProvider.getUsernameFromToken(token); revokeRefreshToken(username); } return ResponseEntity.ok(登出成功); }AccessToken的撤销用黑名单机制。在Redis里以Token本身作为keyTTL设置成Token的剩余有效期。JwtAuthenticationFilter里校验Token时先查一下黑名单里有没有这个Token有就直接判无效。虽然引入了Redis依赖但对于一个正常的生产级系统来说Redis本身也是绕不开的基础设施。我见过不少团队为了省事登出接口就是返回200剩下的全靠前端自己删Token。这种方案在真正的安全评审里一定被挑战。开玩笑地说如果你的系统里做渗透测试的人拿到了Token又访问了登出接口结果服务端照样认这个Token那整个设计的可信度就直接被打折扣了。6. 必须重视的JWT安全漏洞与鉴权绕过问题6.1 算法混淆攻击是JWT最经典的漏洞去年很火的jwt漏洞总结系列文章里算法混淆攻击被反复提及。它的原理是利用服务端校验Token时不限制算法类型攻击者通过抓包拿到一个合法Token把Header里的alg改成none然后去掉签名部分构造一个空签名的Token发给服务端。如果服务端对alg做判断的代码用的是Jwts.parserBuilder().build().parseClaimsJws(token);没有显式指定签名算法或者对none做了特殊处理攻击者就成功绕过了鉴权。jjwt库从0.11版本开始会对algnone的Token直接拒绝但如果你使用的是老版本或者自己手写的解析器那就不好说了。防护方式是在解析时明确指定算法集合Jwts.parserBuilder() .setSigningKey(getSigningKey()) .requireSubject(expectedValue) .build() .parseClaimsJws(token);除了algnone还有一类非常隐蔽的是RS256转HS256。服务端用公钥验签攻击者把算法改成HS256同时用服务端的公钥作为HMAC密钥来签名。服务端如果同时支持这两种算法且不做区分验证就会通过。因为这个原因新版本的jjwt已经不允许在解签时混用不同算法族。更严格的处理是每个应用场景固定一把密钥和一个算法不给攻击者做选择的余地。6.2 密钥硬编码和弱密钥是自毁围墙很多项目的JWT密钥长这样secret: 123456或者直接写在代码里。这等于给攻击者送钥匙。只要有固定的弱密钥用Hashcat配合规则字典暴力破解只是时间问题。攻击者拿到密钥之后不但能伪造Token还能反向解析历史上所有截获的Token内容因为HS算法是符号化的只要能认你签的就能解你签的。正确的做法包括几个层面。密钥必须达到算法要求的最小长度HS256是32字节不能短密钥放在配置中心或环境变量里不进代码仓库线上和测试环境使用不同的密钥定期轮换密钥时保留旧Token的兼容期避免轮换瞬间所有在用的Token全部失效。6.3 Token过期时间设置不合理导致的风险窗口过期时间设置过长是很多JWT系统的通病。我曾经排查过一个案例某内部系统AccessToken有效期设置30天通过日志发现攻击者用截获的Token连续访问了十几天管理员毫无察觉。Token的这种特性决定了不能在过期时间上贪多长Token生命周期意味着更大的风险暴露宽度。建议根据不同场景差异化设置核心交易接口2小时内一般查询12小时内企业内部低敏系统最长7天。同时做好服务端的日志审计记录每个Token的首次签发时间、最后使用时间、访问的接口列表一旦发现异常模式能快速定位风险面。6.4 自省日志里千万别打印Token还有一个很多人容易忽视的细节日志。排查问题的时候顺手把Token打进日志成了常态。但Token和密码的区别是什么密码可以改改了旧的就可以作废Token在有效期内始终有效。如果日志系统被拖库里面所有的Token全部可以被利用而且用户还不知情。强制规范就是全局搜索代码里log.info、log.debug中是否包含request.getHeader(Authorization)或者token变量一个都不能留。真要排查Token相关问题只记录Token的前几位和后几位作为关联标识完整的Token一律不落盘。7. 鉴权绕过和高危场景的排查技实录7.1 过滤器生效了但请求还是401这个问题比重很高。JwtAuthenticationFilter拿不到Token或者解析出来的认证信息没被Security认可最终表现都是401。排查思路按下面的顺序来。第一确认过滤器有没有被注册到过滤器链里。在SecurityConfig里没有调addFilterBefore的话过滤器永远不会执行。可以在JwtAuthenticationFilter的doFilterInternal里打一条日志看请求进来时有没有走到这。第二确认白名单路径是否匹配。requestMatchers(/api/auth/**).permitAll()这里如果请求路径是/api/authLogin前缀和/restful风格不匹配就会被当成需要认证的请求自然401。Spring Security的路径匹配支持Ant风格通配符要用对场景。第三确认Token在Header里的格式。resolveToken方法严格要求Bearer 开头注意B是大写Bearer后面有一个空格。前端拷贝粘贴最容易丢的也是这个东西比如写成bearer或者Bearer后面没有空格都会导致解析失败。第四确认SecurityContext里的Authentication是否正常写入。在业务代码里通过SecurityContextHolder.getContext().getAuthentication()取出来有可能是null最常见的原因是过滤器执行顺序错误或者代码里有异步操作导致SecurityContext在子线程中丢失。7.2 为什么有些接口匿名也能访问排查这类问题先看两个地方。一是requestMatchers白名单是不是太宽。比如把/api/**都permitAll了那所有接口都是裸奔的这种情况往往是复制粘贴造成的。二是注解和过滤器链配置的优先级。EnableMethodSecurity开启后Controller方法上的PreAuthorize会和过滤器链的规则叠加。当过滤器链放行时方法级安全可能仍然在保护这个接口反过来也一样。碰到匿名访问的诡异问题时我推荐一个快速定位方法在目标接口对应的Controller方法入口打一行日志输出当前认证对象然后检查SecurityFilterChain的规则。只要这两点查明白80%的问题都能水落石出。7.3 会话还在为什么升级Spring Security版本后全部403如果你在升级Spring Security到新版本后发现原本正常的接口全部403排查重点放在CSRF和CORS上。6.x版本的csrf配置语法变了老代码如果配置的是http.csrf().disable()直接编译出错但如果用的是http.csrf(AbstractHttpConfigurer::disable)就没事。CORS配置在Security和Spring MVC里是两套体系只在MVC配了CORS没有在Security链里开启预检请求在Security这一层就被拦住了。一个稳妥的配置是在Security链里统一开启corshttp.cors(cors - cors.configurationSource(corsConfigurationSource()))7.4 常见问题速查表症状可能原因排查手段登录接口报401permitAll路径没匹配上检查requestMatchers配置登录成功但后续接口401Token解析失败或Header格式错误检查JWT密钥和Header格式权限变更后Token依旧能访问用户信息未从数据库重新加载检查UserDetailsService是否被调用升级后全是403CSRF或CORS配置不兼容确认csrf disable和cors enable403而不是401无认证信息和无权限是两个概念检查Authorization入口刷新Token返回Token类型错误没做tokenType claim校验补充校验逻辑多个用户串号SecurityContext泄漏到线程池确认线程间SecurityContext传递7.5 线上环境的一次真实鉴权绕过排查记录上个月排查过一个生产事故现象是某用户的Token在其他用户的浏览器上也能正常访问接口。这个问题最后定位到线程池身上。业务代码里用了一个全局的线程池异步处理消息每次提交任务时没有清空SecurityContext导致上一个请求的用户身份被带到了下一个请求的任务上下文里。修复方案是在异步提交前彻底清理SecurityContext或者在线程池任务里重新设置上下文public void asyncHandler() { // 保存当前上下文信息但不直接传递SecurityContext对象 Long currentUserId SecurityContextHolder.getContext().getAuthentication() ! null ? ((SysUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getId() : null; threadPoolTaskExecutor.execute(() - { SecurityContextHolder.clearContext(); try { // 业务逻辑里只使用currentUserId不依赖SecurityContext if (currentUserId ! null) { doBusiness(currentUserId); } } finally { SecurityContextHolder.clearContext(); } }); }这种问题特别隐蔽不压测很难暴露很多团队直到线上出现串号事故才会来排查。8. 从单体走向微服务JWT鉴权的演进路线SpringBoot 3.x Spring Security JWT这套组合不只是单体项目的标配。当服务拆分成多个模块之后JWT的作用会更明显。比如你有一个网关服务、一个用户服务、一个订单服务。用户服务负责签发Token订单服务负责接收请求它并不需要知道用户密码只需要能验证Token的有效性拿里面的用户ID和角色做权限判断就行。微服务下JWT的部署可以有三种模式。最省事的做法是每个业务服务都配置相同的HS256密钥各自引入JwtAuthenticationFilter和SecurityConfig实现自治鉴权。缺点前面说过了密钥防泄露压力大。进阶做法是在网关层统一鉴权网关校验Token把用户信息通过Header透传给下游服务下游服务信任网关卡好的身份信息。这种做法要配合服务间信任机制下游服务不能暴露在外网否则绕过网关就能伪造Header。更正规的做法是引入独立的认证授权服务核心逻辑跟单体一样生成Token时用RS256私钥签名业务服务只配公钥验签。角色的权限数据从认证服务统一拉取。这套拆法需要一点基础设施投入但安全边界清晰密钥管理方便适合对安全比较敏感的金融、政企类项目。实际微服务项目里还有个麻烦事用户Token在A服务已经登录了走到B服务又被要求登录。这种问题的本质是多个服务各自维护了不同的SecurityFilterChain规则Token验证方式不统一。解决方案有两种统一网关过滤规则或者把所有服务纳入同一个SSO体系。不管选哪种核心思路都是让认证逻辑收敛到一处避免各服务各搞一套。8.1 如果从零设计一个多服务共存环境下的JWT方案先把认证逻辑收敛到认证中心。登录、刷新、登出这些接口只存在于认证中心业务服务不实现这些接口。Token生成时用RS256私钥签名业务服务持有公钥做验签。业务服务需要知道当前用户是谁吗需要。但不要在业务服务里解析Token拿用户ID而是由网关在验证Token之后把用户ID、角色列表通过约定的Header明文传给业务服务。业务服务唯一要信任的就是网关所以必须保证业务服务只在内网被网关访问不对公网开放。退出登录需要考虑的就不只是删除Redis里的RefreshToken了网关要能实时把加入黑名单的AccessToken筛掉。可以用网关上的本地缓存加Redis发布订阅的方式同步黑名单也可以每次请求都查一次Redis。实时性要求高就直接查Redis但网关每请求一次Redis会增加一次IO。按国内项目的规模网关与业务服务之间的流量不小这一点建议压测后再定方案。9. 写给新手的三个实操建议如果看完这篇文章你正准备动手在项目里落地这套方案我把自己反复踩过的坑浓缩成三个建议第一花十分钟把你的SecurityConfig配置和代码库里的SecurityConfig做一个diff。如果你的项目是从SpringBoot 2.x升级上来的重点确认WebSecurityConfigurerAdapter是否已经移除干净SecurityFilterChain是否注册成功antMatchers是否全部换成了requestMatchers。这些地方不清理出现的问题会非常诡异因为你脑子里的模型还是老的。第二把JWT相关的组件严格分层。Token的生成解析放专门组件搜索过滤的工作放过滤器控制器只做接入。千万不要在业务Service里直接调用Jwts.builder()临时这样写一时爽后续要换库、要加逻辑的时候你会想骂人。接口清晰了测试也好写后续升级替换的代价就小。第三从第一天就把安全细节做扎实。密钥管理、日志脱敏、过期时间、刷新机制、黑名单方案这些都是上线后极难补的。系统还没上线的时候是加这些逻辑成本最低的窗口期。等系统跑起来用户量上来了想加黑名单机制需要灰度、需要评估兼容性、需要处理各类边缘场景复杂度呈指数上升。最后说一点我自己的体会。JWT本身只是一个技术选型真正的安全取决于周边一套完整的方案设计。网上有很多示例代码只讲了Token的生成和解析完全不提密钥管理、续签设计、日志脱敏这些内容照抄到生产环境是要出大问题的。把这篇文章里的安全意识和实现细节都消化掉再回到你自己的项目里去你的鉴权方案会扎实得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询