ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OAuth2 + JWT 认证授权实战:Spring Security 6 最佳实践

OAuth2 + JWT 认证授权实战:Spring Security 6 最佳实践 做后端接口认证这件事我从最早的 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 6WebSecurityConfigurerAdapter被彻底移除这套写法整个作废。迁移到 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() DSLJDK 要求8/11171.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.xdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-resource-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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 JWKSourceSecurityContext jwkSource() throws Exception { RSAKey rsaKey RSAKey.parse( new KeyStore(keys).getKey(auth-private) ); // 生产环境应使用 KeyStore 加载此处简化为读取文件 return new ImmutableJWKSet(new JWKSet(rsaKey)); } Bean public JwtDecoder jwtDecoder(JWKSourceSecurityContext 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 OAuth2TokenCustomizerJwtEncodingContext { 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 拆开看结构其实是三段 Base64URLeyJhbGciOiJSUzI1NiIsImtpZCI6ImF1dGgta2V5In0 .eyJzdWIiOiJkZW1vLXVzZXIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3MzAwMDAwMDAsImlhdCI6MTczMDAwMDAwMCwianRpIjoiYWIxMjNlZiJ9 .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c三段分别对应 Header、Payload、Signature。Header 里声明签名算法RS256和密钥 IDkidPayload 里是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 ConverterJwt, AbstractAuthenticationToken { Override public AbstractAuthenticationToken convert(Jwt jwt) { CollectionGrantedAuthority 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 能调哪些 APIrole 是这个用户是什么角色。拿支付系统举例一个财务角色的用户可能通过不同 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; } }这个方案有一个明显代价资源服务器每次请求都要查一次 RedisJWT 的无状态优势打了折扣。但工程上这是值得的——注销、改密、封号这些高频安全场景必须有即时生效手段多一跳 Redis 的延迟通常在 1ms 以内完全可以接受。如果对这个延迟都不满意还有一种纯无状态方案在 JWT 里加一个token_version字段用户注销时把版本号加一资源服务器通过配置中心拿到最新版本号做比对。缺点是版本号本身的更新和分发又引入了新的状态适合没有 Redis 的小项目。5.3 多端登录与并发场景的 Token 策略实际项目里用户可能同时登录 Web 和 App甚至同一账号多个设备。这个场景下的 Token 管理要提前想清楚。比较常用的是为每个客户端注册一个独立的会话登录成功后为该设备生成独立的jtiRedis 里维护user:{uid}:devices集合存哪些 jti 还活着。实现单端登录时登录新设备后把该用户其他设备的 jti 加入黑名单。实现踢人下线时管理员操作时直接根据用户 ID 查出所有活跃 jti批量拉黑。这里最核心的一句话是别试图用一个 Token 管到所有端。为每个端分别签发、分别跟踪后续做设备管理、安全风控才有抓手。第 3.3 节的自定义 Claims 里建议加上client_id或者设备标识这样资源服务器能知道当前请求来自哪个端做差异化业务逻辑比如移动端的接口返回更精简的数据。6. 安全加固与常见漏洞规避6.1 JWT 漏洞全景从算法混淆到密钥爆破聊完功能必须聊安全。JWT 相关漏洞这几年在各大漏洞库刷屏的次数非常多我把最常见的几类整理成速查表对照检查自己的实现漏洞类型攻击方式防护措施algnone 绕过把 Header 中算法改成 none去掉签名解码器必须校验签名禁止 none 算法算法混淆服务端用 RSA 验签攻击者改用 HMAC 算法并拿公钥当密钥签名明确限定 JWS 算法列表只允许 RS256弱密钥爆破HS256 使用弱口令暴力破解出签名密钥对称密钥至少 256 位随机生产优先 RSA过期时间绕过篡改 exp 为未来时间验签同时严格校验 exp、nbf、iatKID 注入通过 kid 字段做路径穿越读取本地文件白名单校验 kid不从用户输入拼接路径Claims 过度信任直接拿 JWT 里的角色做权限判断关键权限变更走数据库JWT 只做身份标识algnone这个攻击现在新版本的 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 errorinvalid_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 关闭 CSRFThe 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看系统是不是每个环节都拒绝得干脆利落。认证系统的价值不在正常的时候多顺畅而在被攻击的时候挡得住。
返回列表