ARTICLE DETAIL

资讯详情

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

OAuth2自定义授权模式实战:Spring Security实现短信验证码登录

OAuth2自定义授权模式实战:Spring Security实现短信验证码登录 做后端的人迟早会遇到一个问题标准 OAuth2 那几种授权模式放到真实业务里总有对不上的时候。我之前在做移动端登录体系时产品提了个需求用户拿手机号加短信验证码就能换 token不能走明文密码那套。翻了一遍 Spring Security OAuth2 内置的 authorization_code、password、client_credentials、implicit发现没有一个能直接满足最后只能自己写一个自定义授权模式。这篇文章就把我当时完整实现“短信验证码授权模式”的思路、代码和踩坑经历整理出来给同样需要扩展 OAuth2 授权方式的人一个可以直接参考的落地姿势。先说清楚一点OAuth2 的 grant_type 本质上就是一个字符串TokenEndpoint 拿到请求后把这个字符串分发给不同的 TokenGranter 处理。所以自定义授权模式这件事核心就是两件事写一个自己的 TokenGranter再把自定义的认证逻辑挂上去。听起来简单但中间有不少细节坑比如 scope 丢了、client 校验失败、刷新令牌拿不到用户身份这些都是我真实踩过的。1. 先搞清楚为什么默认的授权模式不够用1.1 内置授权模式的边界在哪Spring Security OAuth2 默认支持的授权模式有四种加上 refresh_token 一共五种每种都有明确的适用场景但也都有对应的死穴授权模式适用场景局限性authorization_code第三方 Web 应用授权用户跳转授权页流程重需要浏览器参与不适合纯 API 场景implicit纯前端应用拿 token已不推荐安全性差token 暴露在 URL 上password用户拿用户名密码直接换 token要求客户端拿到明文密码移动端体验差client_credentials服务之间互相调用没有用户概念拿不到用户身份无法做用户级授权refresh_token用刷新令牌续期访问令牌只是补充品本身不能完成初次认证我当时的需求是 App 用户输入手机号、点击获取验证码、填完直接登录。这个流程的本质是想通过“手机号 验证码”这个凭证去换 OAuth2 的 token但内置的 password 模式写死了 username 和 password 两个参数验证码登录塞不进去。强行用 password 模式改参数名也可以但认证逻辑没法复用还得处理一堆兼容性问题不划算。1.2 自定义授权模式到底解决了什么问题自定义授权模式做的事情简单说就是让授权服务器认识一个新的 grant_type并且用你定义的逻辑去校验用户身份。以我的场景为例新的 grant_type 叫 sms_code请求长这样POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_typesms_codephone13800138000code123456client_idappclient_secretsecret授权服务器收到请求后发现 grant_type 是 sms_code就交给对应的 SmsCodeTokenGranter这个 Granter 里会校验手机号验证码是否有效有效就生成 OAuth2 token 返回给客户端无效就抛认证异常。这就把一个原本 OAuth2 不支持的登录方式完整地带进了授权体系。这样做的实际价值很直接token 的生成、存储、刷新、回调全部复用授权服务器的现成能力你只需要写认证这一小块差异化的逻辑。扫个码登录、一键登录、设备码登录本质上都一样换一下校验逻辑罢了。2. 整体思路自定义授权模式的设计原理2.1 TokenGranter 是整个流程的入口在 Spring Security OAuth2 里TokenEndpoint 收到 /oauth/token 请求后会从请求里取出 grant_type然后交给 TokenGranter 去处理。TokenGranter 是一个接口里面只有一个方法public interface TokenGranter { OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest); }默认情况下AuthorizationServerEndpointsConfigurer 会装配一个 CompositeTokenGranter里面包含 authorization_code、password、client_credentials、implicit 和 refresh_token 这几种内置的 TokenGranter。CompositeTokenGranter 拿到请求后遍历内部持有的所有 TokenGranter谁声称能处理这个 grant_type谁就上。所以自定义授权模式的第一件核心工作就是往这个 CompositeTokenGranter 里塞进一个自己的 TokenGranter。只要它在遍历时看到 grant_type 匹配后续流程就跑起来了。2.2 AbstractTokenGranter 替我们做掉了大部分脏活自己从 TokenGranter 接口开始实现其实没必要因为 spring-security-oauth2 提供了 AbstractTokenGranter 这个抽象类它内部已经完成了三类事情。第一类grant_type 匹配。AbstractTokenGranter 构造时接收一个 grantType 字符串grant 方法会先判断请求的 grant_type 是否等于这个值不相等直接返回 null让 CompositeTokenGranter 继续尝试下一个。第二类客户端信息校验。grant 方法内部会通过 clientDetailsService.loadClientByClientId 加载客户端信息然后校验授权类型、scope 是否合法。第三类token 的生成与存储。认证通过后AbstractTokenGranter 会把构造好的 OAuth2Authentication 交给 authorizationServerTokenServices.createAccessToken由统一机制生成访问令牌、刷新令牌并存入 TokenStore。我们继承 AbstractTokenGranter 后唯一真正需要实现的只有 getOAuth2Authentication 方法。这个方法要做的事情就是从请求参数里取出业务参数调用认证逻辑校验用户身份校验通过后返回一个 OAuth2Authentication 对象。后面的 token 生成、存储全都不用管。Override protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) { // 在这里填你的认证逻辑 }2.3 认证逻辑怎么植进去getOAuth2Authentication 里怎么校验用户身份有两条路可以走。第一种是直接用 Spring Security 的 AuthenticationManager把手机号和验证码封装成一个 Authentication 对象交给 AuthenticationManager 去认证。这种方式的优点是认证逻辑可以独立成 AuthenticationProvider和 Spring Security 的过滤器链、全局认证机制完全打通。第二种是在 getOAuth2Authentication 里直接写业务查询和判断代码比如查数据库比对验证码。这种方式代码少但把认证规则写死在 TokenGranter 里不好扩展。一旦后面要多支持扫码登录、密码登录就得继续往这个类里堆代码迟早变成一坨。我推荐用第一种方式把认证逻辑放到 AuthenticationProvider 里。设计上更符合 Spring Security 的惯例代码也能复用。毕竟后面其他接口要实现对用户的认证时直接注入这个 Provider 就能用。2.4 完整流程串一遍一个短信验证码登录的完整链路是这样的客户端向 /oauth/token 发起 POST 请求携带 grant_typesms_code。TokenEndpoint 从请求中取出授权类型调用 CompositeTokenGranter。CompositeTokenGranter 遍历内部 TokenGranterSmsCodeTokenGranter 匹配成功。AbstractTokenGranter 的模板方法开始执行加载 client 信息、初始化 TokenRequest。getOAuth2Authentication 被调用从 TokenRequest 参数里取出 phone 和 code。构造 SmsCodeAuthenticationToken交给 AuthenticationManager。AuthenticationManager 找到 SmsCodeAuthenticationProvider执行验证码校验。校验通过后SmsCodeAuthenticationProvider 返回已认证的 Authentication。getOAuth2Authentication 用这个认证结果构造 OAuth2Authentication。AbstractTokenGranter 继续执行调用 tokenServices 生成 access_token 和 refresh_token。token 存储到 TokenStore最终返回给客户端。这条链路里第三步是扩展点第五到第九步就是我们自定义代码的核心区域。3. 实操工程搭建与基础配置3.1 版本选型别用太新的坑我这边当时用的是 Spring Boot 2.7.x配合 spring-security-oauth2-autoconfigure 2.6.x 和 spring-security-oauth2 2.5.x。这套组合最大的好处是 EnableAuthorizationServer 还能直接用网上的资料和踩坑经验也最多。有一点需要说明Spring Security 官方早就把 OAuth2 授权服务器功能独立成了 spring-authorization-server 项目原来的 EnableAuthorizationServer 在 Spring Boot 3.x 里直接被移除了。如果你的项目正在用 Boot 3.x直接抄这篇文章里的注解式写法会报注解不存在。新版本虽然也支持自定义授权模式但 API 变化很大配置方式完全不同那就是另一篇文章的内容了。我的建议是如果是存量老项目需要快速扩展用我这套方案最稳妥如果是新版项目从零开始可以先评估迁移到 spring-authorization-server 的代价。3.2 基础依赖与工程结构先看 pom.xml 里的核心依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.security.oauth.boot/groupId artifactIdspring-security-oauth2-autoconfigure/artifactId version2.6.8/version /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-oauth2/artifactId version2.5.2.RELEASE/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies工程结构上为了保证代码清晰我把相关类分到了不同的包configAuthorizationServerConfig、WebSecurityConfiggrantSmsCodeTokenGranter、CompositeTokenGranterFactoryauthSmsCodeAuthenticationToken、SmsCodeAuthenticationProviderserviceSmsCodeUserDetailsService3.3 客户端信息的存储方式授权服务器需要知道哪些客户端应用是合法的以及它们允许哪些授权类型。最常用的做法是把客户端信息放到数据库里对应的表是 oauth_client_details。CREATE TABLE oauth_client_details ( client_id VARCHAR(256) PRIMARY KEY, resource_ids VARCHAR(256), client_secret VARCHAR(256), scope VARCHAR(256), authorized_grant_types VARCHAR(256), web_server_redirect_uri VARCHAR(256), authorities VARCHAR(256), access_token_validity INTEGER, refresh_token_validity INTEGER, additional_information VARCHAR(4096), autoapprove VARCHAR(256) );初始化一条客户端数据这里把 sms_code 加进 authorized_grant_types这是后面能否调用成功的关键漏了会直接报 UnsupportedGrantTypeExceptionINSERT INTO oauth_client_details (client_id, client_secret, scope, authorized_grant_types, access_token_validity, refresh_token_validity) VALUES (app, $2a$10$URwvJd3rE5McBlXnVTLqdeKZ0Yh0nrmzX0Q7E4oY4oNn9Qz3CZT1G, all, sms_code,refresh_token, 7200, 604800);client_secret 我放的是 BCrypt 加密后的值实际开发中不要用明文写在数据库里。3.4 授权服务器基础配置AuthorizationServerConfig 是整个授权服务器的核心配置类继承 AuthorizationServerConfigurerAdapter重写三个 configure 方法。先贴基础的部分Configuration EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { Autowired private AuthenticationManager authenticationManager; Autowired private DataSource dataSource; Autowired private PasswordEncoder passwordEncoder; Autowired private UserDetailsService userDetailsService; Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.jdbc(dataSource) .passwordEncoder(passwordEncoder); } Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints .authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .tokenStore(tokenStore()) .tokenGranter(tokenGranter(endpoints)); } Bean public TokenStore tokenStore() { return new JdbcTokenStore(dataSource); } private TokenGranter tokenGranter(AuthorizationServerEndpointsConfigurer endpoints) { // 这里是我们后面要做的核心注册逻辑 } }tokenStore 这里我用了默认的 JdbcTokenStore表结构直接用 spring-security-oauth2 自带的建表脚本token 落库重启不丢。如果觉得 JDBC 表操作不够快可以换成 RedisTokenStore后面再展开说。4. 核心实现短信验证码授权模式4.1 先定一个自定义 AuthenticationTokenSpring Security 里认证过程中传递的凭证需要用一个 Authentication 实现类包装。用户名密码登录用的是 UsernamePasswordAuthenticationToken短信验证码登录我们当然也可以造一个自己的 Token表示“手机号和验证码”这个凭证。public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken { private final Object principal; private final Object credentials; public SmsCodeAuthenticationToken(Object principal, Object credentials) { super(null); this.principal principal; this.credentials credentials; setAuthenticated(false); } public SmsCodeAuthenticationToken(Object principal, Collection? extends GrantedAuthority authorities) { super(authorities); this.principal principal; this.credentials null; super.setAuthenticated(true); } Override public Object getCredentials() { return this.credentials; } Override public Object getPrincipal() { return this.principal; } }这个 Token 有两个构造方法第一个构造方法用于认证前principal 是手机号credentials 是验证码未认证状态第二个构造方法用于认证通过后principal 换成用户对象credentials 置空带上权限集合标记为已认证。和 UsernamePasswordAuthenticationToken 的设计思路一模一样。4.2 写验证码认证的 AuthenticationProviderAuthenticationProvider 才是真正干活的。它的任务很明确从 SmsCodeAuthenticationToken 里取出手机号和验证码校验验证码加载用户信息返回一个已认证的 SmsCodeAuthenticationToken。public class SmsCodeAuthenticationProvider implements AuthenticationProvider { private final UserDetailsService userDetailsService; public SmsCodeAuthenticationProvider(UserDetailsService userDetailsService) { this.userDetailsService userDetailsService; } Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String phone (String) authentication.getPrincipal(); String code (String) authentication.getCredentials(); // 这里建议用 Redis 存储验证码key 是 phonevalue 是验证码带过期时间 String savedCode smsCodeService.getCode(phone); if (savedCode null || !savedCode.equals(code)) { throw new BadCredentialsException(验证码错误或已过期); } UserDetails userDetails userDetailsService.loadUserByUsername(phone); if (userDetails null) { throw new UsernameNotFoundException(用户不存在); } SmsCodeAuthenticationToken authenticationResult new SmsCodeAuthenticationToken( userDetails, userDetails.getAuthorities()); authenticationResult.setDetails(authentication.getDetails()); return authenticationResult; } Override public boolean supports(Class? authentication) { return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication); } }真实项目中验证码的存取一般用 Rediskey 可以是 sms:login:13800138000value 是六位数字TTL 设为 5 分钟校验成功后立刻删除防止暴力重放。这里的关键点是返回的认证对象里principal 必须是 UserDetails而不是手机号字符串因为后面的 token 生成、资源服务器的用户身份获取都要依赖这个 principal。4.3 实现 SmsCodeTokenGranterTokenGranter 是自定义授权模式里最核心的一段。继承 AbstractTokenGranter把 grant_type 设成 sms_code然后重写 getOAuth2Authentication。public class SmsCodeTokenGranter extends AbstractTokenGranter { private static final String GRANT_TYPE sms_code; private final AuthenticationManager authenticationManager; public SmsCodeTokenGranter(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 LinkedHashMap(tokenRequest.getRequestParameters()); String phone parameters.get(phone); String code parameters.get(code); SmsCodeAuthenticationToken authToken new SmsCodeAuthenticationToken(phone, code); Authentication authentication authenticationManager.authenticate(authToken); SetString scopes tokenRequest.getScope() null || tokenRequest.getScope().isEmpty() ? Collections.emptySet() : new LinkedHashSet(tokenRequest.getScope()); OAuth2Request oAuth2Request new OAuth2Request( tokenRequest.getClientId(), client.getClientId(), authentication.getAuthorities(), true, scopes, client.getResourceIds(), null, null, null); return new OAuth2Authentication(oAuth2Request, authentication); } }有几点要提醒一下。这里取参数一定要用 tokenRequest.getRequestParameters()而不是自己再做一次 HttpServletRequest 解析。TokenRequest 里的 requestParameters 是从原始请求拷贝过来的包含客户端传上来的 phone 和 code。grant_type、client_id 这些会被框架提前消费掉不会出现在这里剩下的是我们自定义业务参数。scope 必须手动放到 OAuth2Request 里。很多人在自定义 TokenGranter 时忘记带 scope导致生成的 token 里没有 scope 信息资源服务器校验权限时报 InvalidScopeException。OAuth2Request 构造器的第五个参数就是 scope 集合。grant_type 字符串不建议用大写或特殊字符。OAuth2 规范里 grant_type 是大小写敏感的Spring Security OAuth2 内部直接拿请求里的字符串和 TokenGranter 构造时的字符串比较大小写不一致就匹配不上也不会报错就是一直拿不到 token很阴间。建议全程小写。4.4 把 TokenGranter 注册到端点配置里有了 TokenGranter 和 Provider最后一步是组装。在 AuthorizationServerConfig 的 tokenGranter 方法里把系统内置的 TokenGranter 和自定义的 SmsCodeTokenGranter 一起丢进 CompositeTokenGranter。private TokenGranter tokenGranter(AuthorizationServerEndpointsConfigurer endpoints) { ListTokenGranter granters new ArrayList(); // 保留框架内置的授权模式 granters.add(endpoints.getTokenGranter()); // 添加自定义的短信验证码授权模式 granters.add(new SmsCodeTokenGranter( authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory() )); return new CompositeTokenGranter(granters); }同时把 AuthenticationProvider 注册到 AuthenticationManager 里这里通过 WebSecurityConfig 完成Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { Autowired private UserDetailsService userDetailsService; Bean Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(new SmsCodeAuthenticationProvider(userDetailsService)); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }注意 AuthenticationManagerBean 必须暴露成 Bean因为 AuthorizationServerConfig 里需要注入 AuthenticationManager。这是老版本 Spring Security 里非常容易漏的一步漏了启动直接报 NoSuchBeanDefinitionException。4.5 调用测试验证完整链路启动项目用 curl 模拟客户端发起短信验证码登录curl -X POST http://localhost:8080/oauth/token \ -H Content-Type: application/x-www-form-urlencoded \ -d grant_typesms_codephone13800138000code123456client_idappclient_secretsecret如果一切正常响应是一个标准的 OAuth2 token 结构{ access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..., token_type: bearer, refresh_token: 0d9f2e7a-5b9a-4c3e-8e4f-4d6a1f3c8a2b, expires_in: 7199, scope: all }如果验证码不对或者手机号不存在授权服务器会返回 400body 里是 InvalidGrantException 对应的错误信息{ error: invalid_grant, error_description: 验证码错误或已过期 }到这一步短信验证码登录已经走通了。整个流程没有改任何 token 生成和存储的逻辑只是扩展了一个认证入口。5. 关键细节与进阶配置5.1 用户信息到底放在哪怎么在资源服务器取出来自定义授权模式完成后最容易被忽略的问题是token 拿到了但资源服务器怎么知道当前登录的用户是谁在 4.3 的 getOAuth2Authentication 里OAuth2Authentication 的构造参数之一是我们认证后的 Authentication这个 Authentication 的 principal 是 UserDetails 对象。当 token 被资源服务器校验时OAuth2Authentication 会作为 Authentication 的底层实现存在我们可以在 Controller 里拿到它然后取用户信息GetMapping(/user/me) public UserInfo getCurrentUser(Authentication authentication) { OAuth2Authentication oauth2Auth (OAuth2Authentication) authentication; return (UserInfo) oauth2Auth.getUserAuthentication().getPrincipal(); }如果自定义的 UserDetailsService 返回的是自定义的 UserInfo 对象这里直接强转就能用。很多人在自定义 TokenGranter 里图省事直接把手机号字符串作为 principal结果资源服务器拿不到用户完整信息还要再查一次数据库这是不必要的。5.2 TokenStore 选型内存版真的只配本地跑框架默认的 TokenStore 是 InMemoryTokenStoretoken 和 refresh token 都存在内存里。单机开发测试没问题但一重启就全没了生产环境根本没法用。我这里用的是 JdbcTokenStore配合官方提供的表结构。需要注意JdbcTokenStore 在并发量上来之后会有明显的数据库读写压力每次 token 校验都查一次 oauth_access_token 表性能瓶颈很突出。如果资源服务器和授权服务器是同一个 Redis 集群直接用 RedisTokenStore 是更常见的做法Bean public TokenStore tokenStore(RedisConnectionFactory connectionFactory) { return new RedisTokenStore(connectionFactory); }RedisTokenStore 的 key 设计很合理access token、refresh token、client token 都分开存储还带 TTL过期自动清理对并发场景友好得多。我们的授权服务器和资源服务器如果共用同一个 Redis资源服务器直接注入同一个 TokenStore 就能本地校验 token不用每次远程调用授权服务器性能差距很大。5.3 刷新令牌的坑自定义授权模式能不能带 refresh_token短信验证码登录后客户端拿到的 access_token 只有 2 小时有效期过期后如果让用户再输一遍验证码体验很差。正确的做法是支持 refresh_token 续期刷新时走内置的 refresh_token 流程不需要重新验证码。这里有一个关键点客户端表里必须把 refresh_token 加进 authorized_grant_types否则刷新请求会被拦截。在 oauth_client_details 表里authorized_grant_types 字段我写的是 sms_code,refresh_token 逗号分隔两个授权类型都要在。刷新请求curl -X POST http://localhost:8080/oauth/token \ -H Content-Type: application/x-www-form-urlencoded \ -d grant_typerefresh_tokenrefresh_token0d9f2e7a-5b9a-4c3e-8e4f-4d6a1f3c8a2bclient_idappclient_secretsecret刷新成功后会返回新的 access_token 和 refresh_token。理论上自定义 TokenGranter 里构造的 OAuth2Authentication 中如果权限、scope 信息完整刷新后的 token 也能保留这些信息。这也是为什么我在 4.3 里强调 scope 和 authorities 必须正确传入否则第一次登录能用刷新后权限就丢了。5.4 一个工厂方法统一管理多个自定义授权模式后面我又接了一个扫码登录的需求发现如果每个模式都单独写一个 TokenGranter然后在 AuthorizationServerConfig 里手动 new代码会比较散。更好的做法是用一个工厂类统一组装把所有 TokenGranter 收集起来避免配置类越写越臃肿。Component public class TokenGranterFactory { private final AuthenticationManager authenticationManager; private final UserDetailsService userDetailsService; public TokenGranterFactory(AuthenticationManager authenticationManager, UserDetailsService userDetailsService) { this.authenticationManager authenticationManager; this.userDetailsService userDetailsService; } public TokenGranter composite(AuthorizationServerEndpointsConfigurer endpoints) { ListTokenGranter granters new ArrayList(); granters.add(endpoints.getTokenGranter()); granters.add(new SmsCodeTokenGranter( authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory() )); granters.add(new QrCodeTokenGranter( authenticationManager, endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory() )); return new CompositeTokenGranter(granters); } }这样 AuthorizationServerConfig 里只需要注入 TokenGranterFactory然后调用 composite 方法即可。以后每新增一个授权模式只需要新增一个 TokenGranter 和一个 AuthenticationProvider工厂里加一行AuthorizationServerConfig 不用再改。6. 常见问题与排查技巧实录6.1 常见问题速查表以下是我在实现和项目上线后遇到过的典型问题整理成表格方便速查。问题现象可能原因解决方案请求 /oauth/token 返回 unsupported_grant_type客户端表 authorized_grant_types 没加自定义 grant_type更新 oauth_client_details 表重新插入或修改授权类型返回 invalid_grant 且日志无认证异常自定义 TokenGranter 没匹配到或者 TokenGranter 未注册确认 CompositeTokenGranter 里是否包含自定义 TokenGranter返回 invalid_scopeOAuth2Request 构造时 scope 传空了从 tokenRequest.getScope() 复制 scope 到 OAuth2Request返回 invalid_token 或 token 校验失败TokenStore 不一致授权服务和资源服务用了不同 TokenStore统一 Redis 或 JDBC TokenStore不要一个内存一个数据库刷新 token 后拿到 401refresh_token 不在 authorized_grant_types 里客户端表的授权类型加入 refresh_tokenaccess_token 重启项目就失效默认使用 InMemoryTokenStore改成 JdbcTokenStore 或 RedisTokenStore自定义 grant_type 里拿不到业务参数用了 OAuth2Request 或 HttpServletRequest 而不是 TokenRequest使用 tokenRequest.getRequestParameters() 获取参数提示 NoSuchBeanDefinitionException: AuthenticationManagerWebSecurityConfigurerAdapter 里没暴露 authenticationManagerBean添加 Bean authenticationManagerBean() 方法6.2 一个隐蔽的坑endpoints.getTokenGranter() 带来的内置模式覆盖我在最初实现的时候以为只有往 CompositeTokenGranter 里加自定义 TokenGranter 就行了结果发现内置的 authorization_code 和 password 模式也调用不了了。排查下来是 tokenGranter 方法里直接 new 了 CompositeTokenGranter 传给 endpoints.tokenGranter()导致框架默认的 TokenGranter 被整个覆盖掉内置模式自然就没了。解决办法就是我上面写的那样先把 endpoints.getTokenGranter() 拿到手也就是框架当前已装配好的 CompositeTokenGranter将它作为第一个元素放进新的 List再追加自定义 TokenGranter最后统一包成新的 CompositeTokenGranter。这样内置模式和自定义模式能共存密码登录、验证码登录、刷新 token 都能正常走。6.3 排查技巧日志是你最好的朋友这类问题很多时候靠读代码看不出来打开日志是最快的。在 application.yml 里把 OAuth2 相关的日志级别调到 DEBUGlogging: level: org.springframework.security: DEBUG org.springframework.security.oauth2: DEBUG org.springframework.security.oauth2.provider.endpoint: DEBUG org.springframework.security.oauth2.provider.token: DEBUG调完日志之后请求 /oauth/token 会发现控制台里打印了完整的请求处理链路包括 CompositeTokenGranter 内部尝试了哪些 TokenGranter、client 信息是怎么加载的、token 是怎么生成的。有一次我调了很久都没有头绪最后就是靠日志发现请求根本没有进入自定义 SmsCodeTokenGranter而是被更靠前的 TokenGranter 匹配了 grant_type问题瞬间定位。6.4 别人不会跟你说的几个安全细节第一自定义授权模式的错误信息不要太详细。验证码错误就返回统一的“验证码错误或已过期”不要告诉用户“验证码还能用 3 分钟”或者“你输入的验证码是 123487”。很多攻击者会利用这种信息差做数据探测。第二验证码校验成功后要立即删除。如果验证码是一次性的在 AuthenticationProvider 里通过验证后立刻把 Redis 里的 key 删掉防止同一个验证码被重放到其他接口或者短时间内反复刷新 token。第三生产环境不要把 access_token 打日志。Token 就是用户的通行凭证日志里一打运维系统、日志平台都等于裸奔。我见过不止一个项目在过滤器里打印了请求头结果 token 全进了日志系统后面还得一轮一轮清。第四grant_type 的命名建议使用 URN 风格比如 urn:ietf:params:oauth:grant-type:sms_code。虽然我演示用的是简短风格但 URN 风格能避免和未来 OAuth2 官方新增的授权类型撞名如果你的授权服务器要开放给第三方接入这点尤其重要。如果只是内部使用简短风格问题不大但一定要全局唯一。7. 一点个人经验收尾这套自定义授权模式的方案我在两个项目里实际落地过。第一个项目是短信验证码登录第二个项目在此基础上扩展了扫码登录总共只改了一个工厂类、新增了一个 Provider 和一个 TokenGranterAuthorizationServerConfig 完全没动。当初选择在 OAuth2 层做扩展而不是另起一套 token 体系现在看来完全值得token 的刷新、过期、鉴权全部复用现成方案后面对接第三方应用也顺理成章。如果你也遇到标准授权模式不够用的情况先别急着推翻重来照着这个思路扩展一层成本比重新造轮子低得多。
返回列表