PAYATHON 2026

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 路径:

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

重点记录:

  • Domain
  • Path
  • SameSite
  • Secure
  • 是否存在重复的 Cookie
  • Cookie 是否因浏览器策略被阻止

Chrome 的 Issues 面板通常也会提示跨站 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;
}

这里有两个限制:

  1. SameSite=None 必须同时设置 Secure,否则现代浏览器通常会拒绝该 Cookie。
  2. Secure Cookie 只能通过 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 通常会失败。

支付流程会跨越外部站点,耗时也不可控,更适合按下面的方式处理:

  1. 离开应用前,保存业务草稿和支付关联状态;
  2. 支付返回后验证交易;
  3. 根据持久化状态启动新的 flow,或进入专门的恢复入口;
  4. 不要把 WebFlow execution key 当作支付业务状态的唯一存储。

最终建议

当前问题可以按以下顺序处理:

  1. 确认回调请求确实没有携带原 JSESSIONID。
  2. 检查会话 Cookie 的 SameSite、Secure、Domain 和 Path。
  3. 修复 /my-web 与 /ir-web 的 Cookie Path 不一致问题。
  4. 将测试环境改为 HTTPS。如果跨站 POST 确实需要携带 Cookie,配置 SameSite=None; Secure。
  5. 评估升级老旧的 Jetty 和 Spring 组件。
  6. 把支付状态移出 HttpSession,通过一次性关联标识、签名验证和持久化记录恢复流程。

即使配置 Cookie 后可以恢复旧 session,也不建议把它作为确认支付完成的唯一依据。浏览器 session 可以改善用户体验,支付正确性应由可验证、可重试、可幂等的服务端状态来保证。

备注:内容仅供参考。