Spring MVC 从支付服务商返回后创建新 Session
结论
回调请求没有继续使用原 session,直接原因是:浏览器从支付域名跨站 POST 回应用时,没有携带原来的 JSESSIONID Cookie。
证据就在回调请求里:
POST /my-web/payment/paypage/manual/response/... HTTP/1.1
Host: localhost:61611
Sec-Fetch-Site: cross-site
请求中没有:
Cookie: JSESSIONID=tramjzpvaxingk1wg4cnard
因此,Jetty 无法识别旧 session。当 Spring MVC、Spring WebFlow 或 Spring Security 调用 request.getSession() 时,容器只能新建 session,并在响应中返回:
Set-Cookie: JSESSIONID=12ev6ciac4on91rondlvyrlycq;Path=/my-web;HttpOnly
所以,Set-Cookie 是浏览器没有发送旧 Cookie 后产生的结果,不是问题的起点。旧 session 没有被删除也很正常,它会保留到超时或被显式失效。
最可能的原因:SameSite 限制了跨站 POST
支付服务商和应用属于不同站点:
payment-web-sogenactif.test.sips-atos.com
localhost
从支付页面提交回应用,是一次跨站顶层导航,而且使用了 POST:
Sec-Fetch-Site: cross-site
Content-Type: application/x-www-form-urlencoded
现代浏览器通常会把没有明确设置 SameSite 属性的 Cookie 当作 SameSite=Lax 处理。Lax Cookie 一般不会随跨站 POST 发送,所以原来的 JSESSIONID 被浏览器拦下了。
这与抓包结果一致:
- 离开应用前,同站请求带有原
JSESSIONID。 - 支付完成后的跨站
POST没有携带任何应用 Cookie。 - Jetty 随后创建了新 session,并发送新的
JSESSIONID。
Origin: null 也需要检查,但它不是识别 session 的依据。浏览器是否发送 Cookie,主要取决于 Cookie 的域、路径、Secure、SameSite 等属性。
还需要检查 Cookie Path 是否错误
示例中出现了两种不同的 Cookie 路径:
Set-Cookie: JSESSIONID=...;Path=/my-web;HttpOnly
以及:
Set-Cookie: JSESSIONID=...;Path=/ir-web;HttpOnly
如果应用的实际访问路径是 /my-web,那么 Path=/ir-web 的 Cookie 不会发送给 /my-web/...。这可能是另一个独立问题,例如:
- 应用的 context path 配置不一致;
- 反向代理重写了路径,却没有同步修改 Cookie Path;
- 浏览器保存了多个 Path 不同的
JSESSIONID; - 不同部署实例使用了不同的 context path。
需要确保会话 Cookie 的 Path 与外部访问路径一致:
请求路径:/my-web/...
Cookie Path:/my-web
还要检查是否混用了 localhost、127.0.0.1 或其他主机名。Cookie 不按端口隔离,但会受到主机名、域名和路径的限制。
排查步骤
1. 找到首次创建原 session 的响应
在浏览器开发者工具的 Network 面板中,找到最初返回 JSESSIONID 的响应,确认它的完整属性,例如:
Set-Cookie: JSESSIONID=...; Path=/my-web; HttpOnly; SameSite=Lax
重点记录:
DomainPathSameSiteSecure- 是否存在重复的 Cookie
- Cookie 是否因浏览器策略被阻止
Chrome 的 Issues 面板通常也会提示跨站 Cookie 被阻止的具体原因。
2. 检查回调请求的 Cookie
不要只比较回调响应中的 Set-Cookie,还要检查回调请求本身是否包含:
Cookie: JSESSIONID=...
如果没有这一行,问题发生在浏览器发送请求之前,并不是 HttpSessionListener、Spring MVC Controller 或 WebFlow 删除了 session。
3. 在服务端记录 session 状态
可以临时记录请求是否携带 Cookie,以及 session 是否为新建:
@GetMapping("/debug/session")
@ResponseBody
public String debugSession(HttpServletRequest request) {
HttpSession session = request.getSession(false);
String requestedId = request.getRequestedSessionId();
boolean valid = requestedId != null
&& request.isRequestedSessionIdValid();
return "requestedSessionId=" + requestedId
+ ", valid=" + valid
+ ", session=" + (session == null ? "null" : session.getId())
+ ", isNew=" + (session != null && session.isNew());
}
也可以在支付回调入口记录同样的信息,但不要把完整 session ID 写入普通业务日志。生产环境至少要做脱敏处理。
可以按下面的方式判断:
getRequestedSessionId() == null:浏览器没有发送 session ID;- ID 不为空,但
isRequestedSessionIdValid() == false:Cookie 已经发送,但服务端找不到对应的 session; - session 存在且
isNew() == true:本次请求创建了新 session。
4. 检查基础设施
如果浏览器确实发送了旧 JSESSIONID,但应用仍然创建了新 session,再检查:
- 请求是否被负载均衡到另一台没有共享 session 的节点;
- 是否缺少 sticky session;
- Spring Session 或外部 session 存储是否正常;
- 应用是否重启或重新部署;
- session 是否已经超时;
- Spring Security 是否在认证过程中更换了 session ID;
- 代理是否改写或删除了
Cookie请求头; - 是否存在两个同名但 Path 不同的
JSESSIONID。
不过,当前抓包中的回调请求没有 Cookie,所以应该先处理浏览器不发送 Cookie 的问题。
解决方案一:允许 JSESSIONID 用于跨站回调
如果业务必须依赖原 session 恢复 Spring WebFlow,可以把会话 Cookie 配置为:
SameSite=None; Secure
在支持这些属性的 Spring Boot 版本中,配置通常类似:
server.servlet.session.cookie.same-site=none
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.path=/my-web
这些属性能否使用,取决于实际的 Spring Boot 和嵌入式容器版本,需要根据项目版本确认。
如果使用 Spring Session,可以通过 DefaultCookieSerializer 配置:
@Bean
public DefaultCookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("JSESSIONID");
serializer.setCookiePath("/my-web");
serializer.setUseHttpOnlyCookie(true);
serializer.setUseSecureCookie(true);
serializer.setSameSite("None");
return serializer;
}
这里有两个限制:
SameSite=None必须同时设置Secure,否则现代浏览器通常会拒绝该 Cookie。SecureCookie 只能通过 HTTPS 正常发送。当前示例使用:
http://localhost:61611
本地浏览器可能会对 localhost 做特殊处理,但不能把这种行为当作生产环境的保证。测试环境也建议使用 HTTPS。
Jetty 6.1.26 已经非常老,原生 SameSite 支持可能不足。不能假定现代 Spring Boot 配置可以直接作用于该容器。更稳妥的做法是升级到受支持的 Jetty 和 Spring 版本。如果暂时无法升级,需要结合实际容器版本,确认能否通过容器配置或统一的响应头处理机制添加 SameSite=None。
解决方案二:让浏览器以 GET 返回
如果支付服务商支持把用户返回地址配置为顶层 GET 导航,那么 SameSite=Lax Cookie 通常可以随请求发送。
流程可以设计为:
支付服务商
-> GET /payment/return?transactionId=...
-> 校验支付状态
-> 302 /registration/create/new
需要注意:
- 只有支付服务商明确支持时,才能这样配置;
- 不要依赖浏览器过去存在的”短时间内允许跨站 POST”兼容行为;
- 不能直接把 GET 返回参数当作支付成功的可信依据;
- 支付结果仍然需要通过签名验签或服务端查询确认。
更可靠的方案:不要让支付结果依赖浏览器 session
支付流程天然会跨站、跨窗口,用户也可能关闭页面、重复提交或稍后返回。把支付回调能否完成绑定在原 HttpSession 上并不可靠。
发起支付时,可以生成一次性关联标识,并把业务状态保存在数据库或其他共享存储中:
String state = UUID.randomUUID().toString();
paymentAttemptRepository.save(new PaymentAttempt(
state,
currentUserId,
orderId,
PaymentStatus.PENDING
));
String returnUrl = applicationBaseUrl
+ "/payment/return?state="
+ URLEncoder.encode(state, StandardCharsets.UTF_8);
支付返回时,再根据关联标识恢复业务上下文:
@PostMapping("/payment/return")
public String paymentReturn(
@RequestParam("state") String state,
@RequestParam Map<String, String> paymentFields) {
PaymentAttempt attempt = paymentAttemptRepository
.findByState(state)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Unknown payment state"));
verifyProviderSignature(paymentFields);
verifyPaymentAgainstProvider(attempt, paymentFields);
paymentService.completeIdempotently(attempt, paymentFields);
return "redirect:/registration/create/new?cacheKey="
+ UriUtils.encodeQueryParam(
state, StandardCharsets.UTF_8);
}
关联标识需要满足以下要求:
- 随机且不可预测;
- 设置有效期;
- 与订单、用户和预期金额绑定;
- 使用后失效,或按照幂等规则处理;
- 不包含可以直接信任的支付结果;
- 回调必须验证服务商签名,必要时主动查询支付状态。
如果支付服务商同时提供浏览器返回地址和服务器通知地址,建议分别处理:
- 服务器通知负责可靠地更新订单状态;
- 浏览器返回只负责展示结果;
- 两条路径都按照幂等方式处理。
这样,即使浏览器没有携带原 session,也不会丢失支付结果。
Spring Security 与 CSRF 的注意事项
跨站支付回调通常使用 POST。Spring Security 的 CSRF 防护可能会拒绝没有 CSRF Token 的请求。不要为了方便而关闭整个应用的 CSRF,只应针对经过严格验签的支付回调做最小范围的配置。
较新的 Spring Security 可以采用类似下面的配置:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers("/payment/return"))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/payment/return").permitAll()
.anyRequest().authenticated());
return http.build();
}
放行 CSRF 后,支付回调必须通过服务商签名、共享密钥、证书或服务端状态查询完成验证。路径可以访问,不代表回调内容可信。
Spring WebFlow 的影响
Spring WebFlow 的 flow execution 通常与 session 中的 flow execution repository 有关。原请求中的:
execution=e1s5
只有原 session 仍然可用,而且对应的 execution 仍然存在,才可能继续执行。新 session 无法访问旧 session 中保存的 flow execution,因此直接恢复 e1s5 通常会失败。
支付流程会跨越外部站点,耗时也不可控,更适合按下面的方式处理:
- 离开应用前,保存业务草稿和支付关联状态;
- 支付返回后验证交易;
- 根据持久化状态启动新的 flow,或进入专门的恢复入口;
- 不要把 WebFlow execution key 当作支付业务状态的唯一存储。
最终建议
当前问题可以按以下顺序处理:
- 确认回调请求确实没有携带原
JSESSIONID。 - 检查会话 Cookie 的
SameSite、Secure、Domain和Path。 - 修复
/my-web与/ir-web的 Cookie Path 不一致问题。 - 将测试环境改为 HTTPS。如果跨站 POST 确实需要携带 Cookie,配置
SameSite=None; Secure。 - 评估升级老旧的 Jetty 和 Spring 组件。
- 把支付状态移出
HttpSession,通过一次性关联标识、签名验证和持久化记录恢复流程。
即使配置 Cookie 后可以恢复旧 session,也不建议把它作为确认支付完成的唯一依据。浏览器 session 可以改善用户体验,支付正确性应由可验证、可重试、可幂等的服务端状态来保证。
备注:内容仅供参考。
