ARTICLE DETAIL

资讯详情

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

Spring Authorization Server自定义密码模式:集成MFA与复杂业务逻辑

Spring Authorization Server自定义密码模式:集成MFA与复杂业务逻辑 Spring Authorization Server 1.x 发布之后很多团队把老项目中基于 Spring Security OAuth2 搭建的授权服务迁移了过来。迁移本身不复杂真正卡住进度的地方往往是标准协议覆盖不到的场景。就拿密码模式来说Spring 官方文档里已经把它标记为 deprecated但现实中内部管理系统、移动端 App、前后端分离的运营后台依然大量依赖这种“拿用户名密码直接换令牌”的交互方式。更麻烦的是标准密码模式根本没办法在认证流程里插入 MFA 二次验证、用户状态实时检查、多租户隔离这些业务逻辑。这篇文章我会从源码扩展的角度分享一套我实际落地过的自定义密码模式方案看完你可以直接在业务代码里扩展自己的 AuthenticationProvider把 MFA 和复杂业务逻辑全部塞进 Spring Authorization Server 的认证链里。1. 为什么标准OAuth2密码模式不够用1.1 Spring Authorization Server 的默认能力边界很多人第一次接触 Spring Authorization Server 时会以为它和老的 Spring Security OAuth2 项目一样通过配置就能开启所有 grant type。实际上新框架的默认实现只内置了授权码模式、客户端凭证模式、刷新令牌模式以及 OIDC 相关的几个扩展点。密码模式虽然在协议层面仍然可用但在 Spring Authorization Server 中已经没有现成的实现需要自己补全整个认证链路。这不是框架偷懒而是协议设计上的趋势。OAuth 2.0 安全最佳实践里密码模式被认为是高风险模式因为它要求客户端直接接触用户的明文密码。新框架默认不实现就是希望开发者把精力放到授权码模式加 PKCE 的方向上。但对内部系统来说授权码模式的跳转体验非常割裂而且很多场景根本没有浏览器比如服务端定时任务、内部 API 网关、以及一些老旧的嵌入式设备交互。在这些场景里一个可控范围的自定义密码模式反而比强行套授权码更务实。1.2 业务场景中的真实痛点踩过坑的团队应该深有体会标准密码模式最让人头疼的是认证流程完全黑盒。你只能拿到用户名和密码然后框架帮你验证一下直接返回 token。中间想插入任何一步额外的校验都无从下手比如用户登录时系统需要先校验短信验证码或者 TOTP 动态口令但密码模式的认证请求里根本没有预留这个参数服务端也没有地方去读取和校验它。企业内部有复杂的用户状态体系用户可能被标记为“首次登录强制改密”“账号已锁定”“组织关系已调整”这些状态都需要在发 token 之前做拦截。标准流程不提供这类钩子你要么在认证前后硬写过滤器要么去 hack 框架内部组件后患无穷。多租户系统里同样一个用户名可能在不同租户下对应不同的账号和权限。标准密码模式没有租户上下文的传递和隔离机制结果就是大家各写一套 ThreadLocal 或者参数透传代码散落一地。这些问题堆在一起最终结论只有一个不能硬套标准实现需要基于 Spring Authorization Server 的扩展点自己实现一个支持业务扩展的密码模式。好在这个框架本身留了足够灵活的扩展机制只要搞懂认证链路改造起来并不复杂。2. 自定义密码模式的整体设计思路2.1 认证链路的扩展原理在动手写代码之前先把 Spring Authorization Server 处理 token 请求的链路拆开看。所有 token 请求都会进到TokenEndpoint框架在这里做两件事第一用AuthenticationConverter把HttpServletRequest里的参数转换成一个具体的Authentication对象第二拿着这个Authentication对象去匹配对应的AuthenticationProvider由它来完成真正的认证。链路中最核心的接口有三个分别对应OAuth2AuthorizationGrantAuthenticationToken认证参数载体、OAuth2AuthorizationGrantAuthenticationConverter参数解析器、OAuth2AuthorizationGrantAuthenticationProvider认证执行器。默认情况下授权码模式有一套实现客户端凭证模式有一套实现而我们需要的自定义密码模式就是完全照抄这条扩展路线写一套属于自己的实现。这里面最容易犯的误区是试图直接修改授权码模式的 Provider或者在现有的过滤器链里塞逻辑。实际上最高效、最干净的做法是新建一个自定义的 grant type比如叫custom_password让客户端用这个新的 grant type 发请求框架像处理其他模式一样处理它。2.2 方案选型继承还是组合具体到实现方式有两条路可以走。第一条路是去继承现有的OAuth2ResourceOwnerPasswordAuthenticationProvider或者参考它的源码重写内部逻辑。我当初也是这么想的因为老项目里 Spring Security OAuth2 有一个现成的密码模式 Provider直接抄过来改一改最快。但深入看代码后发现Spring Authorization Server 1.x 里类的内部结构和老版本差别很大继承之后要覆写的方法太多而且父类内部对OAuth2AuthorizationService、OAuth2TokenGenerator的依赖都是私有字段一旦框架升级子类很容易被破坏。第二条路是完全组合。自己定义一套全新的 Token 类、Converter 和 Provider只在必要的时候调用框架暴露出来的公共服务比如密码编码器、用户详情服务、令牌生成器。这样做虽然初期代码量多一些但每个组件的职责单一后续加 MFA 或者租户逻辑时改动都被限制在自己写的类里不会波及框架内部。我的建议是走第二条路。如果你只想在密码模式里加一个验证码校验用继承的方式勉强能凑合但如果目标是“突破标准 OAuth2 限制实现 MFA 与复杂业务集成”那组合式扩展几乎是唯一能长期维护的选择。2.3 自定义认证的完整流程把整个流程画出来就清楚了客户端请求/oauth2/token携带grant_typecustom_password、username、password、tenant_id、mfa_code等参数。TokenEndpoint拿到请求后依次调用已经注册的AuthenticationConverter直到有一个 Converter 认出grant_typecustom_password并返回我们自定义的CustomPasswordAuthenticationToken。接着框架根据 Token 类型找到对应的CustomPasswordAuthenticationProvider在 Provider 内部先校验客户端凭证再校验用户密码再额外执行 MFA 校验和业务状态校验全部通过后调用框架的OAuth2TokenGenerator生成访问令牌和刷新令牌最终包装成OAuth2AccessTokenAuthenticationToken返回给客户端。这条链路上我们自己贡献的其实就是三个类但它们恰好覆盖了从请求进入到令牌返回的所有可扩展点。理解了这层结构后面写代码就只是照着填空。3. 核心代码实现从认证Token到Provider3.1 定义自定义密码模式的认证Token先定义认证参数的载体。这个类需要继承OAuth2AuthorizationGrantAuthenticationToken把客户端传过来的用户名、密码、租户 ID、MFA 验证码等业务参数都封装进去。父类构造器要求传入授权范围、客户端凭证以及附加参数列表其中附加参数会被框架记录到最终的 authorization 会话中后面生成 ID Token 或者做审计时能用到。public class CustomPasswordAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken { private final String username; private final String password; private final String tenantId; private final String mfaCode; public CustomPasswordAuthenticationToken( String username, String password, String tenantId, String mfaCode, Authentication clientPrincipal, MapString, Object additionalParameters) { super(Collections.singletonList(new SimpleGrantedAuthority(ROLE_CUSTOM_PASSWORD)), clientPrincipal, additionalParameters); this.username username; this.password password; this.tenantId tenantId; this.mfaCode mfaCode; } public String getUsername() { return username; } public String getPassword() { return password; } public String getTenantId() { return tenantId; } public String getMfaCode() { return mfaCode; } }这里有个细节需要注意构造器里的additionalParameters来自框架解析请求时收集的额外参数。我在设计时没有把这些业务字段直接塞进additionalParameters去读而是单独定义了 getter这样 Provider 里取值时类型是安全的也不容易因为键名拼写错误导致运行时异常。3.2 实现认证ConverterConverter 的作用是判断当前请求是不是我们要处理的custom_password类型并把它转换成上面定义的 Token。实现AuthenticationConverter接口核心逻辑就集中在convert(HttpServletRequest request)方法里。需要注意的是客户端凭证信息已经从Authorization请求头里解析出来了通过request.getParameter(grant_type)判断类型不匹配时直接返回 null这样框架会继续尝试下一个已注册的 Converter不会影响其他模式。public class CustomPasswordAuthenticationConverter implements AuthenticationConverter { Override public Authentication convert(HttpServletRequest request) { String grantType request.getParameter(grant_type); if (!custom_password.equals(grantType)) { return null; } // 解析客户端凭证 Authentication clientPrincipal SecurityContextHolder.getContext().getAuthentication(); if (clientPrincipal null || !(clientPrincipal instanceof OAuth2ClientAuthenticationToken)) { return null; } MapString, Object additionalParameters new HashMap(); String username request.getParameter(username); String password request.getParameter(password); String tenantId request.getParameter(tenant_id); String mfaCode request.getParameter(mfa_code); additionalParameters.put(username, username); additionalParameters.put(tenant_id, tenantId); return new CustomPasswordAuthenticationToken( username, password, tenantId, mfaCode, clientPrincipal, additionalParameters); } }一个小坑是客户端凭证的获取方式。TokenEndpoint在调用 Converter 之前会把客户端身份验证结果放到SecurityContext里所以直接用SecurityContextHolder取出OAuth2ClientAuthenticationToken是可行的。这里有极少数情况会出现取不到的问题多半是过滤器链配置时没有把ClientAuthenticationFilter放对位置后面排查章节会细说。3.3 写一个可复用的AuthenticationProviderProvider 是整个自定义模式的核心所有认证与授权逻辑都在这里执行。实现OAuth2AuthorizationGrantAuthenticationProvider接口核心方法是authenticate(Authentication authentication)。我按照实际业务处理顺序把验证拆成了四步。第一步是断言 Token 类型如果不是CustomPasswordAuthenticationToken直接抛出异常第二步是加载客户端详情调用自定义的CustomClientDetailsService检查客户端是否被禁用或过期第三步才是加载用户信息用PasswordEncoder.matches()校验明文密码第四步执行 MFA 和租户相关的业务校验。public class CustomPasswordAuthenticationProvider implements OAuth2AuthorizationGrantAuthenticationProvider { private final CustomClientDetailsService clientDetailsService; private final UserDetailsService userDetailsService; private final PasswordEncoder passwordEncoder; private final MfaVerifyService mfaVerifyService; private final OAuth2AuthorizationService authorizationService; private final OAuth2TokenGenerator? tokenGenerator; private final OAuth2TokenCustomizerOAuth2TokenClaimsContext accessTokenCustomizer; // 构造器省略使用构造器注入 Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { CustomPasswordAuthenticationToken customPasswordToken (CustomPasswordAuthenticationToken) authentication; // 1. 校验客户端 OAuth2ClientAuthenticationToken clientPrincipal (OAuth2ClientAuthenticationToken) customPasswordToken.getClientPrincipal(); if (!clientPrincipal.isAuthenticated()) { throw new OAuth2AuthenticationException(new OAuth2Error(invalid_client)); } // 2. 校验密码 UserDetails userDetails; try { userDetails userDetailsService.loadUserByUsername(customPasswordToken.getUsername()); } catch (UsernameNotFoundException ex) { throw new OAuth2AuthenticationException(new OAuth2Error(invalid_grant), ex); } if (!passwordEncoder.matches(customPasswordToken.getPassword(), userDetails.getPassword())) { throw new OAuth2AuthenticationException(new OAuth2Error(invalid_grant)); } // 3. MFA校验 boolean mfaPassed mfaVerifyService.verify( customPasswordToken.getUsername(), customPasswordToken.getTenantId(), customPasswordToken.getMfaCode()); if (!mfaPassed) { throw new OAuth2AuthenticationException(new OAuth2Error(invalid_mfa)); } // 4. 生成令牌 OAuth2Authorization authorization createAuthorization( customPasswordToken, userDetails, clientPrincipal); OAuth2AccessToken accessToken authorization.getAccessToken().getToken(); MapString, Object tokenResponse new HashMap(); tokenResponse.put(OAuth2ParameterNames.ACCESS_TOKEN, accessToken.getTokenValue()); tokenResponse.put(OAuth2ParameterNames.TOKEN_TYPE, accessToken.getTokenType().getValue()); tokenResponse.put(OAuth2ParameterNames.EXPIRES_IN, accessToken.getExpiresAt() .atOffset(ZoneOffset.UTC).toInstant().getEpochSecond() - Instant.now().getEpochSecond()); if (authorization.getRefreshToken() ! null) { tokenResponse.put(OAuth2ParameterNames.REFRESH_TOKEN, authorization.getRefreshToken().getToken().getTokenValue()); } return new OAuth2AccessTokenAuthenticationToken(clientPrincipal, accessToken, authorization.getRefreshToken() null ? null : authorization.getRefreshToken().getToken(), tokenResponse); } Override public boolean supports(Class? authentication) { return CustomPasswordAuthenticationToken.class.isAssignableFrom(authentication); } }createAuthorization这个方法要调用框架的OAuth2TokenGenerator生成访问令牌和刷新令牌并保存到authorizationService里。这段代码相对固定核心就两件事构建OAuth2TokenContext然后调用tokenGenerator.generate获取令牌对象。需要注意如果你的TokenGenerator是DelegatingOAuth2TokenGenerator它内部可能会有多个子生成器比如JwtGenerator和OAuth2RefreshTokenGenerator要保证两种 token 类型都能被覆盖否则可能只生成 access_token 而丢了 refresh_token。3.4 将自定义认证注册进Authorization Server写好了上面三个类最后一步是把它们注册到AuthorizationServerConfigurer中。这一步的代码不长但它决定了整个链路能否跑通。在自定义的SecurityFilterChain配置里通过 token endpoint 的扩展方法添加 Converter 和 Provider。Bean Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfigurer authorizationServerConfigurer new OAuth2AuthorizationServerConfigurer(); http.apply(authorizationServerConfigurer) .tokenEndpoint(tokenEndpoint - tokenEndpoint .accessTokenRequestConverter( new CustomPasswordAuthenticationConverter()) .authenticationProvider( customPasswordAuthenticationProvider())); http.oauth2ResourceServer(oauth2 - oauth2.jwt(Customizer.withDefaults())); return http.build(); }注册顺序没有严格要求框架会按照supports()方法自动匹配。但有一点要特别注意tokenEndpoint.accessTokenRequestConverter()方法是“追加”而不是“替换”Spring Authorization Server 会把默认的DelegatingAuthenticationConverter和你的自定义 Converter 合到一起也就是说授权码模式仍然在你只是在里面多挂了一个custom_password的解析器。注册完成后用 curl 模拟测试一下curl -X POST http://localhost:8080/oauth2/token \ -H Content-Type: application/x-www-form-urlencoded \ -d grant_typecustom_password \ -d usernameadmin \ -d password123456 \ -d tenant_idtenantA \ -d mfa_code123456 \ --user client-app:secret如果返回 JSON 里包含access_token和refresh_token说明整条链路已经通了。4. MFA多因素认证的完整落地4.1 MFA校验应该放在哪一步在 Spring Authorization Server 的自定义 Provider 中MFA 校验的插入位置非常关键。常见的错误是把它放在密码校验之前这样攻击者可以通过枚举用户名来探测账号是否存在。正确的做法是先校验密码再校验 MFA。密码不对直接返回统一的invalid_grant不要暴露“用户名存在但验证码错误”这种信息。这样从外部看无论是用户名错误、密码错误还是 MFA 错误报错信息都一样能有效降低被撞库的风险。具体到我上面的代码MfaVerifyService.verify()方法在passwordEncoder.matches()通过之后才执行这样的顺序还有另一个好处即使验证码校验逻辑写得比较重比如需要查 Redis 或调用第三方短信服务也能避免攻击者用大量无效密码请求来打爆短信通道。4.2 验证码存储与校验的细节关于验证码的存储我有几个实际踩过坑之后留下的建议。验证码不要放到数据库里要放到 Redis 中并设置过期时间TTL 建议控制在 5 分钟以内具体根据业务安全策略调整。手机上收到的验证码如果 5 分钟后还能用用户基本已经忘记这回事了留太久不仅浪费也增加被中间人截获的风险。还有一个很多人容易忽略的点验证码校验应该是“一次性”的。校验通过后立即删除防止同一个验证码被重复使用。这个操作要放在事务边界之外用 Redis 的del命令即可不用引入分布式锁。如果遇到并发请求同时带着同一个验证码来登录最好的处理方式是先get再del并把这两个操作放进 Lua 脚本来保证原子性或者直接使用 Redis 的getdel命令Redis 6.2 以上支持。public boolean verify(String username, String tenantId, String mfaCode) { String key mfa:code: tenantId : username; String cachedCode redisTemplate.opsForValue().get(key); if (cachedCode null || !cachedCode.equals(mfaCode)) { return false; } redisTemplate.delete(key); return true; }4.3 TOTP 动态口令和短信验证码怎么选MFA 的第二步不一定非得是“服务端下发验证码”。如果你的系统面向内部员工我建议优先考虑 TOTP基于时间的一次性密码。员工在手机上安装一个标准认证器 App首次绑定后扫码录入密钥之后每 30 秒生成一个动态口令。这种方式的好处是没有短信通道成本也不依赖第三方短信服务商的稳定性离线也能用。TOTP 实现起来也不复杂利用java.security.MessageDigest或者成熟的库就能生成和校验。核心是利用用户绑定的密钥加上当前时间窗口计算 HMAC-SHA1再取其中的几位数字。Spring 生态里有一个名叫otp-java的库封装了完整的生成和校验逻辑直接用即可。在 Provider 里接上这个服务后mfa_code参数就代表动态口令。如果面向 C 端用户短信验证码更常见但要注意防刷。同一个手机号一分钟内只能发送一次一天内超过 5 次就要触发风控这些策略最好放网关或者专门的验证码服务里而不要堆在 Authorization Server 的 Provider 里避免把认证服务写成一个什么都干的“大杂烩”。5. 复杂业务集成的最佳实践5.1 多租户隔离与自定义密码模式的结合多租户场景里最难受的一点是同一个用户名在不同租户下对应完全不同的用户记录。自定义密码模式给了一个非常好的切入点在CustomPasswordAuthenticationToken里增加tenantId字段loadUserByUsername时不只是根据用户名查用户而是根据tenantId username联合查询。这里有一个细节框架默认的UserDetailsService接口只有loadUserByUsername(String username)一个方法没有地方传租户 ID。我的做法是自定义一个TenantAwareUserDetailsService接口核心方法改成loadUserByUsername(String username, String tenantId)。这个接口不依赖框架完全是我自己定义的Provider 里直接调用它不去调用框架默认的UserDetailsService。这样就能绕开标准接口的参数限制。租户 ID 的传递不仅发生在认证阶段。登录成功后用户名和租户 ID 会作为additionalParameters保存在OAuth2Authorization里。后续业务系统拿到 access_token 后如果想要获取用户的租户上下文可以在网关或业务系统中解析 token把租户 ID 放到请求头里传递。如果你用的是 JWT 格式的 token可以在生成 token 时把tenantId放到 JWT 的 claims 里这样业务系统解析 token 就能直接拿到租户信息不用再查库。做法是配置一个OAuth2TokenCustomizerBean public OAuth2TokenCustomizerOAuth2TokenClaimsContext accessTokenCustomizer() { return context - { OAuth2Authorization authorization (OAuth2Authorization) ((OAuth2TokenClaimsContext) context).getPrincipal(); MapString, Object additionalParams authorization.getAttributes(); context.getClaims().claim(tenant_id, additionalParams.getOrDefault(tenant_id, default)); }; }5.2 用户状态实时检查与登录审计很多团队把用户状态检查放到授权服务器之外比如在业务系统里拦截请求时判断用户是否有权限。但这样有一个问题用户已经被禁用、离职或者组织关系发生变化后他手里已经拿到的 token 仍然能访问系统直到 token 过期。对内部系统来说token 有效期通常设置得比较长比如 8 小时甚至一天这期间安全漏洞实在太大。所以在自定义密码模式的 Provider 里我加入了用户状态检查。具体来说在密码校验通过之后、MFA 校验之前调用一次TenantAwareUserDetailsService返回的 UserDetails检查它是否实现了自定义的UserStatusAware接口。如果用户被标记为锁定、禁用或首次登录则抛出对应的OAuth2AuthenticationException错误码可以设置成account_locked、user_disabled、password_expired。这样客户端能拿到更明确的错误原因用户侧也可以据此给出更友好的提示文案。if (userDetails instanceof UserStatusAware statusAware) { if (statusAware.isLocked()) { throw new OAuth2AuthenticationException(new OAuth2Error(account_locked)); } if (!statusAware.isEnabled()) { throw new OAuth2AuthenticationException(new OAuth2Error(user_disabled)); } }除了状态检查登录审计也值得在这一层做。每次成功的密码认证都记录一条审计日志包含用户名、租户 ID、客户端 ID、来源 IP、登录时间。这个日志对于后续排查安全问题和追溯操作人非常有帮助。做法可以是在 Provider 里注入一个LoginAuditService认证成功后异步记录日志注意不要影响主流程的响应速度。5.3 与Spring Security其他扩展点联动Spring Authorization Server 毕竟还是基于 Spring Security 的所以很多 Spring Security 的扩展点都可以无缝复用。比如在 Provider 里注入AuthenticationEventPublisher通过它发布认证成功或失败的事件事件监听器可以做风控告警、用户行为分析等后续处理。这样一来自定义密码模式就不仅仅是一个登录入口而是一个可以承接各种业务规则的事件源。还有一个很实用的联动场景是验证码发送。很多系统要求“用户输入密码和验证码后先校验验证码再发短信”或者“密码错误次数过多时要求输入验证码”。这个逻辑在 Provider 里做起来非常顺畅加载用户后先判断用户最近的失败次数如果超过阈值就强制要求请求里携带mfa_code如果没有或者校验失败就拒绝访问。这样就把验证码从“可选”变成了“条件必选”安全性大大提高。6. 常见问题与排查技巧实录6.1 典型报错与解决方案汇总在开发过程中我整理了一份高频问题清单如果你在落地自定义密码模式时遇到了类似报错可以直接对照排查。报错或现象根本原因解决方案invalid_grant或Invalid authorization grant type自定义 Converter 没有被注册或 grant_type 参数拼写不一致检查tokenEndpoint.accessTokenRequestConverter()是否生效以及grant_type是否完全匹配认证成功后没有refresh_tokenOAuth2TokenGenerator返回的令牌类型不完整配置OAuth2RefreshTokenGenerator确保DelegatingOAuth2TokenGenerator里包含了刷新令牌生成器ProviderNotFoundExceptionProvider 的supports()方法返回类型与 Token 类型不匹配确认supports()判断的是CustomPasswordAuthenticationToken.class.isAssignableFrom(authentication)invalid_client客户端凭证未正确传参或客户端状态异常检查请求头Authorization中 Basic Auth 格式以及 client 配置里的client_secret自定义参数在 JWT claims 中丢失additionalParameters只保存在 authorization 会话中没有写入 token claims配置OAuth2TokenCustomizer手动把租户 ID 等字段放入 JWT claims循环依赖Provider 中依赖了多个服务形成 A-B-A 的结构适度拆分组件必要时用Lazy注解延迟注入6.2 调试技巧与日志分析思路调试 Spring Authorization Server 时最怕的就是“黑盒”看不到内部流程。建议在本地开发环境把日志级别调到 DEBUG重点观察TokenEndpoint、OAuth2AuthorizationGrantAuthenticationConverter和自定义 Provider 的调用日志。这样能清晰地看到请求走到了哪一步、被哪个类拦截、在哪里抛出的异常。logging.level.org.springframework.securityDEBUG logging.level.org.springframework.security.oauth2.serverDEBUG logging.level.com.example.authserverDEBUG如果 DEBUG 日志太多看不清楚直接在你的 Converter 和 Provider 里打业务日志打印每个参数的解析结果和每条校验规则的执行结果。要知道invalid_grant这个错误码是高度抽象的它可能由七八种原因触发。没有日志排查起来全靠猜。我还有一个非常推荐的排查手段用 Postman 或者 IDEA 的 HTTP Client手动组装完整的 token 请求去掉所有无关参数逐个字段验证。先用grant_typeclient_credentials跑通客户端凭证模式确认 TokenEndpoint 本身没问题再用grant_typecustom_password请求把异常信息逐步收窄到自定义代码上。6.3 上线前检查清单项目上线前除了功能要跑通还有一些容易被忽略的坑。第一access_token的有效期和refresh_token的有效期要分别设置合理值。内部系统建议 access token 设置 2 小时refresh token 设置 12 小时或 24 小时不要图省事直接配到 7 天。第二配置好的 Token 端点是否做了 TLS 加密所有 token 请求都必须在 HTTPS 下传输否则明文密码和令牌在网络上裸奔自定义模式本身的安全优势就荡然无存。第三登录失败的错误信息需要统一格式化不能把底层异常信息直接抛给前端。有一个我在生产环境踩过的坑是使用 Redis 存储OAuth2Authorization时如果同一个客户端实例的 ID 发生了重复会导致 token 被意外覆盖。这个问题的本质和自定义密码模式无关属于OAuth2AuthorizationService的键设计问题但遇到时非常隐蔽。最后解决方案是统一给授权记录增加一个唯一 ID避免和 client 的主键冲突。所以我在这里强烈建议如果业务量不大优先使用自带的JdbcOAuth2AuthorizationService它的建表语句和索引都是官方验证过的稳定性更高。收尾一点项目落地后的体会前前后后这套自定义密码模式的改造我在生产环境维护了近一年。最初只想解决一个“登录时多校验一步验证码”的小需求结果发现一旦抓住了 Spring Authorization Server 的扩展思路后面加租户隔离、加登录审计、加条件性 MFA 都不是什么大工程了。整个过程最大的成本不在写代码而在理解框架约定谁负责解析参数、谁负责校验身份、谁负责生成令牌这些边界理清了自定义模式只是又一个 grant type 而已。最后分享一个建议给准备动手的团队不要一上来就追求完整的企业级方案。先用最简化的三步——自定义 Token、Converter、Provider——把自定义密码模式跑通让前端可以用grant_typecustom_password换到 token。这个过程中不要混入任何附加业务。等链路稳定之后再逐步把 MFA、租户隔离、审计日志这些能力往 Provider 里加。每个阶段都保持可回滚的状态。毕竟认证授权是系统的第一道门门如果打不开或者关不严业务再花哨也白搭。
返回列表