12k
All articles

如何在单页应用中处理 3D Secure

了解如何在使用 Stripe 的单页应用中处理 3D Secure:保存结账状态、重建返回路由,并在服务器端验证 PaymentIntent。

OpenReplay Team
OpenReplay Team
如何在单页应用中处理 3D Secure

要在单页应用(SPA)中处理 3D Secure,需要在跳转至银行验证之前保存购物车和 PaymentIntent ID,并将客户重定向回一个专用的返回路由。在该路由上,从服务器重建结账状态,并在服务器端确认支付结果后再进行履约。

这个问题通常表现为:客户点击”支付”,跳转到银行页面完成验证后返回,却看到购物车已被清空,或者加载图标一直转个不停。部分客户随后会重复付款。本文将介绍 Stripe Payment Intents API 中关于返回 URL 的规则、iframe 方案、服务器端确认,以及覆盖各条路径所需的测试卡。

要点速览

  • 整页重定向到银行页面会导致 SPA 被卸载,因此客户返回时,所有仅保存在内存中的购物车或结账状态都会丢失。
  • Stripe 会在你的 return_url 上附加 payment_intent 和 payment_intent_client_secret 参数。它们只用于标识是哪一个 PaymentIntent,并不表示支付是否成功。
  • 返回路由应使用 URLSearchParams 读取 ID,向服务器查询支付状态,并且绝不能为已经成功的 intent 再次显示支付按钮。
  • 3D Secure iframe 不得设置 sandbox 属性,且 CSP 必须允许来自 https://js.stripe.com、https://hooks.stripe.com 以及 return_url 所在源的框架。
  • 只有在服务器端获取(retrieve)PaymentIntent 或收到 payment_intent.succeeded webhook 之后,才可以进行订单履约。

什么是 3D Secure?

3D Secure(3DS)是发卡行在线上支付过程中执行的一项验证,用于确认付款人确实是持卡人本人。有时验证在后台静默完成;有时则需要客户主动操作,例如输入发送到手机上的一次性验证码。Stripe 的强客户认证指南指出,SCA(强客户认证)是英国和欧洲针对客户主动发起的支付所制定的法规,而 Stripe 的 SCA 合规准备页面则明确指出,3D Secure 是银行卡支付满足该法规要求的方式。在实践中,这意味着当商户和发卡行均位于欧洲经济区(EEA)或英国时,大多数线上银行卡支付都必须进行 3DS 验证。

数字钱包是例外。Stripe 的 3DS 认证指南将数字钱包支付和 off-session 支付(客户不在场时发起的支付)列为不支持 3DS 的交易类型。这也是浏览器原生钱包流程(如 Payment Request API)通常会跳过挑战(challenge)步骤的原因之一。

为什么 SPA 在 3D Secure 过程中会丢失状态?

单页应用在 3D Secure 过程中丢失状态,是因为结账流程会离开当前页面:要么客户跳转到银行网站,要么在确认完成后由 Stripe 将客户重定向到你的 return_url。客户返回时,应用必须从中断的地方继续,但此时路由状态、store 和组件树都已不复存在。返回的实际上是在 return_url 上的一次冷启动。

即便支付成功,同样会离开页面。默认情况下,stripe.confirmPayment 会在确认完成后立即将客户重定向到 return_url,因此它的 Promise 在当前页面上永远不会 settle,await 之后的代码也永远不会执行。

持久化状态并设置专用的 return_url

Stripe 3D Secure 重定向完成后,客户会携带 payment_intent 和 payment_intent_client_secret 两个查询参数回到你的 return_url。这两个参数用于标识 PaymentIntent,但并不说明支付是否成功。return_url 是 Stripe 将客户送回的页面,应将其指向一个专门用于此用途的路由。

使用 Payment Element 时,stripe.confirmPayment 要么在对话框中展示 3DS 验证,要么将客户跳转到银行页面。默认情况下,确认完成后它会整页重定向到 return_url。如果希望银行卡支付跳过这次重定向,可以传入 redirect: 'if_required'。但基于重定向的支付方式仍然会离开页面,此时你需要在代码中自行处理成功结果。

import type { Stripe, StripeElements } from '@stripe/stripe-js';

const PENDING_KEY = 'checkout:pending';

export async function pay(
  stripe: Stripe,
  elements: StripeElements,
  cartId: string,
  paymentIntentId: string,
): Promise<void> {
  sessionStorage.setItem(PENDING_KEY, JSON.stringify({ cartId, paymentIntentId }));

  const { error } = await stripe.confirmPayment({
    elements,
    confirmParams: { return_url: `${window.location.origin}/checkout/return` },
  });

  if (error) {
    showError(error.message ?? 'Payment failed');
    enableForm();
  }
}

只有在确认立即失败时,代码才会执行到 await 之后。此时客户仍停留在当前页面,因此应显示错误信息并重新启用表单。sessionStorage 中的数据在同一标签页内的页面跳转和刷新后依然保留。应将其用作指向购物车的”指针”,而购物车数据本身应保存在服务器端。

基于服务器数据构建返回路由

返回路由从查询字符串中读取 PaymentIntent ID,从地址栏中移除 client secret,然后向后端查询支付结果。它渲染的任何内容都不依赖内存中的数据。请使用 URLSearchParams 解析查询字符串,不要截取第一个 = 之后的子串,因为一旦 URL 中带有第二个参数,这种做法就会出错。

type PiStatus =
  | 'succeeded' | 'processing' | 'requires_capture'
  | 'requires_payment_method' | 'requires_action'
  | 'requires_confirmation' | 'canceled';

export async function loadReturnState(): Promise<PiStatus | null> {
  const params = new URLSearchParams(window.location.search);
  const pending = JSON.parse(sessionStorage.getItem(PENDING_KEY) ?? 'null') as
    | { paymentIntentId: string }
    | null;
  const id = params.get('payment_intent') ?? pending?.paymentIntentId;
  if (!id) return null;

  history.replaceState(null, '', window.location.pathname);

  const res = await fetch(`/api/payments/${encodeURIComponent(id)}`);
  if (!res.ok) throw new Error(`Status lookup failed: ${res.status}`);
  const { status } = (await res.json()) as { status: PiStatus };
  return status;
}

如果查询字符串已经不存在(例如页面刷新之后),该路由会回退使用保存在 sessionStorage 中的指针。history.replaceState 会将 secret 从地址栏和当前历史记录条目中移除。请尽早执行这一步,赶在数据分析或会话回放脚本读取 URL 之前,确保它们永远不会记录到 secret。将每种状态映射到对应的界面:

状态返回界面
succeeded订单确认页,清除 pending 键
requires_capture确认页(如果你采用授权与扣款分离的模式)
processing显示”正在确认支付”,随后再次轮询服务器
requires_payment_method支付失败,重建购物车并请客户更换银行卡
requires_action客户可能仍在验证中,也可能已经离开,提供继续支付的选项
canceled支付已取消,开始新的结账流程

在允许客户再次支付之前,先检查现有 intent 的状态。依赖内存重建状态的返回路由,可能会为一个已经成功的 intent 显示新的”支付”按钮。由于故障发生在页面卸载前后,错误日志很少能将结账、银行验证和返回这几个环节串联起来。而对返回访问进行会话回放,就能看到客户实际看到的画面:空购物车、永不结束的加载图标,或是第二个支付按钮。

3D Secure 应该使用重定向还是 iframe?

整页重定向是默认且最简单的方案。iframe 方案可以让 SPA 保持加载状态,但需要你自行实现更多功能,而且仅适用于银行卡支付。在 iframe 方案中,你需要在关闭自动操作处理的情况下进行确认,读取 next_action(Stripe 指示客户必须完成的步骤),并在框架中加载 next_action.redirect_to_url.url。

重定向Iframe
验证发生位置银行页面,顶层窗口银行页面,嵌在你的模态框中
SPA 是否卸载是否
需要自行实现返回路由框架、postMessage 页面、监听器
对 CSP 的影响无需为框架配置需添加 frame-src 条目
回退方案不需要重定向

以下代码片段将 Payment Element 中的银行卡信息转换为 PaymentMethod,然后在关闭 Stripe 自带 3DS 处理的情况下进行确认。为此,创建 Elements 实例时需要传入 paymentMethodCreation: 'manual'。Stripe 的 Elements 参考文档指出,正是这个选项让 stripe.createPaymentMethod 能够基于 Payment Element 创建 PaymentMethod。此外,你还必须先调用 elements.submit() 来校验表单。

const elements = stripe.elements({ clientSecret, paymentMethodCreation: 'manual' });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');

// When the customer clicks "Pay":
const { error: submitError } = await elements.submit();
if (submitError) {
  showError(submitError.message ?? 'Check your card details');
  return;
}

const { paymentMethod, error: pmError } = await stripe.createPaymentMethod({ elements });
if (pmError) {
  showError(pmError.message ?? 'Payment failed');
  return;
}

const { paymentIntent, error } = await stripe.confirmCardPayment(
  clientSecret,
  { payment_method: paymentMethod.id, return_url: `${location.origin}/checkout/3ds-done` },
  { handleActions: false },
);
if (error) {
  showError(error.message ?? 'Payment failed');
  enableForm();
  return;
}

const action = paymentIntent?.next_action;
if (paymentIntent?.status === 'requires_action' && action?.redirect_to_url?.url) {
  const frame = document.createElement('iframe');
  frame.src = action.redirect_to_url.url;
  frame.width = '600';
  frame.height = '400';
  container.appendChild(frame);
}

/checkout/3ds-done 页面会执行 window.top?.postMessage('3ds-complete', window.location.origin)。按照 MDN 关于 postMessage 的建议,父页面在执行任何操作之前,先校验 event.origin 是否与自身的源一致,然后移除框架,并向服务器查询支付状态。

不要在 3D Secure iframe 上设置 sandbox 属性。框架中加载的部分内容由发卡行控制,Stripe 的 3DS 指南指出,某些发卡行页面在沙箱环境下会无法正常运行,从而导致支付失败。如果你配置了内容安全策略(CSP),其 frame-src 指令必须允许 https://js.stripe.com、https://hooks.stripe.com 以及 return_url 所在的源。同时还应为客户提供备用出口:一个调用 window.location.assign(url) 的”打开银行页面”链接。在这种情况下,银行会以整页形式将客户送回 /checkout/3ds-done,因此该页面需要检查 window.top === window 是否成立,如果成立,则携带相同的查询字符串跳转到 /checkout/return。这样客户就会经过与重定向流程相同的返回路由。

为什么必须在服务器端确认支付结果?

完成 3D Secure 后跳转到 return_url,并不能说明任何支付结果。任何人都可以在该 URL 中手动输入查询字符串。履约只能依据服务器端的 retrieve 结果或 webhook。

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

app.get('/api/payments/:id', async (req, res) => {
  const intent = await stripe.paymentIntents.retrieve(req.params.id);
  res.json({ status: intent.status });
});

在返回响应之前,请检查该 intent 是否属于当前会话的购物车。Stripe 的支付状态更新指南建议通过 webhook 而非轮询来驱动履约。监听 payment_intent.succeeded 事件以完成订单履约,监听 payment_intent.payment_failed 事件以通知客户。关于 endpoint 端的实现,可参阅 OpenReplay webhooks 指南。通过 3DS 验证后,符合条件的欺诈争议成本通常会转移给发卡行,但 Stripe 明确表示,这一点永远无法保证。

如何测试 3D Secure 流程?

Stripe 的测试卡可以触发各个分支。CVC、邮政编码可任意填写,有效期填写任意未来日期即可:

卡号行为
4000000000003220始终要求 3DS2 验证
4000008400001629要求 3DS 验证,随后以 card_declined 被拒
4000000000003055支持 3DS,但不强制要求
4242424242424242支持 3DS,但该卡未注册 3DS,因此不会弹出验证

使用测试密钥时,Stripe 会显示一个模拟银行页面,提供通过或不通过验证的按钮。请通过你自己的前端进行测试,因为在 Stripe Dashboard 中创建的支付会跳过 3DS 重定向。对于每张测试卡,在返回路由加载完成后刷新一次页面,它仍应显示正确的界面。

总结

在 SPA 中,3D Secure 的银行跳转本质上是一次页面导航,而返回路由则是一次冷启动。在确认支付之前持久化指向购物车的指针,在客户返回时从服务器重建状态,并将重定向视为”去查询状态”的请求,而绝不是支付成功的凭证。下一步,添加一个 /checkout/return 路由,用全部四张测试卡跑一遍流程,并确认没有任何一种情况会为已成功的 intent 显示支付按钮。

常见问题

3D Secure 2 中的无感验证流程(frictionless)与挑战流程(challenge)有什么区别?

在无感验证流程中,发卡行在后台完成支付验证,客户不会看到任何额外步骤。在挑战流程中,客户必须主动操作,例如输入一次性验证码。Stripe 的 SCA 指南指出,成功通过的无感 3DS 验证同样会将欺诈责任转移给发卡行。而如果改为应用 SCA 豁免,欺诈争议的责任则仍由商户承担。

如何在 Stripe 支付中强制启用 3D Secure?

在创建或确认 PaymentIntent 时,将 payment_method_options[card][request_three_d_secure] 设置为 'any' 或 'challenge'。通常情况下,Stripe 会借助 Radar 根据风险判断何时要求 3DS 验证。设置该参数后,Stripe 会对该笔支付尝试进行 3DS 验证,你的 Radar 动态 3DS 规则将不再对其生效。'any' 倾向于无感验证流程,'challenge' 倾向于挑战流程,但最终由发卡行决定。Stripe 表示,手动触发功能面向的是运行自有反欺诈引擎的团队。

如果客户在 3D Secure 过程中放弃结账,应该如何处理?

客户返回时,应复用现有的 PaymentIntent,而不是新建一个。Stripe 建议在中断的结账流程恢复时继续使用同一个 PaymentIntent,如果购物车有变化,则更新其金额。被放弃的验证通常会使 intent 停留在 requires_action 状态。请先在服务器端检查其状态,如果已经显示为 succeeded 或 processing,则展示订单状态,而不是支付表单。

Stripe 将 client secret 放在返回 URL 中是否安全?

请将 client secret 视为敏感信息。Stripe 的 API 参考文档警告称,仅凭该 secret 就足以在浏览器中完成支付,因此只应让客户本人看到它,并且绝不能存储或记录到日志中。读取 payment_intent 参数后,使用 history.replaceState 清除查询字符串。请尽早执行这一步,赶在数据分析或会话回放脚本读取 URL 之前,确保它们永远不会记录到该 secret。结账页面应通过 TLS 提供服务。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.