
你有没有遇到过这种场景项目里已经用了Spring Security OAuth2搭好了授权服务器默认的password模式、client_credentials模式也都跑通了结果产品突然提了个需求——”登录的时候除了密码还要带一个动态验证码而且这个验证码要跟账号绑定同一个账号五分钟内只能发三次“。你一看默认的password模式压根没地方塞这个验证码更没地方做这个频次控制。改框架不现实。绕过去更麻烦。这时候最正经的路子就是基于Spring Security OAuth2去实现一个自定义授权模式把TokenEndpoint的入口拿过来自己定义一套grant_type自己写校验逻辑自己控制整个Token的签发过程。这篇文章就把这件事从头到尾拆开来讲。我会先解释为什么默认模式满足不了真实业务再说清楚授权服务器里那几个核心组件到底是怎么协作的然后给出一份可以直接照着改的代码实现最后把我在实际项目中踩过的坑和排查链路也一并列出来。不管你是刚接触OAuth2的后端新人还是已经在用Spring Security但没动过授权模式的老手照着这篇文章的思路走一遍基本就能在自己的项目里落地一个自定义授权模式。1. 为什么默认的password模式搞不定真实业务很多团队一开始选Spring Security OAuth2就是冲着它开箱即用。官方文档里写了五种授权模式authorization_code、implicit、password、client_credentials、refresh_token。大部分内部系统或者前后端分离的项目直接拿password模式顶上用户名密码一换Token完事。但用着用着就会发现password模式有个很尴尬的边界它只认用户名和密码TokenEndpoint的请求参数基本是锁死的。你要想在密码之外再加一个验证码字段、一个设备指纹、一个短信签名默认的TokenGranter会在解析参数的时候直接把多余的东西忽略掉或者更准确地说它压根不会把那些参数传到你的校验逻辑里。我举个具体例子。之前做一个运营后台的登录改造安全团队要求必须支持”密码谷歌验证码“的双因子登录而且同一个账号连续输错五次验证码就要锁半小时。我一开始想走扩展UserDetailsService那条路把验证码校验塞到DaoAuthenticationProvider里结果发现一个问题默认的password模式走的是DaoAuthenticationProvider它拿到的Authentication对象里只有username、password这两个关键信息我根本没有干净的办法把验证码参数从HTTP请求里传进来。还有一种情况是client_credentials模式它默认只认client_id和client_secret适合机器对机器的调用但它压根没有用户实体的概念。而很多内部系统的服务间调用又希望能在Token里带一个操作者身份、一个租户ID方便下游服务做数据权限过滤。这些需求默认模式都给不了。所以自定义授权模式的核心动机其实很简单默认模式的参数模型和校验流程跟真实业务对不上。我们需要的不是换一个UserDetailsService也不是改一个Provider而是重新定制一条从”HTTP请求参数“到”Token签发“的完整链路。这也是OAuth2框架聪明的设计之处它把授权模式这条链路抽象成了TokenGranter接口只要你愿意完全可以插一个自己的实现进去而不是去改框架源码。2. 授权服务器里那几个核心组件是怎么协作的在动手写代码之前必须先把授权服务器的内部协作逻辑搞清楚。很多网上教程一上来就贴代码结果读者照着抄完换个场景就不知道怎么改了就是因为没搞明白这块。Spring Security OAuth2的授权服务器Token签发这条链路大致是这样一个流程HTTP请求打到/oauth/token端点这个端点由TokenEndpoint处理。TokenEndpoint根据请求里的grant_type参数从TokenGranter里找到对应的授权模式实现。TokenGranter负责把HTTP请求参数组装成一个OAuth2Request再结合客户端的身份信息交给AuthenticationManager去做用户凭证校验。AuthenticationManager会遍历所有已注册的AuthenticationProvider找到能处理当前Authentication类型的那个去执行真正的校验逻辑。校验通过以后TokenGranter会调用AuthorizationServerTokenServices去生成Token、刷新Token最后返回给调用方。这里面最容易被忽略的是第3步和第4步之间的配合。TokenGranter构建的其实是一个OAuth2Request和一个Authentication对象前者代表”客户端要申请的Scope和资源“后者代表”用户提交的凭证信息“。到了AuthenticationManager那里它看到的已经不是一个普通的用户名密码了而是一个你自定义的Authentication类型——前提是你在TokenGranter里自己构建了这个类型。我打个比方。默认的ResourceOwnerPasswordTokenGranter就像一个只收现金的柜台你把用户名密码递过去他给你一张Token。现在你要做的是一个支持”现金会员卡“的柜台你不能在原来的柜台上贴张纸条说”我支持会员卡“你得自己开一个柜台自定义TokenGranter自己定一套收银规则自定义AuthenticationProvider然后告诉整个商场授权服务器”有这个特殊请求的客户请到我这个柜台办理“。TokenGranter的注册机制也有讲究。AuthorizationServerEndpointsConfigurer里有一个tokenGranter属性你一旦手动set了一个组合的TokenGranter框架就不会再去加载那些默认实现。这就意味着如果你只set了一个自定义的granter默认的password模式、client_credentials模式全部会失效。所以实际项目中正确做法是用CompositeTokenGranter把默认实现和自定义实现组合起来按顺序匹配grant_type。再往下说一个完整的授权服务器离不开两个存储层。一个是ClientDetailsService负责按client_id加载客户端应用的配置另一个是AuthorizationServerTokenServices负责Token的存储、刷新和撤销。这两块如果你的项目里还没有那建议先把基础的数据表设计好否则自定义模式跑起来之后你会发现客户端信息、Token信息根本没地方放。3. 动手实现自定义TokenGranter与AuthenticationProvider现在开始进入正题。这里我以扩展password模式为例实现一个支持”密码动态验证码“的自定义授权模式grant_type定为custom_password。这个例子的意义在于它既保留了原有用户名密码校验的能力又额外加入了你自己的业务校验而且整个流程对调用方来说就是一个普通的OAuth2 Token请求。3.1 第一步先搭好基础的数据表与客户端配置自定义模式依赖的数据库表其实和标准OAuth2用到的基本一样。我一般建这么几张核心表oauth_client_details存客户端的client_id、client_secret、授权模式、scope、access_token有效期等。oauth_access_token存已签发的Token序列化之后以BLOB或者JSON的形式存储。oauth_refresh_token存刷新Token可选。sys_user业务系统的用户表存用户名、密码BCrypt加密以及状态字段。如果你的项目里已经有现成的用户体系那sys_user不用动只需要补两张OAuth2的表。这里有一个细节oauth_client_details表的authorized_grant_types字段里一定要把你自定义的这个custom_password加进去否则TokenEndpoint在第一步校验客户端请求的时候就会直接拒绝。3.2 第二步实现自定义TokenGranter自定义授权模式最核心的类就是TokenGranter的实现。我直接继承AbstractTokenGranter这样可以复用父类里关于客户端加载、Token生成这一大坨逻辑只专注于构建请求和凭证public class CustomPasswordTokenGranter extends AbstractTokenGranter { private static final String GRANT_TYPE custom_password; private final AuthenticationManager authenticationManager; public CustomPasswordTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) { super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE); this.authenticationManager authenticationManager; } Override protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) { MapString, String parameters new HashMap(tokenRequest.getRequestParameters()); String username parameters.get(username); String password parameters.get(password); String verifyCode parameters.get(verifyCode); // 这里可以先做参数完整性校验避免空指针异常打到后面 if (username null || password null || verifyCode null) { throw new InvalidRequestException(username, password and verifyCode are required); } // 构建自定义的认证凭证把验证码等信息一并塞进去 CustomVerifyCodeAuthenticationToken userAuth new CustomVerifyCodeAuthenticationToken( username, password, verifyCode); try { Authentication authentication authenticationManager.authenticate(userAuth); return new OAuth2Authentication(tokenRequest.createOAuth2Request(client), authentication); } catch (Exception e) { throw new InvalidGrantException(认证失败: e.getMessage()); } } }你看AbstractTokenGranter帮我们处理了grant_type匹配、客户端加载、Token创建这一整套流程。我们只需要覆写getOAuth2Authentication方法把参数取出来组装成一个自定义的Authentication对象交给AuthenticationManager去处理。这里有个很关键的点OAuth2RequestFactory.createOAuth2Request会把TokenRequest里的参数原样透传下去所以在自定义的AuthenticationProvider里你还能拿到scope、client_id这些上下文信息。如果你要在校验逻辑里用到这些一定不要自己手动组装OAuth2Request直接复用tokenRequest.createOAuth2Request(client)就好。3.3 第三步自定义AuthenticationToken与AuthenticationProviderCustomVerifyCodeAuthenticationToken这个类承载的是用户提交的一组待验证凭证。它跟UsernamePasswordAuthenticationToken的结构类似但多了一个verifyCode字段并且authenticated属性初始为falsepublic class CustomVerifyCodeAuthenticationToken extends AbstractAuthenticationToken { private final Object principal; private Object credentials; private final String verifyCode; public CustomVerifyCodeAuthenticationToken(String username, String password, String verifyCode) { super(null); this.principal username; this.credentials password; this.verifyCode verifyCode; setAuthenticated(false); } // getter省略 Override public Object getCredentials() { return credentials; } }接着写AuthenticationProvider。这个类的任务是把用户提交的凭证和数据库里的真实数据做比对比对通过以后返回一个authenticatedtrue的Authentication对象public class CustomPasswordAuthenticationProvider implements AuthenticationProvider { private final UserDetailsService userDetailsService; private final PasswordEncoder passwordEncoder; private final VerifyCodeService verifyCodeService; Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username authentication.getName(); String password (String) authentication.getCredentials(); String verifyCode ((CustomVerifyCodeAuthenticationToken) authentication).getVerifyCode(); // 1. 加载用户信息 UserDetails userDetails userDetailsService.loadUserByUsername(username); if (userDetails null) { throw new BadCredentialsException(用户不存在); } // 2. 密码校验 if (!passwordEncoder.matches(password, userDetails.getPassword())) { throw new BadCredentialsException(密码错误); } // 3. 验证码校验 if (!verifyCodeService.validateVerifyCode(username, verifyCode)) { throw new BadCredentialsException(验证码错误或已过期); } // 4. 校验通过返回已认证的Authentication UsernamePasswordAuthenticationToken result new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); return result; } Override public boolean supports(Class? authentication) { return CustomVerifyCodeAuthenticationToken.class.isAssignableFrom(authentication); } }这里有一个非常容易被忽略的细节返回的Authentication对象它的principal应该是一个完整的UserDetails而不是只有用户名。因为Token签发之后之后的所有请求都要靠这个principal去识别当前用户。如果你只放了一个字符串用户名后面接口里获取当前登录用户信息的时候就会缺胳膊少腿。supports方法也很关键它决定了AuthenticationManager会不会把这个Provider用在你的自定义Token类型上。千万不要省略否则框架会报”No AuthenticationProvider found for ...“。3.4 第四步把组装好的Bean注册到授权服务器写完了核心类接下来就是组装。这个组装过程如果出错不是启动报错就是请求404所以必须耐心核对。Configuration EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { // 注入 AuthenticationManager、ClientDetailsService、TokenStore 等 Autowired private AuthenticationManager authenticationManager; Autowired private ClientDetailsService clientDetailsService; Autowired private TokenStore tokenStore; Autowired private UserDetailsService userDetailsService; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public VerifyCodeService verifyCodeService() { return new RedisVerifyCodeService(); } Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { // 1. 先创建自定义的 AuthenticationProvider CustomPasswordAuthenticationProvider provider new CustomPasswordAuthenticationProvider( userDetailsService, passwordEncoder(), verifyCodeService()); endpoints.authenticationManager(authenticationManager) .tokenStore(tokenStore) .tokenGranter(tokenGranter(endpoints)); // 2. 把自定义 provider 注册进 AuthenticationManager // 注意这里不是简单 set而是把 provider 加进已有的管理器 if (authenticationManager instanceof ProviderManager) { ((ProviderManager) authenticationManager).getProviders().add(provider); } } private TokenGranter tokenGranter(AuthorizationServerEndpointsConfigurer endpoints) { // 拿到默认的 TokenGranter TokenGranter defaultTokenGranter new CompositeTokenGranter( Arrays.asList( new ResourceOwnerPasswordTokenGranter( authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory()), new ClientCredentialsTokenGranter( authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory()), new RefreshTokenGranter( authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory()) ) ); // 把我们自定义的 granter 和默认的组合在一起 return new CompositeTokenGranter( Arrays.asList( new CustomPasswordTokenGranter( authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory()), defaultTokenGranter ) ); } }光看这段代码可能会觉得复杂但核心逻辑其实就一句话把默认的三种granter和自定义的granter全部塞进一个CompositeTokenGranter框架会根据grant_type自动路由到对应的实现上。这里再提醒一下如果你用的是Spring Boot 2.xAuthenticationManager本身是Spring Security管的上面的ProviderManager.getProviders().add(...)操作是在运行时往Manager里追加自定义Provider。这种方法实测可行但从架构上说不算很优雅。更稳妥的方式是拿一个单独的AuthenticationManager专门处理这个自定义的Provider然后把这个定制版manager传给CustomPasswordTokenGranter——这样不会影响到其他模块的认证流程。3.5 第五步测试调用配置完成之后启动项目调用方只需要向/oauth/token发送一个POST请求POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_typecustom_passwordusernameadminpassword123456verifyCode888888client_idmy-clientclient_secretmy-secret如果一切正常返回的JSON就是一个标准的OAuth2 Token响应{ access_token: xxxx, token_type: bearer, refresh_token: yyyy, expires_in: 43199, scope: read write }到这个阶段一个最小可用的自定义授权模式就已经跑通了。4. 让客户端应用从数据库动态加载上面那个版本客户端信息是写死在InMemoryClientDetailsService里的。真实项目里客户端应用往往是要动态接入的不能每接一个新应用就改一次代码、重启一次服务。这里我展开讲一下基于数据库的ClientDetailsService改造这也是自定义授权模式在落地时绕不开的一步。4.1 表结构设计我推荐直接用一张oauth_client_details表字段尽可能对齐Spring的BaseClientDetails这样实现JdbcClientDetailsService的时候几乎不需要额外转换逻辑。核心字段如下字段名类型说明client_idvarchar(64)客户端唯一标识主键client_secretvarchar(256)BCrypt加密后的客户端密钥resource_idsvarchar(256)可访问的资源ID没有就空scopevarchar(256)授权范围逗号分隔authorized_grant_typesvarchar(256)授权模式逗号分隔必须包含custom_passwordweb_server_redirect_urivarchar(256)回调地址授权码模式用authoritiesvarchar(256)客户端权限access_token_validityintToken有效期单位秒refresh_token_validityint刷新Token有效期单位秒additional_informationvarchar(4096)额外配置JSON格式autoapprovevarchar(256)自动授权范围这里要特别注意additional_information字段。自定义授权模式里我经常会把一些业务参数放进去比如allowed_origins、token_expire_multiplier之类的然后在ClientDetails的getAdditionalInformation()里读取。Spring的JdbcClientDetailsService会把它解析成一个Map非常方便。4.2 动态加载的实现方式Spring Security OAuth2里提供了一个现成的JdbcClientDetailsService直接new出来用就行。但问题在于这个类每次从数据库加载客户端信息之后是没有任何缓存的。如果你的授权服务器接口量很大每次都查一次库性能上不划算。我的做法是包一层缓存但要注意一个坑一旦客户端信息在数据库里被修改缓存必须及时失效。我采用的是本地缓存加一个简单的失效接口管理员在后台改完client配置之后手动调用一次刷新接口。如果你用的Redis那可以做得更轻一点——数据变更的时候发一条消息所有授权服务器节点都清掉对应的client缓存。Service public class CacheableClientDetailsService implements ClientDetailsService { Autowired private JdbcClientDetailsService jdbcClientDetailsService; private final MapString, ClientDetails cache new ConcurrentHashMap(); Override public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException { return cache.computeIfAbsent(clientId, id - { ClientDetails details jdbcClientDetailsService.loadClientByClientId(id); if (details ! null) { return details; } throw new ClientRegistrationException(客户端不存在: id); }); } public void evictCache(String clientId) { cache.remove(clientId); } }4.3 客户端密钥的加密问题说到client_secret很多第一次接触的人会在这一步踩坑。oauth_client_details表里的client_secret存的必须是BCrypt加密后的结果而不是明文。因为ClientDetailsUserDetailsService在验证客户端身份的时候会用PasswordEncoder去做matches校验。通常你可以在启动时写一个CommandLineRunner或者直接在测试类里生成加密串public class GenerateSecret { public static void main(String[] args) { BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); System.out.println(encoder.encode(my-secret)); } }然后把生成的密文存到数据库里。请求的时候调用方还是传明文client_secretSpring Security会在后台自动比对加密后的密文。5. 实测中踩过的坑排查链路复盘自定义模式跑通之后真正痛苦的阶段才开始。这里我把我实际项目中踩过的一些坑整理出来每个都附上排查思路希望能帮你少走弯路。5.1 坑一grant_type匹配不上请求直接403这个是我最常遇到的问题症状是调用/oauth/token接口返回403而且日志里什么都没有。从头排查HTTP请求开始我做了这几步确认grant_typecustom_password确实传了而且拼写完全一致。OAuth2框架对grant_type是精准匹配多一个空格都不行。确认oauth_client_details表里的authorized_grant_types字段包含了custom_password。查数据库是最直接的方式如果该字段只写了password,client_credentials框架会在最外层的ClientDetails校验阶段就把请求拦截掉。确认TokenGranter的grantType常量跟请求参数完全一致。我在调试中经常把grantType传给AbstractTokenGranter时引用的常量是custom_password而调用方传的是custom-password这种低级错误浪费了大半天时间。排查链路总结下来就是先查客户端配置再查TokenGranter注册最后查请求参数。这三层从外到内一层一层卡。5.2 坑二AuthenticationProvider没有生效报”Unable to authenticate“这个错误最让人崩溃的是代码明明写了Provider也注册了但还是报错。我当时的排查链路是这样的第一步确认CustomVerifyCodeAuthenticationToken的supports方法返回true。因为AuthenticationManager是遍历所有Provider的它靠supports来决定用哪个Provider处理如果这个方法返回false你的Provider就是透明的。第二步确认authenticationManager.authenticate()执行的时候当前线程拿到的确实是同一个AuthenticationManager实例。这里有个非常细的坑如果你在AuthorizationServerConfig里注入了AuthenticationManager又通过构造器注入到CustomPasswordTokenGranter里那这两个地方必须是同一个Bean。Spring Boot的AuthenticationConfiguration默认会暴露一个全局的AuthenticationManager但如果你的配置里手动new了一个就会导致两个不同的Manager自定义Provider注册错了地方。第三步确认Provider的authenticate方法里没有提前抛异常。我是通过加日志来确认的在方法入口和出口都打印一条日志三十秒就能定位问题。5.3 坑三自定义参数在TokenRequest里被过滤掉了还有一个比较隐蔽的问题我明明在请求里传了verifyCode但TokenGranter里拿到的tokenRequest.getRequestParameters()是空的连username都是空。这个问题往往是因为TokenEndpoint和ClientDetails之间多了一层OAuth2RequestFactory的过滤逻辑。实际上TokenEndpoint在解析请求参数的时候默认只会保留它认识的那几个参数。我在排查的时候先看OAuth2RequestFactory是什么实现。如果是DefaultOAuth2RequestFactory它会把请求参数原样透传下去问题不大。如果是自定义的OAuth2RequestFactory那就得看看它的createTokenRequest方法是不是把参数给过滤了——很多二次封装的人会在这里把多余参数丢掉。解决方式也很简单在TokenGranter里不依赖tokenRequest.getRequestParameters()或者不要只依赖它直接从一个能拿到原始HttpServletRequest的地方取参数。最粗暴也最有效的方案是在TokenGranter里注入HttpServletRequest从request里重新拿原始参数。5.4 坑四passwordEncoder不一致导致密码校验失败这个坑比较隐蔽。我自定义的Provider里用的是BCryptPasswordEncoder但项目里全局的PasswordEncoder配置的可能是DelegatingPasswordEncoder或者反过来。由于我直接new了一个Provider它持有的passwordEncoder和用户表里密文的编码方式对不上就会导致所有密码校验失败。后面我改成了使用Spring容器里统一管理的PasswordEncoderBean不让Provider自己随便new。排查这个问题的过程中我还发现一个更隐蔽的变体有的表里用户密码不是BCrypt而是旧系统用的MD5加盐这时候如果无脑用BCrypt去matches永远失败。正确做法是写一个UserDetailsService在加载用户的时候就把密码格式统一转换好或者自定义多个PasswordEncoder做兼容判断。5.5 坑五TokenStore选择不当刷新令牌失效如果你用的是InMemoryTokenStore服务一重启所有Token全部失效。如果你的业务要求刷新令牌长期有效那必须上JdbcTokenStore或者RedisTokenStore。这个不是自定义模式特有的坑但自定义模式下Token的scope和有效期往往跟业务强绑定一旦Token丢失影响面会比默认模式更大。我的建议是生产环境一律用RedisTokenStore或JdbcTokenStore不要因为开发方便就留着内存版。同时如果Token里要携带一些自定义信息比如租户ID、用户类型一定要确认这些信息在OAuth2Authentication的反序列化过程中不会丢。Spring Security OAuth2的DefaultAccessTokenConverter只处理标准的principal、authorities等字段自定义的附加信息需要通过additionalInformation通道传递。6. 进一步扩展在自定义模式里加入更多业务校验自定义授权模式的好处是一旦跑通了后续加校验逻辑就跟搭积木一样灵活。下面列几个我在不同项目里实际做过的扩展你可以在理解原理的基础上按需添加。6.1 登录频次控制与锁定前面提到的验证码校验我通常会在VerifyCodeService里做两块一是发送频次控制二是错误次数锁定。发送频次控制很好实现Redis里存一个keyverify:send:username每次发送时校验次数。错误次数锁定稍微麻烦一点因为校验失败和校验成功都是同一个Provider的authenticate方法里完成的你需要把失败次数也记到Redis里。大概逻辑是这样的Provider里验证码不匹配时先自增错误次数如果超过五次直接把这个账号的验证码key删掉并写入30分钟的锁定key。下次请求进来第一步检查锁定key是否存在存在就抛异常。这样比在数据库里加锁字段更灵活而且天然支持集群。6.2 多因子认证的组合校验如果你的双因子不是固定一种而是用户在“短信验证码”和“谷歌验证器”之间自选那自定义AuthenticationToken里就应该增加一个factorType字段。Provider拿到这个字段后再去决定调用SmsVerifyCodeService还是GoogleAuthenticatorService。这个设计的价值在于授权模式本身没有变变的只是Provider内部的校验策略。以后再加人脸识别、扫码确认都只是加一个factorType分支不会动TokenGranter那一层。6.3 自定义Token里的附加信息默认的TokenClaims里只有user_name、scope、client_id这些标准字段。但很多时候下游服务需要知道用户的部门、角色级别、甚至一些个性化配置。这个需求可以通过给OAuth2Authentication的details或者OAuth2AccessToken的additionalInformation里塞数据来实现。我建议在Provider校验成功之后构造UsernamePasswordAuthenticationToken时把额外的用户属性放到details里。然后在TokenEnhancer里把这些details字段复制到Token的additionalInformation。配置一个自定义TokenEnhancer也不复杂public class CustomTokenEnhancer implements TokenEnhancer { Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { MapString, Object additionalInfo new HashMap(); additionalInfo.put(department, tech); additionalInfo.put(user_type, admin); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; } }然后在AuthorizationServerEndpointsConfigurer里把这个enhancer挂上endpoints.tokenEnhancer(new CustomTokenEnhancer())。下游服务解析JWT或者查TokenStore的时候就能拿到这些字段了。6.4 和refresh_token联动的注意点自定义授权模式签发的refresh_token在刷新的时候走的还是RefreshTokenGranter。这里有一个容易翻车的点RefreshTokenGranter会根据原始授权信息重新调用AuthenticationManager吗不会。它只验证客户端身份和refresh_token本身然后直接基于原来的OAuth2Authentication签发新的AccessToken。所以如果你的自定义模式里有那种“每次刷新Token都必须重新校验验证码”的业务要求光靠RefreshTokenGranter是做不到的。要么在刷新接口前面套一层自己的校验逻辑要么把refresh_token的有效期缩短让它过期之后强制走完整登录流程。7. 写在最后这套方案的适用边界与我的个人体会自定义授权模式不是银弹。如果你的业务场景跟默认password模式完全吻合那就没必要自己造轮子默认方案经过多年社区验证稳定性肯定比你自己写的要高。但一旦你遇到了”默认参数不满足业务校验“的情况怎么在不动框架源码的前提下扩展就成了一个必须掌握的技能。我个人在实际项目里最大的体会是整个自定义授权模式最核心的设计决策都在TokenGranter和AuthenticationProvider的边界划分上。TokenGranter只负责把HTTP参数转成Authentication对象AuthenticationProvider只负责把Authentication对象转成已认证的Authentication。这两个类职责清晰改动互不干扰才是这套方案能灵活应对各种业务需求的根本原因。如果你现在的项目里恰好需要做类似的改造我建议从最小的demo开始。不要一上来就搞数据库、搞Redis、搞集群先用内存版把所有类跑通再逐步替换存储层。这个过程一般两到三天就能完成等链路完全清晰之后再推到真实的分布式环境里会省去很多排查问题的时间。最后再分享一个小技巧调试这种授权链路时一定要把org.springframework.security和org.springframework.security.oauth2相关的日志级别调到DEBUG。这个框架的日志打得非常全从请求进入TokenEndpoint到TokenGranter的匹配到AuthenticationManager的provider选择每一步都有输出。很多时候你纠结半天的问题一条DEBUG日志就能看出端倪。祝顺利。