news 2026/9/30 8:59:55

Spring Security OAuth2自定义授权模式:扩展TokenGranter实现动态验证码

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Security OAuth2自定义授权模式:扩展TokenGranter实现动态验证码

你有没有遇到过这种场景:项目里已经用了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签发这条链路大致是这样一个流程:

  1. HTTP请求打到/oauth/token端点,这个端点由TokenEndpoint处理。
  2. TokenEndpoint根据请求里的grant_type参数,从TokenGranter里找到对应的授权模式实现。
  3. TokenGranter负责把HTTP请求参数组装成一个OAuth2Request,再结合客户端的身份信息,交给AuthenticationManager去做用户凭证校验。
  4. AuthenticationManager会遍历所有已注册的AuthenticationProvider,找到能处理当前Authentication类型的那个,去执行真正的校验逻辑。
  5. 校验通过以后,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) { Map<String, 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与AuthenticationProvider

CustomVerifyCodeAuthenticationToken这个类,承载的是用户提交的一组待验证凭证。它跟UsernamePasswordAuthenticationToken的结构类似,但多了一个verifyCode字段,并且authenticated属性初始为false:

public 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。这个类的任务,是把用户提交的凭证和数据库里的真实数据做比对,比对通过以后返回一个authenticated=true的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.x,AuthenticationManager本身是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_type=custom_password&username=admin&password=123456&verifyCode=888888&client_id=my-client&client_secret=my-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_password
web_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 Map<String, 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_secret,Spring Security会在后台自动比对加密后的密文。

5. 实测中踩过的坑:排查链路复盘

自定义模式跑通之后,真正痛苦的阶段才开始。这里我把我实际项目中踩过的一些坑整理出来,每个都附上排查思路,希望能帮你少走弯路。

5.1 坑一:grant_type匹配不上,请求直接403

这个是我最常遇到的问题,症状是:调用/oauth/token接口,返回403,而且日志里什么都没有。从头排查HTTP请求开始,我做了这几步:

  • 确认grant_type=custom_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里存一个key:verify: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) { Map<String, 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日志就能看出端倪。祝顺利。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 8:59:50

Model-Optimizer:AI模型部署的四层决策框架

1. “Model-Optimizer”不是工具名&#xff0c;而是工程目标的精准表达 很多人第一次看到“Model-Optimizer”这个标题&#xff0c;下意识会以为它是一个现成的开源项目、某个厂商发布的GUI软件&#xff0c;或者像TensorRT、vLLM那样带版本号和安装命令的独立工具。我刚接触这个…

作者头像 李华
网站建设 2026/9/30 8:58:37

计算机网络第一章核心:分组交换、协议分层与延迟计算实战

简介&#xff1a;该PDF课件聚焦高级计算机网络课程第一章&#xff0c;以谢希仁经典教材为蓝本&#xff0c;系统讲解计算机网络与Internet的基础知识&#xff0c;重点剖析分组交换的产生背景、工作原理及相对电路交换的优势&#xff0c;并辨析结点等关键术语。资源共1个文件&…

作者头像 李华
网站建设 2026/9/30 8:58:28

ComfyUI多人姿势站位编辑器:AI视频空间一致性控制方案

1. 项目概述&#xff1a;为什么这个“多人姿势站位编辑器”在AI视频工作流里突然火了&#xff1f; 最近在ComfyUI社区刷到一个高频词——“多人姿势站位编辑器”&#xff0c;不是模型、不是Lora、也不是新节点&#xff0c;而是一个 专为AI视频生成前序环节服务的交互式姿态编排…

作者头像 李华
网站建设 2026/9/30 8:58:00

m3u8live.cn实战:让全团队用同一工具排查m3u8流故障

上个月我们线上直播出现了一次诡异的“部分用户能播、部分用户不能播”的故障。前端说后端接口没问题&#xff0c;后端说CDN状态码正常&#xff0c;CDN 的兄弟说回源都没有报错&#xff0c;最后发现所有人都在凭感觉猜测&#xff0c;谁也没有真正把那条 m3u8 链接从头到尾地“读…

作者头像 李华
网站建设 2026/9/30 8:57:57

PyTorchMobile部署图像分类:模型压缩、量化调优与避坑实战

简介&#xff1a;一份聚焦移动端AI落地的技术手册&#xff0c;系统讲解如何在算力、内存与功耗受限的移动环境中&#xff0c;借助PyTorch Mobile完成图像分类模型的压缩与部署优化。核心覆盖剪枝、量化、知识蒸馏三大主流模型压缩手段&#xff0c;逐一拆解其在PyTorch中的实现原…

作者头像 李华
网站建设 2026/9/30 8:57:51

408计算机组成原理笔记:加法器Cache流水线重难点拆解

计算机组成原理这门课&#xff0c;在408四门里属于那种你不重视它、它就一定会在分数上教训你的类型。很多人复习时把大把时间砸在数据结构和操作系统上&#xff0c;等到十一月翻开王道的计组笔记&#xff0c;发现里面的加法器、Cache映射、流水线相关这些内容还是一片模糊。我…

作者头像 李华