Spring Boot OAuth2 + MFA:服务器端缓存的/oauth2/authorize请求在OTP验证后变为null(本地运行正常)的问题排查与解决
我之前在做Spring Boot OAuth2授权服务器+自定义MFA的项目时,也遇到过几乎一模一样的问题——本地单实例完全正常,部署到服务器后就出现缓存请求丢失的情况。结合你的代码和场景,咱们一步步拆解原因和解决方案:
核心原因分析
本地和服务器环境的核心差异,通常集中在Session的存储与共享机制、部署架构(单实例vs多实例) 这两点上,你的问题大概率是其中一个或多个因素导致的:
1. 多实例环境下的Session隔离问题
本地是单Tomcat/JVM实例,所有请求的Session都存在当前实例的内存中,缓存的请求不会丢失;但如果服务器是多实例部署(比如K8s多Pod、Docker多容器、Tomcat集群),每个实例的Session是完全独立的:
- 用户第一次请求(
/oauth2/authorize→/login)路由到实例A,Session存在A的内存中; - OTP验证请求路由到实例B,B的Session中根本没有缓存的
/oauth2/authorize请求,自然返回null。
2. Session超时时间配置不一致
本地默认的Session超时时间通常是30分钟,而服务器端可能因为性能优化设置了更短的超时(比如5分钟)。如果用户在验证OTP时耗时超过这个阈值,Session会被自动销毁,缓存的请求也就跟着丢失了。
3. HttpSessionRequestCache的保存逻辑存在漏洞
看你代码中的判断条件:
if (savedRequest == null || !savedRequest.getRedirectUrl().contains("/oauth2/authorize")) { requestCache.saveRequest(request, response); }
这个逻辑可能会在某些场景下跳过正确的请求保存:比如如果之前已经存在一个非/oauth2/authorize的SavedRequest,或者savedRequest的RedirectUrl判断不精准(比如带参数的URL匹配失败),导致真正的/oauth2/authorize请求没有被缓存。
4. 分布式Session的序列化问题
如果服务器已经配置了分布式Session(比如Spring Session + Redis),但Session中存储的对象(比如你的LoginUser、SavedRequest)没有实现Serializable接口,会导致对象无法正确序列化到Redis,取出时就会变成null。
针对性解决方案
方案1:配置分布式Session(解决多实例隔离问题)
这是多实例环境下的根本解决办法,用Spring Session + Redis实现Session共享:
步骤1:引入依赖
在pom.xml中添加:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
步骤2:配置Redis连接
在application.yml中添加Redis配置:
spring: redis: host: your-redis-host port: 6379 password: your-redis-password # 如果有密码的话 session: store-type: redis timeout: 30m # 设置Session超时时间为30分钟,覆盖默认值
步骤3:启用Redis Session
在你的配置类(比如DefaultSecurityConfig)上添加注解:
@EnableRedisHttpSession @Configuration public class DefaultSecurityConfig { // 你的现有代码... }
方案2:修复RequestCache的保存逻辑(解决缓存漏洞)
修改mfaAuthenticationSuccessHandler中的请求缓存逻辑,主动将/oauth2/authorize请求存储到Session的自定义属性中,避免依赖默认的判断:
@Bean public AuthenticationSuccessHandler mfaAuthenticationSuccessHandler() { return (request, response, authentication) -> { // 现有代码:检查MFA开关、生成OTP、发送邮件... if(mfaSettings.getMFAEnable() && authentication.getAuthorities().stream() .noneMatch(a -> a.getAuthority().equals("MFA_VERIFIED"))) { // 替换原有的RequestCache保存逻辑,主动缓存/oauth2/authorize请求 RequestCache requestCache = new HttpSessionRequestCache(); SavedRequest savedRequest = requestCache.getRequest(request, response); if (savedRequest != null && savedRequest.getRedirectUrl().contains("/oauth2/authorize")) { // 直接存到Session自定义属性,后续验证时直接取这个 request.getSession().setAttribute("ORIGINAL_AUTHORIZE_REQUEST", savedRequest); } else { // 如果默认缓存没有取到,主动构建DefaultSavedRequest DefaultSavedRequest defaultSavedRequest = new DefaultSavedRequest(request, new PortResolverImpl()); request.getSession().setAttribute("ORIGINAL_AUTHORIZE_REQUEST", defaultSavedRequest); } // 现有代码:重定向到MFA验证页 response.sendRedirect("/mfa/verify"); } else { // 现有代码:无MFA时的跳转逻辑... } }; }
然后在你的MFA验证Controller中,从自定义属性中取出请求:
@Controller @RequestMapping("/mfa") public class MfaController { @PostMapping("/verify") public void verifyOTP(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { // 1. 验证OTP逻辑... boolean otpValid = true; // 替换为实际验证逻辑 if (otpValid) { // 2. 从Session取出缓存的原始请求 SavedRequest savedRequest = (SavedRequest) request.getSession().getAttribute("ORIGINAL_AUTHORIZE_REQUEST"); if (savedRequest != null) { // 3. 标记MFA已验证 authentication = new UsernamePasswordAuthenticationToken( authentication.getPrincipal(), authentication.getCredentials(), Stream.concat(authentication.getAuthorities().stream(), Collections.singleton(new SimpleGrantedAuthority("MFA_VERIFIED")).stream()) .collect(Collectors.toList()) ); SecurityContextHolder.getContext().setAuthentication(authentication); // 4. 重定向到原始请求,生成授权码 new SavedRequestAwareAuthenticationSuccessHandler().onAuthenticationSuccess(request, response, authentication); // 清理缓存的请求 request.getSession().removeAttribute("ORIGINAL_AUTHORIZE_REQUEST"); } else { // 降级处理:重定向到登录页或错误页 response.sendRedirect("/login?error=mfa_failed"); } } else { response.sendRedirect("/mfa/verify?error=invalid_code"); } } }
方案3:验证Session一致性(排查问题)
在SuccessHandler和MFA验证Controller中都打印Session ID,确认是否为同一个:
// 在SuccessHandler中 LOGGER.info("MFA SuccessHandler Session ID: {}", request.getSession().getId()); // 在MFA验证Controller中 LOGGER.info("MFA Verify Session ID: {}", request.getSession().getId());
如果两个Session ID不一致,说明请求路由到了不同的实例,必须用方案1的分布式Session解决;如果Session ID一致,再排查超时时间或序列化问题。
方案4:确保Session对象可序列化
检查你存储在Session中的所有自定义对象(比如LoginUser),确保它们实现了Serializable接口:
public class LoginUser implements UserDetails, Serializable { // 必须加Serializable // 你的现有字段和方法... }
验证步骤
- 先在服务器上打印Session ID,确认是否是多实例问题;
- 如果是多实例,配置Spring Session + Redis;
- 修改请求缓存逻辑,用自定义Session属性存储请求;
- 验证OTP流程,确认缓存的请求不再为null。
内容来源于stack exchange

