☰
OAuth2 + JWT 认证授权实战:Spring Security 6 最佳实践
2026/9/28 6:00:25 网站建设 项目流程

做后端接口认证这件事,我从最早的 Session 一路折腾到 JWT,中间经历过 Spring Security 多个大版本更替,踩过的坑攒下来能开个专栏。今天要聊的是 Spring Security OAuth2 与 JWT 集成的最佳实践——授权服务器、资源服务器、令牌续签、安全漏洞规避,我按实际项目落地顺序全部拆开讲。这套方案适合所有需要统一认证、前后端分离、多端登录的项目,也特别适合正在从 Spring Boot 2 往 Boot 3 迁移、被 Security 6 配置改动折磨过的同学。全文不空谈理论,所有代码都是能直接复制运行的那种。

1. 整体架构设计与技术选型背后的权衡

1.1 为什么是 OAuth2 + JWT 而不是传统 Session

先聊最根本的问题:很多团队做认证第一个想到的就是 Session,操作简单,框架自带,Spring Security 里加个表单登录就完事。但一旦进入前后端分离、多端接入、微服务拆分的阶段,Session 的问题就变得非常扎眼——服务端要存状态,集群环境下得引入 Redis 做 Session 共享,移动端和第三方系统要对接时还得专门设计一套 Token 交换协议。

OAuth2 解决的是"授权"本身的问题:它把认证和授权彻底分层。客户端(比如前端 SPA、App、第三方应用)不再直接接触用户密码,而是通过授权流程换取 Token,再拿 Token 去访问资源服务器。JWT 解决的是 Token 的"可验证性":它是一个自包含的、经过签名的 JSON 结构,资源服务器拿到之后无需回查授权服务器就能本地验签,天然适合分布式场景。

这两个东西配合起来,就是一套完整的"发令牌、验令牌、管令牌"闭环。我见过不少项目只用了 JWT 没走 OAuth2,结果自己发明了一套"登录接口发 Token"的流程——前期确实快,但后续加刷新、加撤销、加多客户端支持时,全部要手写,而且安全设计往往是裸奔的。直接站在 OAuth2 协议肩膀上,等于让成熟协议帮你把最难的部分扛掉,性价比非常高。

1.2 版本选型的血泪教训:Boot 3 必须用 Security 6

这一节是我最想强调的,因为网上 70% 的旧教程已经不能直接用了。Spring Boot 2.x 时代主流的做法是继承WebSecurityConfigurerAdapter,重写configure(HttpSecurity http),再用antMatchers()配路径规则。Spring Boot 3 直接上了 Spring Security 6,WebSecurityConfigurerAdapter被彻底移除,这套写法整个作废。

迁移到 Boot 3 之后,正确姿势是声明SecurityFilterChain的@Bean,配合requestMatchers()方法。两套 API 看着像,但细节差异非常大:一个是"继承+重写",一个是"构建+注入";antMatchers的路径匹配模式也被requestMatchers取代。依赖方面,Spring Boot 3 里做 OAuth2 授权服务器要引入spring-boot-starter-oauth2-authorization-server,这是一个独立的 starter,跟老版本的@EnableAuthorizationServer注解完全不是一回事——老注解在 Security 5 里就废弃了,Boot 3 里直接不存在。

版本对照表我建议直接记下来:

组件Spring Boot 2.xSpring Boot 3.x
核心写法继承 WebSecurityConfigurerAdapter声明 SecurityFilterChain Bean
路径匹配antMatchers()requestMatchers()
授权服务器@EnableAuthorizationServer(已废弃)spring-boot-starter-oauth2-authorization-server
资源服务器@EnableResourceServeroauth2ResourceServer() DSL
JDK 要求8/1117+

1.3 这套方案的适用边界

OAuth2 + JWT 不是万灵药,我要先说清楚它的适用边界,免得你听完直接往所有项目里套。它最适合的场景是:系统有多个前端(Web、App、小程序),或需要给第三方系统提供受控的接口访问能力,并且后端是多实例部署或者微服务架构。这种情况下,无状态的 JWT 配合 OAuth2 的授权流程,收益是最大的。

反过来,如果只是一个小后台系统,用户量几百人,没有外部接入需求,那老老实实用 Session + Cookie 反而是更稳的选择,别为了技术炫技引入一套复杂度。这里的关键判断点是"有没有多端接入"和"需不需要与第三方系统做授权协作",两个都没有,就别折腾了。另外,纯内部系统用 JWT 时不能丢掉刷新令牌和注销机制,否则 Token 泄漏后的补救手段几乎为零,这个后面第 5 节细说。

2. Spring Boot 3 与 Security 6 项目环境准备

2.1 依赖引入:三件套缺一不可

我先给出一个最小可运行的依赖配置。这里用的是 Maven 坐标,实测于 Spring Boot 3.2.x:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

授权服务器和资源服务器这两个 starter 我把它们放进了同一个应用里——实际开发时可以拆成两个独立服务,但单体起步阶段放一起调试最方便。Redis 是用来做 Token 黑名单和刷新令牌状态管理的,后面第 5 节会用到。顺带提一句,很多教程还会推荐单独引入jjwt库来做 JWT 的解析和生成,但 Security 6 的授权服务器自带 Nimbus JOSE + JWT 支持,如果你没特殊需求,不引第三方 JWT 库反而少一层依赖冲突风险。

2.2 最容易被忽略的 Security 6 配置迁移细节

网上很多报错帖子问的都是同一个问题:为什么我照着旧教程写http.authorizeRequests(),编译不过?因为在 Security 6 里,authorizeRequests()已经被authorizeHttpRequests()取代,且antMatchers()要换成requestMatchers()。这不是简单的改名,语义上也有区别——新的requestMatchers支持更精准的路径模式、MIME 类型、正则等多种匹配方式。

还有一个隐蔽的变化是默认行为:Security 6 里默认所有请求都需要认证,且 CSRF 默认开启。在开发授权服务器时,如果你从旧项目迁移过来,会发现/oauth2/token之类的端点频繁报 403。这不是 Bug,是 CSRF 防护在起作用——授权服务器的 token 端点不需要 CSRF 防护,但你要显式配置放行,否则踩一整天坑都找不出原因。

依赖注入上也变了:以前可以直接注入AuthenticationManager,现在更推荐直接声明AuthenticationManager的 Bean 或者用AuthenticationConfiguration获取。这部分我在第 3 节给了完整配置,照着抄就不会卡壳。

2.3 项目基础结构与密钥准备

工程落地前,先把目录结构和密钥准备好。我用的是经典的三层包结构:

com.example.auth ├── config │ ├── AuthorizationServerConfig.java │ ├── ResourceServerConfig.java │ └── SecurityConfig.java ├── keys │ ├── auth-private.key │ └── auth-public.key ├── controller │ └── UserController.java └── service └── RedisTokenService.java

密钥用 RSA 非对称加密,这是 JWT 签名最稳妥的方案。生成命令如下,使用 OpenSSL 一把梭:

# 生成私钥(2048 位 RSA) openssl genrsa -out auth-private.key 2048 # 从私钥导出公钥 openssl rsa -in auth-private.key -pubout -out auth-public.key

为什么要用 RSA 而不用 HS256?核心原因是职责分离——授权服务器持有私钥签名,资源服务器只持有公钥验签,私钥永远不会分发出去。如果两个服务器用同一个对称密钥(HS256),那密钥必然要在多处存储,任何一处泄漏等于整个认证体系崩溃。实际生产项目中,私钥应该放在配置中心或者 KMS 里,绝不允许进 Git 仓库,这个坑我在第 6 节会展开讲。

3. 授权服务器与 JWT 令牌核心实现

3.1 客户端注册:RegisteredClient 配置

授权服务器第一步是注册客户端。这里用内存方式做演示,生产环境建议改成JdbcRegisteredClientRepository,把客户端信息持久化到数据库。

@Configuration public class RegisteredClientConfig { @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("demo-client") .clientSecret("{noop}demo-secret") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/demo") .scope("read") .scope("write") .tokenSettings(TokenSettings.builder() .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) .accessTokenTimeToLive(Duration.ofMinutes(30)) .refreshTokenTimeToLive(Duration.ofDays(7)) .reuseRefreshTokens(true) .build()) .build(); return new InMemoryRegisteredClientRepository(client); } }

几个配置要点我得逐个解释一下,否则你抄的时候不知道每个字段在干吗:

  • clientSecret前面的{noop}是密码编码器前缀,表示明文存储。生产环境切忌这样干,要用BCrypt加密,写成{bcrypt}前缀。
  • accessTokenFormat设成SELF_CONTAINED,意思是令牌采用 JWT 这种自包含格式;另一种是REFERENCE,即不透明令牌,需要资源服务器回查授权服务器,跟 JWT 的理念正好相反。
  • reuseRefreshTokens(true)表示刷新时复用同一个刷新令牌。这里先按最宽松的方式配置,第 5 节我会讲生产环境应该设成false做刷新令牌轮换。

3.2 授权服务器核心配置:SecurityFilterChain 与 JWT 编解码

接下来是授权服务器的主配置类,这是整个方案的心脏。我直接贴出完整代码,再拆开讲原理:

@Configuration public class AuthorizationServerConfig { @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/oauth2/**", "/.well-known/**") .authorizeHttpRequests(authorize -> authorize .requestMatchers("/.well-known/**").permitAll() .anyRequest().authenticated() ) .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/token")) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .apply(new OAuth2AuthorizationServerConfigurer()); return http.build(); } @Bean public JWKSource<SecurityContext> jwkSource() throws Exception { RSAKey rsaKey = RSAKey.parse( new KeyStore("keys").getKey("auth-private") ); // 生产环境应使用 KeyStore 加载,此处简化为读取文件 return new ImmutableJWKSet<>(new JWKSet(rsaKey)); } @Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } }

这里的核心是JWKSource:授权服务器在签发 JWT 之前,会从JWKSource里取出 RSA 私钥,通过 Nimbus 库创建签名的 JWS。对应的公钥信息会通过/.well-known/jwks.json端点暴露出来,资源服务器正是靠这个端点获取公钥列表来验签的。

有一个非常重要的细节:JWKSource里的密钥必须持久化。默认情况下如果只写在代码里每次重启生成新密钥,那过去签发的所有 JWT 在资源服务器那边全部变成无效签名,用户全部被登出。生产环境一定要把 RSA 密钥存到 KeyStore 或者配置中心,启动时加载进来,保证密钥稳定。

3.3 自定义 Claims:把用户信息塞进令牌

OAuth2 默认签发的 JWT 里只有sub(用户标识)、iss(签发者)、exp(过期时间)这些标准字段,实际项目中通常需要把用户 ID、角色、昵称等信息放进去,省得资源服务器每次查库。自定义 Claims 通过OAuth2TokenCustomizer实现:

@Component public class CustomClaimsTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> { @Override public void customize(JwtEncodingContext context) { if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) { Authentication principal = context.getPrincipal(); // 从 UserDetails 中提取用户信息 Object details = principal.getPrincipal(); if (details instanceof CustomUserDetails userDetails) { context.getClaims().claim("uid", userDetails.getUid()); context.getClaims().claim("roles", userDetails.getRoles()); context.getClaims().claim("nickname", userDetails.getNickname()); } // 添加 jti,用于后续做黑名单 context.getClaims().id(UUID.randomUUID().toString()); } } }

jti这个字段我建议大家一定要加上。它不是标准必需字段,但后续做 Token 撤销、黑名单、防重放时全靠它。默认情况下 Nimbus 会生成,但为了确保格式统一,建议显式设置。

自定义 Claims 要遵循"最小必要"原则——只放不会频繁变动的信息。如果你把用户的积分、状态之类的实时数据塞进 JWT,那数据一变 Token 就失真了,这时候就不得不缩短过期时间强行刷新,反而把架构搞复杂。放稳定的标志性字段(uid、角色、昵称)就够了,动态数据资源服务器自己查。

3.4 JWT 令牌格式解析:一眼看懂 Token 里有什么

做完上面配置,授权服务器就能正常签发 JWT。截一个真实 Token 拆开看,结构其实是三段 Base64URL:

eyJhbGciOiJSUzI1NiIsImtpZCI6ImF1dGgta2V5In0 .eyJzdWIiOiJkZW1vLXVzZXIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3MzAwMDAwMDAsImlhdCI6MTczMDAwMDAwMCwianRpIjoiYWIxMjNlZiJ9 .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

三段分别对应 Header、Payload、Signature。Header 里声明签名算法RS256和密钥 IDkid;Payload 里是sub、iss、exp、iat、jti等声明;第三段是私钥签名结果。资源服务器验签时,先用kid从 JWKS 端点的密钥列表里找到对应公钥,再用公钥验证第三段签名,然后检查exp过期时间,全部通过才算有效。

我见过太多人在这一步踩坑:资源服务器配置的公钥跟授权服务器的不配对,结果所有 Token 都报 Invalid signature。排查思路很简单,直接打开/.well-known/jwks.json,看暴露的n(模数)和e(指数),跟本地公钥文件是否一致。记住一个口诀:签名用私钥,验签用公钥,分发靠 JWKS 端点。

4. 资源服务器配置与 JWT 校验全链路

4.1 资源服务器 SecurityFilterChain 配法

授权服务器负责发证,资源服务器负责验证明。资源服务器不持有私钥,只配置解码器和校验规则,把公钥来源指向授权服务器的 JWKS 端点:

@Configuration public class ResourceServerConfig { @Bean @Order(2) public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/public/**").permitAll() .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwkSetUri("http://localhost:9000/.well-known/jwks.json") .jwtAuthenticationConverter(customJwtAuthenticationConverter()) ) ); return http.build(); } }

jwkSetUri指向授权服务器的公钥端点,资源服务器第一次收到 JWT 时,会自动去这个地址拉取公钥集合并缓存起来,之后验签都在本地完成。这里有个性能细节:默认的 JWK 缓存刷新周期是 5 分钟,如果你做了密钥轮换(第 6 节会讲),新公钥最长需要 5 分钟才能被资源服务器感知,设计时要给这个窗口留出缓冲。

4.2 从 Scope 到权限:JwtAuthenticationConverter 改造

Security 6 中,资源服务器默认会把 JWT 里的scope字段转换成SCOPE_read这样的权限。但很多项目用的是自定义的roles字段,希望直接映射成ROLE_ADMIN这种角色权限,这就需要自定义转换器:

public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> { @Override public AbstractAuthenticationToken convert(Jwt jwt) { Collection<GrantedAuthority> authorities = new ArrayList<>(); // 映射 scope 字段 Object scopes = jwt.getClaim("scope"); if (scopes instanceof Collection<?> scopeList) { scopeList.forEach(scope -> authorities.add( new SimpleGrantedAuthority("SCOPE_" + scope) )); } // 映射自定义 roles 字段 Object roles = jwt.getClaim("roles"); if (roles instanceof Collection<?> roleList) { roleList.forEach(role -> authorities.add( new SimpleGrantedAuthority("ROLE_" + role) )); } return new JwtAuthenticationToken(jwt, authorities); } }

这个转换器解决了两个非常实际的问题:一是你可以同时用 OAuth2 的scope(控制 API 访问范围)和自定义roles(控制用户角色权限),两者不冲突;二是方法级安全可以直接用@PreAuthorize("hasRole('ADMIN')")这种熟悉的写法,而不必纠结SCOPE_前缀。

这里要提醒一个权限模型上的常见误区:scope 和 role 是两个维度的东西。scope 是"这个 Token 能调哪些 API",role 是"这个用户是什么角色"。拿支付系统举例,一个财务角色的用户可能通过不同 scope 的 Token 访问只读或可写接口。两个维度不要混在一起,否则权限管理会越做越乱。

4.3 方法级安全与细粒度权限控制

路径级权限用requestMatchers搞定,但真正复杂的业务权限(比如"订单只能由创建人本人查看")必须靠方法级安全:在启动类或者配置类上加@EnableMethodSecurity,然后在方法上声明规则:

@RestController @RequestMapping("/api/order") public class OrderController { @PreAuthorize("hasRole('ADMIN') or #orderId == authentication.principal.uid") @GetMapping("/{orderId}") public Order getOrder(@PathVariable Long orderId) { // 业务逻辑 } @PreAuthorize("hasAuthority('SCOPE_write')") @PostMapping public Order createOrder(@RequestBody Order order) { // 业务逻辑 } }

从 JWT 里能直接拿到uid,配合authentication.principal就能实现"资源归属校验",不用每次查数据库判断权限。这种写法定粒度非常灵活,适合订单、文档、项目这类有明显属主概念的资源。

我个人的建议是权限控制分三层:第一层路径规则管大的访问边界,第二层@PreAuthorize管资源级权限,第三层在 Service 内部做数据级校验(比如只查属于自己的记录)。不要试图把全部权限都压在@PreAuthorize表达式里,表达式写得太复杂,后续维护的人根本看不懂,可读性会崩。

5. Token 续签与注销机制的工程实践

5.1 Refresh Token 轮换:为什么不能一直复用

JWT 的过期时间一般只有 30 分钟到 1 小时,过期后客户端要用刷新令牌换新令牌。这里最关键的决策是刷新令牌能不能复用。之前的第 3 节示例里我故意配置了reuseRefreshTokens(true),那是为了方便本地调试——每次刷新都返回同一个刷新令牌,抓包时好认。但生产环境必须改成false:每次刷新都颁发一个新刷新令牌,旧令牌作废。

这就是刷新令牌轮换(Refresh Token Rotation)。它能有效降低刷新令牌泄漏的长期风险——就算攻击者偷到了旧刷新令牌,只要持有者先刷新了一次,旧令牌立刻失效。配合"重复使用检测"(reuse detection),一旦发现同一刷新令牌被用了两次,直接判定为泄漏,把整个用户的所有令牌全部吊销,强制重新登录。这个机制能拦住绝大多数令牌窃取型攻击。

配置上只需在TokenSettings里改两个地方:

.tokenSettings(TokenSettings.builder() .reuseRefreshTokens(false) // 禁用复用,启用轮换 .refreshTokenTimeToLive(Duration.ofDays(7)) .build())

5.2 主动注销:Redis 黑名单方案

JWT 无状态是把双刃剑——签发之后授权服务器没法主动让它失效。用户点了"退出登录",资源服务器依然认为 Token 有效,直到自然过期。解决思路是引入黑名单机制:维护一个"已注销 JWT 的 jti 列表",资源服务器在验签通过后额外查一次黑名单。

我用 Redis 实现,代码很轻量:

@Service public class TokenBlacklistService { private static final String BLOCKLIST_PREFIX = "jwt:blocklist:"; public void revoke(String jti, long ttlMillis) { stringRedisTemplate.opsForValue().set( BLOCKLIST_PREFIX + jti, "1", Duration.ofMillis(ttlMillis) ); } public boolean isRevoked(String jti) { return Boolean.TRUE.equals(stringRedisTemplate.hasKey(BLOCKLIST_PREFIX + jti)); } }

注意黑名单的过期时间要跟 JWT 的剩余有效期对齐——Token 过期后黑名单记录就没存在意义了,让它自动消失可以防止 Redis 里的垃圾键越积越多。在资源服务器的 JWT 解码链路上加上一步检查:

@Component public class BlocklistJwtDecoder implements JwtDecoder { private final JwtDecoder delegate; private final TokenBlacklistService blocklistService; @Override public Jwt decode(String token) throws JwtException { Jwt jwt = delegate.decode(token); // 先做标准验签 if (blocklistService.isRevoked(jwt.getId())) { throw new JwtException("Token has been revoked"); } return jwt; } }

这个方案有一个明显代价:资源服务器每次请求都要查一次 Redis,JWT 的"无状态"优势打了折扣。但工程上这是值得的——注销、改密、封号这些高频安全场景必须有即时生效手段,多一跳 Redis 的延迟(通常在 1ms 以内)完全可以接受。如果对这个延迟都不满意,还有一种纯无状态方案:在 JWT 里加一个token_version字段,用户注销时把版本号加一,资源服务器通过配置中心拿到最新版本号做比对。缺点是版本号本身的更新和分发又引入了新的状态,适合没有 Redis 的小项目。

5.3 多端登录与并发场景的 Token 策略

实际项目里,用户可能同时登录 Web 和 App,甚至同一账号多个设备。这个场景下的 Token 管理要提前想清楚。比较常用的是为每个客户端注册一个独立的"会话":

  • 登录成功后,为该设备生成独立的jti,Redis 里维护user:{uid}:devices集合,存哪些 jti 还活着。
  • 实现"单端登录"时,登录新设备后把该用户其他设备的 jti 加入黑名单。
  • 实现"踢人下线"时,管理员操作时直接根据用户 ID 查出所有活跃 jti,批量拉黑。

这里最核心的一句话是:别试图用一个 Token 管到所有端。为每个端分别签发、分别跟踪,后续做设备管理、安全风控才有抓手。第 3.3 节的自定义 Claims 里建议加上client_id或者设备标识,这样资源服务器能知道当前请求来自哪个端,做差异化业务逻辑(比如移动端的接口返回更精简的数据)。

6. 安全加固与常见漏洞规避

6.1 JWT 漏洞全景:从算法混淆到密钥爆破

聊完功能,必须聊安全。JWT 相关漏洞这几年在各大漏洞库刷屏的次数非常多,我把最常见的几类整理成速查表,对照检查自己的实现:

漏洞类型攻击方式防护措施
alg=none 绕过把 Header 中算法改成 none,去掉签名解码器必须校验签名,禁止 none 算法
算法混淆服务端用 RSA 验签,攻击者改用 HMAC 算法并拿公钥当密钥签名明确限定 JWS 算法列表,只允许 RS256
弱密钥爆破HS256 使用弱口令,暴力破解出签名密钥对称密钥至少 256 位随机;生产优先 RSA
过期时间绕过篡改 exp 为未来时间验签同时严格校验 exp、nbf、iat
KID 注入通过 kid 字段做路径穿越读取本地文件白名单校验 kid,不从用户输入拼接路径
Claims 过度信任直接拿 JWT 里的角色做权限判断关键权限变更走数据库,JWT 只做身份标识

alg=none这个攻击现在新版本的 Nimbus 默认就拒绝了,但如果你用的是老库或者自己手写了 JWT 解析逻辑,一定要检查是否允许无签名 Token 通过。算法混淆攻击稍微隐蔽一点:如果授权服务器用 RSA 私钥签名,但资源服务器的解码器配置里同时允许 HS256,攻击者可以用公开的 RSA 公钥作为 HMAC 密钥对 Token 重新签名,资源服务器验签时拿公钥当 HMAC 密钥一比对,发现签名"匹配",就放行了。防止方法就是显式限定算法白名单。

6.2 默认密钥风险:Nacos 事件给所有人的警示

前阵子 CNVD 有一起因为默认密钥绕过身份认证的漏洞事件,涉及 Nacos。攻击者可以利用默认的 JWT 密钥伪造任意用户身份的 Token,直接拿到管理权限。这类漏洞的本质原因就一条:生产环境用了代码里面写死的默认密钥。

在 JWT 方案的审计中,我每次都会检查三样东西:第一,公私钥是不是项目初始化时一次生成然后固化下来的;第二,私钥有没有进过 Git 仓库(哪怕后来删了,历史提交里还有);第三,有没有用于测试的通用密钥被带到生产环境。这三条只要中一条,整个认证体系就等于对攻击者敞开大门。

密钥管理的正确姿势是:私钥通过环境变量或者配置中心注入,部署环境之间彼此隔离。至少要做到不同环境(开发、测试、生产)用不同的密钥对。有条件的话用云厂商的 KMS 服务托管私钥,应用启动时拉取到内存,磁盘上不留明文。这里再强调一次:RSA 密钥对要提前生成、妥善保存,而不是每次启动项目时临时创建——这个坑我在 3.2 节提过,但重复多少遍都不嫌多,因为它引发的故障(所有用户突然掉线)非常难排查。

6.3 防重放与 JWT 时效控制

JWT 本身没有一个内置的"一次性使用"机制,同一个 Token 在有效期内可以无数次使用,这是它的设计使然。但某些高风险接口(转账、改密)不希望同样的请求被重放攻击,这时一般的做法是引入jti一次性校验:Redis 里记录已使用过的jti,业务接口校验时发现该jti已存在则拒绝。因为jti是每个 Token 唯一的,这个方案实现简单且可靠。

时效控制方面,除了标准的exp,我建议签发时把iat(签发时间)和nbf(生效时间)也带上。nbf主要用来防"未来 Token"——虽然正常流程不会出现,但如果签发服务器的时钟有问题,或者攻击者拿到了尚未生效的令牌,nbf能挡住一部分边界场景。还有一个小细节:所有认证相关的时间字段统一使用 UTC 时间,避免服务器时区配置不一致导致过期时间判断出错——这类问题特别阴间,表现为"有时候能登录有时候不能",查半天才发现是时区差了一个小时。

7. 排查实战与经验教训实录

7.1 401 和 403 的定位思路

接入这套方案后,最常见的报错就是 401 Unauthorized 和 403 Forbidden。很多新手分不清这俩的区别:401 是"你没证明你是谁",Token 缺失、过期、签名错误都属于这一类;403 是"我知道你是谁,但你没权限",Token 有效但角色不够。所以遇到 403 先别怀疑 Token 有问题,重点检查 JWT 里解析出的权限集合和requestMatchers/@PreAuthorize里的要求是否匹配。

我排障时通常会先抓一次完整请求链路:带上 Token 请求受保护接口,在前端看响应头里的WWW-Authenticate字段。如果是Bearer error="invalid_token",那就是验签环节挂了,去查公钥配置;如果响应头正常但业务代码里抛了AccessDeniedException,那就是权限不足,重点看转换器有没有把角色正确解析出来。这套排查顺序能省掉至少一半的瞎猜时间。

7.2 Security 6 迁移期的高频报错

这里把我在 Boot 2 升 Boot 3 过程中遇到的高频报错和解决方法整理成一张速查表:

报错信息原因解决方案
Cannot construct instance of SecurityFilterChain配置类里同时声明了多个 SecurityFilterChain 但没有顺序用 @Order 注解明确各链的顺序
No AuthenticationProvider found for JwtAuthenticationToken缺少 JWT Decoder 配置在 oauth2ResourceServer 里配置 jwt()
antMatchers() is deprecated使用了 Security 5 的 API全部替换为 requestMatchers()
Invalid signature公钥跟私钥不匹配或密钥轮换未同步对比 JWKS 端点和本地公钥,确认密钥一致
Access to this resource is forbiddenCSRF 拦截了 token 端点对 /oauth2/token 关闭 CSRF
The client is not authorized to request a tokengrant_type 与客户端配置不符检查 RegisteredClient 里授权的 grantType

还有一个容易忽略的配置项:如果你把授权服务器和资源服务器放在同一个应用里,两条SecurityFilterChain必须有@Order声明,并且通过securityMatcher区分路径。securityMatcher是 Security 6 推荐的做法,它决定了这条过滤链接管哪些 URL。授权服务器的链第一个执行,资源服务器的链第二个执行,其他请求走默认安全配置,顺序乱了就会出现各种莫名其妙的互相拦截。

7.3 实战中的性能与监控经验

最后聊几个性能优化的点。第一,资源服务器的 JWT 验签是 CPU 密集型操作(RSA 2048 验签大约需要 0.1ms 到 0.5ms),高并发下对单机 QPS 有一定影响。如果发现资源服务器 CPU 飙高,先查是不是每次请求都触发了公钥拉取——JWKS 缓存没生效的话,网络开销加解密开销会双重打击性能。

第二,JWKS 缓存的刷新策略要调好。默认每 5 分钟刷新一次,如果密钥轮换频繁,可以把刷新间隔调短,但代价是资源服务器会频繁请求授权服务器的 JWKS 端点,注意权衡。我自己通常是固定 5 分钟,密钥轮换安排在业务低峰期,轮换完成后手动触发一次资源服务器的缓存清理。

第三,日志脱敏。排障时经常要打印 Token 或者请求头,JWT 全量打出来等于把凭证泄露到日志系统。我在项目里强制规定:日志里只能看到 Token 的前 20 个字符加省略号,或者只打印jti。这个习惯一开始可能觉得麻烦,但一旦日志系统被拖库或者运维误操作,你就知道它值多少钱了。

7.4 一个小技巧:本地调试时直接解码 JWT

调试 OAuth2 流程时,我强烈建议大家留一个本地解码的辅助接口或者直接用工具解码 JWT。因为 JWT 三段中 Header 和 Payload 只是 Base64URL 编码,没有加密,你可以直接复制到任何在线解析器或者手写几行代码解出来看内容:

echo "eyJzdWIiOiJkZW1vIn0" | base64 -d

实际开发中我更喜欢用 IDEA 插件或者 jq 脚本快速查看 Payload 里的 Claims,这样能立刻确认 Token 里到底带了哪些用户信息、过期时间对不对、权限集合对不对。很多"权限没生效"的问题,第一步都是先解码 Token 看看里面装了什么。如果 Token 里压根没有roles字段,那你写再多的@PreAuthorize也是白搭,问题根源在签发侧而不是校验侧。

回到最开始的话题,做认证方案最重要的是理解每一层配置在替你把什么关。OAuth2 负责把授权的流程标准化,JWT 负责把凭证做成可验签、自包含的格式,Security 6 把这些能力揉进了它的事件链里。这套组合上手有门槛,但一旦你理解了SecurityFilterChain的顺序、公钥私钥的分工、Token 生命周期的管理这三大主线,后续不管项目怎么扩张都能稳稳接住。最后再分享一个个人习惯:每次改造完认证链路,第一件事不是测正常流程,而是测异常流程——把 Token 篡改一位、伪造一个过期 Token、换一个不存在的客户端 ID,看系统是不是每个环节都拒绝得干脆利落。认证系统的价值不在正常的时候多顺畅,而在被攻击的时候挡得住。

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

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

立即咨询