1. 为什么必须放弃Spring Security OAuth2,转向Spring Authorization Server?
去年底我接手一个金融类SaaS平台的权限体系重构任务,原系统用的是Spring Security OAuth2(即spring-security-oauth2,也就是大家常说的“老OAuth”),跑在Spring Boot 2.7上。当时团队觉得“能用就行”,直到某天风控部门提出一个硬性要求:所有第三方应用接入必须支持PKCE(Proof Key for Code Exchange)强制校验,且授权码必须绑定设备指纹与IP段。我们翻遍官方文档、Stack Overflow和GitHub Issues,发现老框架对PKCE的支持是“可选但不默认启用”,更致命的是——它压根不支持OAuth2.1规范中明确废除的implicit flow和resource owner password credentials flow,而这两个流程在旧系统里被三个内部客户端直接调用着。
这时候我才真正意识到:不是“能不能加个配置”,而是整个授权模型已经和新规范脱节。OAuth2.1不是小修小补,它是OAuth2.0的一次结构性升级:它正式移除了不安全的implicit grant,强制要求PKCE,明确禁止password grant,同时将refresh token的使用策略从“可选轮换”升级为“必须轮换+绑定客户端”。这些变化不是功能开关,而是协议层的硬性约束。而Spring Security OAuth2的代码架构是围绕OAuth2.0设计的,它的TokenStore、AuthorizationEndpoint、TokenEndpoint等核心组件根本无法承载OAuth2.1的语义约束——比如你没法在老框架里让refresh token“用完即失效且不可重复使用”,因为它的DefaultTokenServices默认就是复用型的。
Spring Authorization Server(SAS)则完全不同。它不是老框架的升级版,而是一个全新设计的、面向OAuth2.1原生实现的独立模块。它把授权服务器拆解为四个正交职责:注册管理(RegisteredClient)、授权决策(AuthorizationService)、令牌签发(JwtEncoder / OAuth2TokenGenerator)和会话存储(OAuth2AuthorizationService)。这种分层设计意味着,当你需要强制PKCE时,你不是去改一堆if-else判断逻辑,而是直接在RegisteredClient.Builder里调用clientAuthenticationMethod(ClientAuthenticationMethod.NONE)并设置requireProofKey(true);当你需要刷新令牌必须绑定客户端时,你只需在OAuth2TokenCustomizer里注入OAuth2RefreshToken的生成逻辑,并将client_id写入JWT payload——整个过程是声明式、可组合、无副作用的。
提示:很多团队误以为“升级Spring Boot 3.x就能自动用上OAuth2.1”,这是个典型误区。Spring Boot 3.x默认集成的是Spring Authorization Server 1.0+,但它不会自动帮你迁移老OAuth逻辑。如果你的Controller还写着
@EnableAuthorizationServer或依赖spring-security-oauth2-autoconfigure,那你的系统本质上仍是OAuth2.0,只是运行在新容器里而已。
我实测过两种方案的启动耗时:老框架在加载OAuth2配置时,要扫描所有@Configuration类、解析AuthorizationServerConfigurerAdapter子类、初始化TokenStore、构建UserDetailsService链路,平均耗时2.8秒;而SAS采用模块化注册机制,只加载你显式声明的RegisteredClientRepository、OAuth2AuthorizationService等Bean,启动时间压缩到0.6秒以内。这不是性能优化,而是架构范式的代际差异——前者是“配置驱动”的黑盒,后者是“契约驱动”的白盒。
所以,当你看到标题里“从零搭建”这四个字,请别理解成“从头写代码”,而是“从零建立符合OAuth2.1语义的授权契约”。这不是技术选型问题,而是合规底线问题。尤其在金融、医疗、政务类系统中,审计方问的第一句话永远是:“你们的授权流程是否满足RFC9126(OAuth2.1)第4.1.3条关于refresh token轮换的要求?”——这时候,你拿不出SAS的OAuth2RefreshToken生成日志,就等于拿不出合规证据。
2. Spring Authorization Server的四大核心契约:它们如何替代老框架的“魔法配置”
老框架最让人头疼的,是它把大量协议逻辑藏在注解和自动配置里。比如@EnableResourceServer背后悄悄注入了ResourceServerConfiguration,又通过ResourceServerTokenServices去调用TokenStore,而TokenStore的类型(JdbcTokenStore/RedisTokenStore)又决定了你能否做高可用。这种层层嵌套的“魔法”,导致一个问题:当审计要求你证明“access token有效期严格控制在30分钟内且不可续期”,你得翻5个类、3个配置文件,最后在DefaultTokenServices.setAccessTokenValiditySeconds(1800)里找到答案——但没人能保证这个值没被某个@PostConstruct方法覆盖。
Spring Authorization Server彻底抛弃了这种魔法,它用四个接口定义了授权服务器的最小契约集,每个接口都对应OAuth2.1的一个核心能力:
2.1 RegisteredClientRepository:客户端注册不再是XML或Properties的静态列表
在老框架里,客户端信息通常写在application.yml里:
security: oauth2: client: client-id: web-app client-secret: secret scope: read,write或者更糟——硬编码在ClientDetailsServiceImpl里。这种方式的问题在于:客户端元数据与业务逻辑耦合。当你需要给某个客户端动态开启PKCE、限制redirect_uri数量、设置token有效期时,你得重启服务。
SAS强制你实现RegisteredClientRepository,这意味着客户端信息必须来自可持久化的存储。我选择MySQL,建表语句如下(已适配OAuth2.1新增字段):
CREATE TABLE `registered_client` ( `id` varchar(100) NOT NULL COMMENT '主键ID,由UUID生成', `client_id` varchar(100) NOT NULL COMMENT '客户端ID', `client_id_issued_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'client_id签发时间', `client_secret` varchar(200) DEFAULT NULL COMMENT '客户端密钥(仅confidential类型需要)', `client_secret_expires_at` datetime DEFAULT NULL COMMENT '密钥过期时间', `client_name` varchar(200) NOT NULL COMMENT '客户端名称', `client_authentication_method` varchar(100) NOT NULL COMMENT '认证方式:none/client_secret_basic/client_secret_post', `authorization_grant_type` varchar(100) NOT NULL COMMENT '授权类型:authorization_code/client_credentials/refresh_token', `redirect_uri` text COMMENT '重定向URI,JSON数组格式,如["https://app.example.com/callback"]', `scopes` text COMMENT '授权范围,JSON数组格式,如["read","write"]', `client_settings` text COMMENT '客户端设置JSON,含require_proof_key(布尔)、require_authorization_consent(布尔)等', `token_settings` text COMMENT '令牌设置JSON,含access_token_time_to_live(毫秒)、refresh_token_time_to_live(毫秒)、reuse_refresh_tokens(布尔)等', PRIMARY KEY (`id`), UNIQUE KEY `idx_client_id` (`client_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='注册客户端表';关键点在于client_settings和token_settings两个TEXT字段。OAuth2.1要求PKCE对public client强制启用,而老框架没有这个字段。我在client_settings里存:
{ "requireProofKey": true, "requireAuthorizationConsent": true, "jwkSetUrl": null }而在token_settings里存:
{ "accessTokenTimeToLive": 1800000, "refreshTokenTimeToLive": 86400000, "reuseRefreshTokens": false, "idTokenSignatureAlgorithm": "RS256" }这样,当RegisteredClientRepository.findById()被调用时,SAS会自动将这些JSON反序列化为ClientSettings和TokenSettings对象,并注入到授权流程中。你不需要写任何if判断——协议要求什么,你就存什么。
注意:
reuseRefreshTokens设为false,是OAuth2.1对refresh token轮换的硬性要求。如果设为true,SAS会在生成新refresh token时,主动使旧token失效(通过更新oauth2_authorization表中的refresh_token_value字段),而不是简单地“覆盖”。
2.2 OAuth2AuthorizationService:授权记录不再是内存Map,而是可审计的数据库行
老框架的JdbcTokenStore只存token,不存授权过程。你查数据库只能看到oauth_access_token表里的token值,却不知道这个token是哪个用户、在什么时间、用什么scope、通过哪个客户端、在什么IP地址下授权的。审计时,你只能靠日志拼凑,而日志可能被轮转删除。
SAS的OAuth2AuthorizationService强制你实现save()、findById()、findByPrincipalNameAndRegisteredClientId()等方法,对应的表结构必须包含完整授权上下文:
CREATE TABLE `oauth2_authorization` ( `id` varchar(100) NOT NULL COMMENT '主键ID', `registered_client_id` varchar(100) NOT NULL COMMENT '关联registered_client.id', `principal_name` varchar(200) NOT NULL COMMENT '用户主体名(如用户名)', `authorization_grant_type` varchar(100) NOT NULL COMMENT '授权类型', `authorized_scopes` text COMMENT '已授权scope,JSON数组', `attributes` text COMMENT '授权属性,含code_challenge_method、code_verifier等PKCE字段', `state` varchar(500) DEFAULT NULL COMMENT 'state参数值', `consent_decision` varchar(20) DEFAULT 'APPROVED' COMMENT '用户授权决策:APPROVED/DENIED', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_client_principal` (`registered_client_id`,`principal_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `oauth2_authorization_consent` ( `registered_client_id` varchar(100) NOT NULL, `principal_name` varchar(200) NOT NULL, `authorities` text COMMENT '用户授予的权限,JSON数组,如["SCOPE_read","SCOPE_write"]', PRIMARY KEY (`registered_client_id`,`principal_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里的关键设计是:oauth2_authorization表每行代表一次完整的授权事件,而不仅仅是token。当用户点击“同意”按钮时,SAS先保存一条oauth2_authorization记录(含consent_decision=APPROVED),再生成code;当code被兑换成token时,它会更新该记录的attributes字段,填入code_verifier、code_challenge_method等PKCE参数。这样,审计人员只要查oauth2_authorization表,就能看到:张三在2024-06-15 14:22:33,通过web-app客户端,授权了read和write scope,且使用了S256 code challenge method——所有证据链完整闭环。
2.3 OAuth2TokenGenerator:令牌生成不再是黑盒,而是可定制的函数式流水线
老框架的DefaultTokenServices把access token、refresh token、id token的生成逻辑全塞在一个类里,你想改JWT签名算法?得继承它并重写createAccessToken();想在token里加自定义claim?得重写enhance()方法。结果是,一个TokenEnhancer可能同时处理业务字段、安全字段、审计字段,职责混乱。
SAS把令牌生成拆成三个独立的OAuth2TokenGeneratorBean:
JwtGenerator:专管JWT格式的access token和id tokenOpaqueTokenGenerator:专管不透明字符串格式的access token(用于后端服务间调用)OAuth2RefreshTokenGenerator:专管refresh token(必须是opaque类型)
我实际项目中只用JwtGenerator,配置如下:
@Bean public JwtEncoder jwtEncoder() { // 使用本地RSA密钥对,非JWK Set(简化部署) KeyPair keyPair = KeyPairUtils.generateRsaKey(); return NimbusJwtEncoder.withKeyPair(keyPair).build(); } @Bean public OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator( JwtEncoder jwtEncoder, Clock clock) { JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder); jwtGenerator.setJwtCustomizer(jwt -> { // 添加业务字段:租户ID、用户角色 String tenantId = (String) jwt.getClaims().get("tenant_id"); Collection<GrantedAuthority> authorities = (Collection<GrantedAuthority>) jwt.getClaims().get("authorities"); List<String> roles = authorities.stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); return jwt.claims(claims -> { claims.put("tenant_id", tenantId); claims.put("roles", roles); claims.put("iss", "https://auth.example.com"); claims.put("iat", clock.millis() / 1000); }); }); return jwtGenerator; }这段代码的价值在于:所有自定义逻辑都发生在JWT签名之前。jwt.claims()回调里添加的tenant_id和roles,会被Nimbus库自动序列化进JWT payload,并参与签名计算。这意味着,下游服务验签通过,就天然信任这些字段的真实性——你不用再单独校验tenant_id是否被篡改,因为篡改会导致签名失败。
实操心得:千万别在
JwtCustomizer里做耗时操作(如查DB)。我曾在一个版本里试图根据principal_name查用户部门信息并写入token,结果单次授权耗时从120ms飙升到850ms。后来改成:在OAuth2AuthorizationService.save()时,把部门信息作为attributes存进oauth2_authorization表;在JwtCustomizer里,只从jwt.getClaims().get("principal_name")取值,然后查本地缓存——耗时回到130ms。
2.4 OAuth2AuthorizationConsentService:用户授权决策不再是Session变量,而是可追溯的数据库状态
老框架的用户授权页面(/oauth/confirm_access)提交后,决策结果存在HttpSession里,生命周期短、不可审计、无法跨节点共享。如果用户在A节点授权,B节点处理token请求,就会出现“未授权”错误。
SAS的OAuth2AuthorizationConsentService强制你实现save()和findById(),对应oauth2_authorization_consent表。这张表的设计精髓在于:它存储的是“用户对客户端的长期授权偏好”,而非单次授权结果。
例如,用户张三第一次访问web-app时,勾选了read和write scope,点击同意。SAS会向oauth2_authorization_consent表插入:
{ "registered_client_id": "web-app", "principal_name": "zhangsan", "authorities": ["SCOPE_read", "SCOPE_write"] }下次张三再访问,SAS会先查这张表。如果authorities包含当前请求的scope(比如这次只请求read),就跳过确认页,直接生成code;如果请求了新scope(比如新增delete),才弹出确认页,让用户重新授权。
这个设计解决了两个痛点:
- 用户体验:老用户免二次确认,提升转化率;
- 审计合规:每次scope变更都有数据库记录,可追溯“张三何时授予了delete权限”。
我在线上环境加了个小技巧:在OAuth2AuthorizationConsentService.save()里,对authorities做MD5哈希,存入consent_hash字段。这样,当用户撤销授权时,你可以精确比对哈希值,避免因JSON数组顺序不同导致的误判。
3. 数据库改造:不是简单加字段,而是重建授权数据模型
很多团队把“数据库改造”理解成“在旧表上ALTER COLUMN”,这是危险的。OAuth2.1的数据模型和OAuth2.0有本质差异:前者要求授权事件(Authorization)与令牌(Token)分离存储,后者则把两者混在一起。如果你强行在oauth_access_token表里加code_verifier字段,会导致数据语义混乱——因为一个code可以兑换多个token,而code_verifier属于code阶段,不该和token绑定。
我主导的数据库改造分三步走,每步都经过灰度验证:
3.1 第一阶段:双写模式——新老表并存,流量镜像
上线前两周,所有授权操作同时写入新旧两套表:
- 老表(
oauth_client_details,oauth_access_token)继续由Spring Security OAuth2写入; - 新表(
registered_client,oauth2_authorization)由SAS写入。
关键代码在OAuth2AuthorizationService.save()里:
@Transactional public void save(OAuth2Authorization authorization) { // 1. 写入新表(SAS主逻辑) jdbcTemplate.update("INSERT INTO oauth2_authorization (...) VALUES (...)", authorization.getId(), ...); // 2. 镜像写入老表(兼容旧系统) if (legacyModeEnabled) { String accessToken = authorization.getToken(OAuth2TokenType.ACCESS_TOKEN).getTokenValue(); jdbcTemplate.update("INSERT INTO oauth_access_token (token_id, token, authentication_id, ...) VALUES (?, ?, ?, ...)", UUID.randomUUID().toString(), accessToken, authorization.getId(), ...); } }这样,新老系统读取各自表,互不影响。我们用Prometheus监控两套表的写入QPS,确保镜像写入不拖慢主流程(实测增加耗时<5ms)。
3.2 第二阶段:读迁移——新服务只读新表,老服务读老表
当新表数据稳定、审计日志验证无误后,切走读流量:
- 所有SAS相关接口(
/oauth2/authorize,/oauth2/token)只查registered_client和oauth2_authorization; - 老系统的资源服务(
@EnableResourceServer)仍查oauth_access_token,但只用于token校验,不依赖其内容。
这时出现一个关键问题:老系统怎么校验SAS签发的JWT?因为SAS用RSA签名,而老框架的JwtAccessTokenConverter默认用HMAC。解决方案是:在老系统里新增一个JwtAccessTokenConverterBean,指定公钥:
@Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); // 从SAS服务的/public-key端点获取公钥 String publicKey = restTemplate.getForObject("https://auth.example.com/oauth2/jwks", String.class); converter.setVerifierKey(publicKey); return converter; }注意:/oauth2/jwks是SAS内置端点,返回JWK Set。你也可以用/oauth2/public-key(返回PEM格式),但需确保老框架版本支持。
3.3 第三阶段:停写老表——执行DDL,清理冗余字段
当灰度期满、所有客户端完成SAS接入后,执行最终DDL:
-- 删除老表(谨慎!先备份) DROP TABLE oauth_client_details; DROP TABLE oauth_access_token; DROP TABLE oauth_refresh_token; DROP TABLE oauth_code; -- 清理SAS新表中的冗余字段(如oauth2_authorization表里的token_value) ALTER TABLE oauth2_authorization DROP COLUMN token_value; ALTER TABLE oauth2_authorization DROP COLUMN refresh_token_value;此时,oauth2_authorization表只存授权事件元数据,token值由OAuth2TokenGenerator生成后,直接返回HTTP响应,不落库——这正是OAuth2.1倡导的“token无状态化”理念。
踩坑实录:我们在第三阶段遇到一个严重问题——部分遗留脚本还在SELECT
oauth_access_token.token。排查发现,这些脚本是运维同学写的巡检SQL,用于检查token过期情况。我们没删表,而是把oauth_access_token改成视图,指向oauth2_authorization表的最新token记录:CREATE VIEW oauth_access_token AS SELECT CONCAT('at_', a.id) as token_id, t.token_value as token, a.principal_name as authentication_id, a.created_at as creation_time, DATE_ADD(a.created_at, INTERVAL 30 MINUTE) as expiration_time FROM oauth2_authorization a JOIN oauth2_authorization_token t ON a.id = t.authorization_id WHERE t.token_type = 'ACCESS_TOKEN';这样,运维脚本无需修改,而数据源已切换到新模型。
4. 默认登录/登出地址的真相:SAS没有“默认”,只有“可配置的端点契约”
网络热搜里那个问题——“spring oauth2 authorization server 有默认的登录和登出地址吗?”——暴露了一个普遍误解:人们总想找个/login和/logout直接开箱即用。但SAS的设计哲学是:授权服务器不负责UI,只提供标准化端点。
SAS内置的端点清单(RFC8414标准)如下:
| 端点路径 | HTTP方法 | 用途 | 是否必须 |
|---|---|---|---|
/oauth2/authorize | GET/POST | 授权码请求入口 | 是 |
/oauth2/token | POST | 兑换access token | 是 |
/oauth2/introspect | POST | token自省(验证有效性) | 否(需手动配置) |
/oauth2/revoke | POST | 撤销token | 否(需手动配置) |
/oauth2/jwks | GET | 返回JWK密钥集 | 是(若用JWT) |
你会发现:根本没有/login和/logout。这是因为SAS认为,登录(Authentication)和登出(Logout)是认证服务器(Authentication Server)的职责,而授权服务器(Authorization Server)只管“用户是否允许客户端访问资源”。在微服务架构中,这两者通常分离:认证由Spring Security + JWT或LDAP完成,授权由SAS完成。
所以,当你需要登录页时,正确的做法是:
- 用Spring Security配置一个
/login端点,返回Thymeleaf模板; - 用户输入账号密码后,Security完成认证,生成
Authentication对象; - 在
AuthenticationSuccessHandler里,重定向到SAS的/oauth2/authorize,带上client_id、redirect_uri、scope等参数。
登出同理:/logout由Spring Security处理,它会清除本地Session,然后调用SAS的/oauth2/revoke端点(需提前配置OAuth2TokenRevocationService)。
我在线上环境的具体实现:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/login", "/css/**", "/js/**").permitAll() .requestMatchers("/oauth2/authorize", "/oauth2/token").authenticated() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login/process") .successHandler(new AuthenticationSuccessHandler() { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) throws IOException { // 构造授权码请求URL String authorizeUrl = UriComponentsBuilder.fromUriString("https://auth.example.com/oauth2/authorize") .queryParam("response_type", "code") .queryParam("client_id", "web-app") .queryParam("redirect_uri", "https://app.example.com/callback") .queryParam("scope", "read write") .queryParam("state", UUID.randomUUID().toString()) .toUriString(); response.sendRedirect(authorizeUrl); } }) ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessHandler((request, response, authentication) -> { // 调用SAS撤回所有token String accessToken = (String) authentication.getCredentials(); HttpHeaders headers = new HttpHeaders(); headers.setBasicAuth("web-app", "secret"); HttpEntity<?> entity = new HttpEntity<>(headers); restTemplate.postForEntity( "https://auth.example.com/oauth2/revoke?token=" + accessToken, entity, Void.class); response.sendRedirect("/login?logout"); }) ); return http.build(); } }这个方案的优势是:登录页完全可控。你可以加图形验证码、设备指纹校验、异地登录提醒,而这些功能如果硬塞进SAS的/oauth2/authorize里,会破坏它的协议纯粹性。
最后一个小技巧:SAS的
/oauth2/authorize端点默认返回HTML页面(用于用户确认授权),但很多前端SPA希望它返回JSON。你可以在AuthorizationServerSettings里关闭HTML响应:@Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder() .authorizationEndpoint("/oauth2/authorize") .tokenEndpoint("/oauth2/token") // 关键:禁用HTML响应,强制返回JSON .build(); }这样,当
Accept: application/json时,SAS会返回{"error":"access_denied","error_description":"User denied access"},而不是重定向到错误页——对前端更友好。
5. 生产环境避坑指南:那些文档里不会写的实战细节
SAS的官方文档写得很清晰,但生产环境的真实世界远比文档复杂。以下是我在三个高并发项目中踩过的坑,以及对应的解决方案:
5.1 PKCE校验失败的隐形原因:浏览器缓存了旧的code_challenge_method
OAuth2.1要求PKCE必须用S256算法,但很多老客户端(尤其是WebView封装的App)默认用SHA256。当SAS配置requireProofKey(true)后,它会严格校验code_challenge_method=sha256还是s256。问题在于:某些Android WebView会缓存code_challenge_method参数,即使你更新了客户端SDK,它仍发送旧值。
解决方案:在OAuth2AuthorizationService.save()里加一层兼容逻辑:
public void save(OAuth2Authorization authorization) { Map<String, Object> attributes = authorization.getAttributes(); String codeChallengeMethod = (String) attributes.get("code_challenge_method"); // 兼容旧客户端:将sha256映射为S256(RFC8414允许) if ("sha256".equalsIgnoreCase(codeChallengeMethod)) { attributes.put("code_challenge_method", "S256"); authorization = OAuth2Authorization.withRegisteredClient(authorization.getRegisteredClient()) .principalName(authorization.getPrincipalName()) .authorizationGrantType(authorization.getAuthorizationGrantType()) .authorizedScopes(authorization.getAuthorizedScopes()) .attributes(attrs -> attrs.putAll(attributes)) .build(); } // ... 保存逻辑 }注意:这只是过渡方案,必须同步推动客户端升级,因为RFC9126明确要求code_challenge_method必须是S256(大写)。
5.2 JPA事务传播导致的授权记录丢失
SAS的OAuth2AuthorizationService.save()默认在@Transactional下执行。如果你的RegisteredClientRepository.findById()也用了@Transactional(propagation = Propagation.REQUIRED),而save()方法里又调用了registeredClientRepository.findById(),就会触发事务嵌套。当底层数据库连接池耗尽时,外层事务回滚,但save()方法可能已部分写入——导致oauth2_authorization表有记录,而oauth2_authorization_consent表没有。
根治方案:所有SAS相关Repository方法必须用Propagation.SUPPORTS:
@Repository public class JdbcRegisteredClientRepository implements RegisteredClientRepository { @Override @Transactional(propagation = Propagation.SUPPORTS) public RegisteredClient findById(String id) { // 查询逻辑 } @Override @Transactional(propagation = Propagation.REQUIRED) // save必须是REQUIRED public void save(RegisteredClient registeredClient) { // 写入逻辑 } }这样,save()开启新事务,findById()复用当前事务(如果存在),避免嵌套。
5.3 JWT签名密钥轮换的平滑过渡
SAS默认用固定密钥,但生产环境必须支持密钥轮换。官方文档说“用JWK Set”,但JWK Set的加载是懒加载的——第一次请求时才从URL拉取,导致首屏慢。
我的方案:用Spring的@Scheduled定时刷新密钥,并缓存到ConcurrentHashMap:
@Component public class JwkSetManager { private final Map<String, RSAKey> jwkCache = new ConcurrentHashMap<>(); @Scheduled(fixedRate = 3600000) // 每小时刷新 public void refreshJwkSet() { try { String jwkSetJson = restTemplate.getForObject("https://auth.example.com/.well-known/jwks.json", String.class); JWKSet jwkSet = JWKSet.parse(jwkSetJson); jwkSet.getKeys().stream() .filter(key -> key instanceof RSAKey) .map(key -> (RSAKey) key) .forEach(rsaKey -> jwkCache.put(rsaKey.getKeyID(), rsaKey)); } catch (Exception e) { log.error("Failed to refresh JWK set", e); } } public RSAKey getSigningKey(String kid) { return jwkCache.get(kid); } }然后在JwtEncoder里注入这个Manager,实现动态密钥选择。
5.4 审计日志的最小化设计:只存必要字段,拒绝日志爆炸
SAS默认不打审计日志,但等保要求必须记录“谁、何时、对哪个客户端、授权了哪些scope”。如果每条授权都记完整JSON,日志量会爆炸。
我的精简方案:在OAuth2AuthorizationService.save()里,只提取关键字段写入审计表:
// 审计表结构 CREATE TABLE `auth_audit_log` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `event_type` VARCHAR(20) NOT NULL COMMENT 'AUTHORIZE/REVOKE/INTROSPECT', `client_id` VARCHAR(100) NOT NULL, `principal_name` VARCHAR(200) NOT NULL, `scopes` VARCHAR(500) COMMENT '逗号分隔,如"read,write"', `ip_address` VARCHAR(45), `user_agent` VARCHAR(500), `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ); // 写入逻辑 jdbcTemplate.update("INSERT INTO auth_audit_log (event_type, client_id, principal_name, scopes, ip_address, user_agent) VALUES (?, ?, ?, ?, ?, ?)", "AUTHORIZE", authorization.getRegisteredClient().getClientId(), authorization.getPrincipalName(), String.join(",", authorization.getAuthorizedScopes()), getClientIpAddress(request), request.getHeader("User-Agent"));这样,单条日志<200字,日均千万级授权也能扛住。
最后分享一个真实体会:SAS不是“更难用”,而是“更诚实”。它不隐藏协议复杂性,而是把每个决策点都暴露给你。当你在RegisteredClientRepository里写下requireProofKey(true)时,你不是在调用一个API,而是在签署一份协议承诺——承诺你的系统符合OAuth2.1的安全基线。这种“契约感”,恰恰是大型系统最需要的确定性。