PAYATHON 2026

Payment provider 的 POST 重定向导致客户 session 丢失?

支付小周

结论

问题通常不在 3D-Secure,也不是 _csrf_token 失效,而是浏览器的 SameSite Cookie 规则所致:

  • payment provider 通过跨站顶层 GET 返回时,浏览器通常会发送 SameSite=Lax 的 session cookie。
  • 通过跨站 POST 返回时,浏览器通常不会发送该 cookie。
  • Shopware 收不到原来的 session cookie,就会创建新的匿名 session,于是客户看起来像是被登出了。

比较稳妥的做法是把 provider 的 POST 返回端点设为无状态入口。端点验证支付签名并记录结果后,立即通过 303 See Other 跳转到本站的 GET 页面。后续的同站请求或使用安全方法的顶层 GET 请求会重新携带原 session cookie。整个过程不需要传递 session ID,也不应根据回调参数手动恢复客户的登录状态。

为什么 GET 正常,POST 却丢失 session

假设店铺的 session cookie 使用常见配置:

Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax

客户离开店铺进入 3D-Secure 页面后,浏览器最终会从 payment provider 或 ACS 域名向店铺发起请求。

对于跨站顶层导航,SameSite=Lax 通常有以下行为:

返回方式是否通常携带 session cookie
顶层 GET是
跨站 POST否
同站 POST是

两种请求虽然返回同一家店铺,Shopware 收到的内容却不同:

GET return:
Cookie: session=abc123

POST return:
没有原来的 session cookie

如果 POST 端点启动了 session,服务器可能创建新 session,并在响应中发送新的 Set-Cookie。这个新 cookie 甚至可能覆盖浏览器里仍然保存的旧 cookie,导致后续请求继续使用匿名 session。

3D-Secure 只是触发了这种请求形式。部分支付流程会通过自动提交的 HTML form 发起跨站 POST,真正影响 session 的是浏览器的 Cookie 策略。

为什么传递 _csrf_token 没有作用

CSRF token 和 session cookie 处理的是两个不同的问题。

CSRF token 通常由服务器生成,并绑定到以下一项或多项信息:

  • 当前 session
  • action、intent 或 route
  • 用户身份
  • token namespace
  • token manager 的内部状态

即使 payment provider 原样返回 _csrf_token,只要浏览器没有同时提交原 session cookie,服务器就无法在原 session 中验证它。服务器此时看到的是一个新 session,原 token 自然无法匹配。

CSRF token 不能代替 session,也不能用于恢复登录状态。

不同请求中的 token 看起来不同,也不一定表示出现了异常。不同的 action、scope 或 session 可以生成完全不同的 token,不能只看字符串外观来判断 token 是否正确。

推荐的处理流程

浏览器返回和支付状态通知最好分开处理:

支付平台 POST
        │
        ▼
无状态返回端点
  - 不依赖客户 session
  - 验证支付平台签名
  - 根据 transaction/order reference 更新支付状态
  - 不创建或覆盖 session cookie
        │
        ▼
303 See Other
        │
        ▼
店铺 GET 完成页
  - 浏览器重新发送原 session cookie
  - 正常显示订单结果

如果 payment provider 支持 server-to-server webhook,应以 webhook 提供的支付状态为准。浏览器返回只负责界面跳转,不能作为判断支付成功的唯一依据,因为用户可能关闭页面、重复提交或伪造请求。

POST 入口示例

下面用 Symfony 风格的代码说明基本结构。不同 Shopware 版本在 controller、route 和依赖注入方面的写法可能有所区别,但处理原则相同。

<?php

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class PaymentReturnController
{
    #[Route(
        path: '/payment/provider/return',
        name: 'payment.provider.return',
        methods: ['POST'],
        defaults: ['_stateless' => true]
    )]
    public function returnFromProvider(Request $request): Response
    {
        $payload = $request->request->all();

        if (!$this->signatureVerifier->isValid($payload)) {
            return new Response('Invalid signature', Response::HTTP_BAD_REQUEST);
        }

        $transactionReference = (string) ($payload['transaction_reference'] ?? '');

        $transaction = $this->paymentService->findByReference(
            $transactionReference
        );

        if ($transaction === null) {
            return new Response('Unknown transaction', Response::HTTP_NOT_FOUND);
        }

        // 必须支持重复调用,避免 provider 重试造成重复扣款或重复状态变更。
        $this->paymentService->applyProviderResult($transaction, $payload);

        $returnToken = $this->returnTokenService->createForTransaction(
            $transaction
        );

        $location = '/payment/provider/finish?token='
            . rawurlencode($returnToken);

        return new RedirectResponse(
            $location,
            Response::HTTP_SEE_OTHER,
            [
                'Cache-Control' => 'no-store',
                'Pragma' => 'no-cache',
            ]
        );
    }
}

这里应使用 303 See Other。该状态码会明确要求浏览器把后续导航转换成 GET,而不是再次提交原来的 POST 数据。

仅设置 _stateless => true,未必能让所有 Shopware 或 Symfony 版本保持无状态。还需要确认:

  • 路由不会读取或写入 session。
  • 没有调用 cart、customer、flash message 等依赖 session 的服务。
  • security firewall 不会为该路径启动有状态登录流程。
  • 响应不会意外发送新的同名 session cookie。
  • Shopware 的前端 bootstrap 不会在该端点自动创建 session。

如果当前 Shopware 版本无法让普通 storefront controller 保持无状态,可以把入口放到独立的 callback route 或专用 controller 中,再跳转回 storefront。

GET 完成页示例

#[Route(
    path: '/payment/provider/finish',
    name: 'payment.provider.finish',
    methods: ['GET']
)]
public function finish(Request $request): Response
{
    $token = (string) $request->query->get('token');

    $transaction = $this->returnTokenService->resolve($token);

    if ($transaction === null) {
        return new Response('Invalid or expired return token', 400);
    }

    return $this->render('payment/finish.html.twig', [
        'transaction' => $transaction,
    ]);
}

这个 token 应是短期、一次性或具有明确有效期的 opaque token,并绑定到具体订单或支付 transaction。不要把客户 session ID 放入 URL。即使另外附加 security hash,这种做法仍会增加信息泄露和 session fixation 的风险。

回调端点应该怎样防护

来自 payment provider 的 POST 通常不适合使用依赖客户 session 的 CSRF 校验。可以让该端点豁免 storefront CSRF,但必须改用支付协议本身提供的认证机制,例如:

  • provider 提供的 HMAC 或数字签名
  • webhook secret
  • transaction reference
  • merchant account 校验
  • 金额和币种比对
  • nonce、timestamp 或重放保护
  • 必要时主动调用 provider API 查询支付状态

伪代码如下:

$expectedSignature = hash_hmac(
    'sha256',
    $providerCanonicalPayload,
    $webhookSecret
);

if (!hash_equals($expectedSignature, $receivedSignature)) {
    throw new AccessDeniedHttpException('Invalid provider signature');
}

签名的拼接方式必须严格遵循 payment provider 的协议,不能自行决定字段顺序或编码方式。

是否可以改成 SameSite=None

从技术上看,可以把 session cookie 配置为:

Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=None

这样设置后,浏览器通常会在跨站 POST 中发送 session cookie,但不建议把它作为首选修复方案:

  • SameSite=None 必须与 Secure 配合使用。
  • session cookie 会在更多跨站请求中发送。
  • 所有依赖 session 的写操作都需要重新检查 CSRF 防护。
  • 浏览器隐私策略、WebView 和嵌入式支付页面的行为可能不同。
  • 部分用户环境可能进一步限制跨站 cookie。
  • 全局修改会影响整个 storefront,而不只影响支付返回路径。

如果业务确实要求跨站 POST 直接恢复原 session,应先确认当前 Shopware 版本是否支持按 cookie 或销售渠道进行安全配置,并完成完整的 CSRF 和浏览器兼容性评估。通常,无状态 POST + 303 + GET 更容易控制。

如何确认问题

在浏览器开发者工具的 Network 面板中,对比正常 GET 和异常 POST:

  1. 检查 POST 请求的 Cookie header,确认原 session cookie 是否缺失。
  2. 在浏览器 Cookies 面板中查看 session cookie 是否设置为 SameSite=Lax 或 Strict。
  3. 检查 POST 响应是否包含 Set-Cookie。
  4. 确认新的匿名 session cookie 是否覆盖了旧 cookie。
  5. 检查 POST 后的跳转状态码。优先使用 303,避免使用会保留 POST 方法的 307 或 308。
  6. 确认域名、协议以及 cookie 的 Domain、Path、Secure 属性一致。
  7. 检查反向代理是否正确传递 HTTPS 信息,防止应用错误生成非安全 cookie。

如果 POST 请求没有原 session cookie,而 GET 请求携带了该 cookie,基本可以确认问题来自 SameSite 行为。如果 POST 已经携带正确的 cookie,则需要继续检查 session 存储、负载均衡 sticky session、共享 session backend、cookie domain 和响应覆盖问题。

需要特别注意的边界情况

即使采用上述方案,也无法保证原登录 session 一直存在。客户可能在支付过程中清除 cookie、切换浏览器、使用隐私模式,或者 session 已经自然过期。出现这些情况时,不能只根据 provider 返回的数据自动登录客户。

完成页可以通过短期 return token 展示有限的支付结果,也可以要求客户重新登录后查看订单。支付状态应根据订单和 transaction reference 恢复,而不是根据客户 session 恢复。

需要避免的问题并不是任何情况下都出现匿名 session,而是跨站 POST 意外创建新 session、覆盖原 cookie,并把支付状态处理错误地绑定到浏览器的登录状态。

备注:内容仅供参考。