如何在 2C2P payment gateway 中处理 OTP Verification
结论
transStatus === 'C' 表示 3-D Secure 2 要求持卡人完成 challenge。此时交易仍在处理中,不能把订单标记为已付款。
应按以下流程处理:
- 保存 2C2P 交易标识,订单状态保持为
pending或on-hold。 - 让用户的浏览器向
acsURL发送 POST 请求。 - 将
rawCreq作为creq提交,同时提交threeDSSessionData。 - 用户在发卡行的 ACS 页面完成 OTP 或其他身份验证。
- challenge 返回后,通过 2C2P 的服务器通知或 Payment Inquiry/交易状态查询接口确认最终状态。
- 只有 2C2P 明确返回最终成功状态,才能调用 WooCommerce 的
payment_complete()。
OTP 的输入、校验和重试均由发卡行的 ACS 页面处理,商户系统不应自行收集 OTP。
响应字段分别有什么作用
acsURL
这是发卡行 ACS 的 challenge 地址。浏览器必须通过 POST 访问,不能由 PHP 后端代为请求,也不能简单地使用 GET 重定向。
rawCreq
这是实际的 EMV 3-D Secure CReq 消息。提交给 ACS 时,字段名通常为小写的 creq:
<input type="hidden" name="creq" value="...">
不要再次对 rawCreq 进行 Base64 编码,也不要先将其解码成 JSON,修改后再重新编码。
threeDSSessionData
这是商户生成或由 2C2P 返回的会话关联值。它会随 challenge 往返,用于将 ACS 返回的结果关联到原订单。
这个值不能证明支付成功,不能仅凭它更新订单状态。
CReq 和 ChallengeHtml
从示例内容判断,这两个字段不是单纯的 CReq JSON,而是经过 Base64 URL-safe 编码的完整 HTML。解码后大致是一个自动提交的表单,其中已经包含:
<form method="POST" action="ACS_URL">
<input type="hidden" name="creq" value="RAW_CREQ">
<input type="hidden" name="threeDSSessionData" value="SESSION_DATA">
</form>
启动 challenge 有两种方式:
- 使用
acsURL、rawCreq和threeDSSessionData自行生成 POST 表单; - 根据当前 2C2P 接口文档的要求,解码并呈现
ChallengeHtml。
建议优先采用第一种方式。各字段的边界更清楚,也不需要直接执行接口返回的整段 HTML。两种方式不能同时使用,也不要把示例中 CReq 字段的整段内容作为 creq 提交,否则 ACS 收到的是 HTML,而不是 CReq 消息。
WooCommerce 中的处理方式
process_payment() 不适合直接输出 challenge HTML。WooCommerce 期望这个方法返回支付结果和跳转地址。可以先把 challenge 数据暂存到订单元数据,再跳转到站内的 challenge 页面,由该页面生成自动提交表单。
以下是流程示例。具体的路由注册方式、状态查询方法和字段名,需要根据实际使用的 2C2P SDK/API 版本调整。
public function process_payment( $order_id ) {
$order = wc_get_order( $order_id );
$result = $this->request->result( $order );
$response_code = $result->response->ApiResponse->ResponseCode ?? '';
$incomplete = $result->response->Data->paymentIncompleteResult ?? null;
$challenge = $incomplete->aresACSChallenge ?? null;
$pending_codes = array(
'PC-B050000',
'PC-B050001',
'PC-B050002',
);
if (
in_array( $response_code, $pending_codes, true )
&& $challenge
&& isset( $challenge->transStatus )
&& 'C' === $challenge->transStatus
) {
if (
empty( $challenge->acsURL )
|| empty( $challenge->rawCreq )
|| empty( $challenge->threeDSSessionData )
) {
throw new Exception(
'2C2P requested a 3DS challenge but did not return all required challenge fields.'
);
}
$order->update_status(
'on-hold',
'Waiting for the customer to complete 3DS authentication.'
);
$order->update_meta_data( '_2c2p_acs_url', (string) $challenge->acsURL );
$order->update_meta_data( '_2c2p_raw_creq', (string) $challenge->rawCreq );
$order->update_meta_data(
'_2c2p_three_ds_session_data',
(string) $challenge->threeDSSessionData
);
if ( ! empty( $incomplete->InvoiceNo2C2P ) ) {
$order->update_meta_data(
'_2c2p_invoice_no',
(string) $incomplete->InvoiceNo2C2P
);
}
if ( ! empty( $incomplete->controllerInternalID ) ) {
$order->update_meta_data(
'_2c2p_controller_internal_id',
(string) $incomplete->controllerInternalID
);
}
$order->save();
return array(
'result' => 'success',
'redirect' => add_query_arg(
array(
'wc-api' => '2c2p_3ds_challenge',
'order_id' => $order->get_id(),
'order_key' => $order->get_order_key(),
),
home_url( '/' )
),
);
}
// 在这里继续处理无 challenge 的最终成功、失败或其他待处理状态。
}
challenge 页面需要验证订单和 order_key,然后输出 POST 表单:
public function render_3ds_challenge() {
$order_id = absint( $_GET['order_id'] ?? 0 );
$order_key = wc_clean( wp_unslash( $_GET['order_key'] ?? '' ) );
$order = wc_get_order( $order_id );
if ( ! $order || ! hash_equals( $order->get_order_key(), $order_key ) ) {
wp_die( 'Invalid payment session.', '', array( 'response' => 403 ) );
}
$acs_url = (string) $order->get_meta( '_2c2p_acs_url' );
$creq = (string) $order->get_meta( '_2c2p_raw_creq' );
$session = (string) $order->get_meta( '_2c2p_three_ds_session_data' );
if ( '' === $acs_url || '' === $creq || '' === $session ) {
wp_die( 'The 3DS challenge data is missing.' );
}
if ( 'https' !== wp_parse_url( $acs_url, PHP_URL_SCHEME ) ) {
wp_die( 'Invalid ACS URL.' );
}
nocache_headers();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex,nofollow">
<title>3DS authentication</title>
</head>
<body>
<p>Redirecting to your card issuer for verification…</p>
<form id="three-ds-challenge"
method="post"
action="<?php echo esc_url( $acs_url ); ?>">
<input type="hidden"
name="creq"
value="<?php echo esc_attr( $creq ); ?>">
<input type="hidden"
name="threeDSSessionData"
value="<?php echo esc_attr( $session ); ?>">
<noscript>
<button type="submit">Continue verification</button>
</noscript>
</form>
<script>
document.getElementById('three-ds-challenge').submit();
</script>
</body>
</html>
<?php
exit;
}
这里应在顶层页面提交 challenge。3DS challenge 虽然也可以放入 iframe,但发卡行的 CSP、第三方 Cookie 或浏览器限制可能导致 iframe 失败。除非 2C2P 的集成文档明确要求使用 iframe,否则顶层跳转通常更稳妥。
challenge 完成后如何确认支付结果
ACS 完成验证后,通常会将 cres 和 threeDSSessionData POST 到 3DS 流程预先登记的 notification/return URL。这个 URL 应在创建支付或认证请求时配置,不能等收到 transStatus = C 后再临时决定。
回调处理器至少需要完成以下工作:
- 读取
cres和threeDSSessionData。 - 根据服务器保存的 session 数据找到对应订单。
- 不把浏览器提交的
cres或前端跳转本身当作到账证明。 - 调用当前 2C2P API 版本提供的 Payment Inquiry/交易状态查询接口。
- 校验订单号、金额、币种、商户号和 2C2P 交易标识。
- 根据查询结果更新 WooCommerce 订单。
示意代码如下:
public function handle_3ds_return() {
$cres = isset( $_POST['cres'] )
? wc_clean( wp_unslash( $_POST['cres'] ) )
: '';
$session_data = isset( $_POST['threeDSSessionData'] )
? wc_clean( wp_unslash( $_POST['threeDSSessionData'] ) )
: '';
if ( '' === $session_data ) {
status_header( 400 );
exit( 'Missing threeDSSessionData.' );
}
$order = $this->find_order_by_3ds_session_data( $session_data );
if ( ! $order ) {
status_header( 404 );
exit( 'Payment session not found.' );
}
/*
* 这里调用 2C2P Payment Inquiry/交易状态查询接口。
* 请求参数、签名方式和最终状态字段必须以所用 API 版本的文档为准。
*/
$status = $this->query_2c2p_payment_status( $order );
if ( $this->is_final_success_status( $status ) ) {
if ( ! $order->is_paid() ) {
$transaction_id = $this->get_2c2p_transaction_id( $status );
$order->payment_complete( $transaction_id );
$order->add_order_note( '2C2P payment confirmed after 3DS authentication.' );
}
wp_safe_redirect( $this->get_return_url( $order ) );
exit;
}
if ( $this->is_final_failure_status( $status ) ) {
$order->update_status(
'failed',
'2C2P reported that 3DS authentication or payment failed.'
);
wp_safe_redirect( wc_get_checkout_url() );
exit;
}
// 查询结果仍在处理中,不要误判为失败或成功。
$order->update_status(
'on-hold',
'3DS returned, but 2C2P payment status is still pending.'
);
wp_safe_redirect( $order->get_checkout_payment_url() );
exit;
}
query_2c2p_payment_status()、is_final_success_status() 等方法只是流程占位符。不同 2C2P 产品和 API 版本使用的查询端点、签名格式及最终成功状态可能不同,不能只根据示例中的 PC-B050001 作出判断。
还需要实现 2C2P 的服务器端 payment notification/webhook。用户可能在完成 OTP 后关闭页面、断网,或者没有返回商户网站,因此最终订单状态不能依赖前端回跳。Webhook 和浏览器回调都应支持幂等处理,以免重复扣减库存、重复发送邮件或重复调用 payment_complete()。
如果必须使用 ChallengeHtml
如果当前 2C2P 接口文档明确要求渲染 ChallengeHtml,这个字段看起来使用了 Base64 URL-safe 编码,需要按以下方式解码:
function base64url_decode_strict( $value ) {
$value = strtr( $value, '-_', '+/' );
$value .= str_repeat( '=', ( 4 - strlen( $value ) % 4 ) % 4 );
return base64_decode( $value, true );
}
使用前至少需要确认解码成功:
$html = base64url_decode_strict( $challenge->ChallengeHtml );
if ( false === $html ) {
throw new Exception( 'Invalid 3DS ChallengeHtml encoding.' );
}
直接输出这段 HTML 会执行其中的表单和 JavaScript。因此,只能使用刚从可信 2C2P 响应中取得,并且已经通过签名或令牌验证的数据,不能接受客户端传入的 ChallengeHtml。如果可以取得 rawCreq,使用固定模板自行生成表单,更容易控制安全边界。
注意事项
PC-B050000、PC-B050001、PC-B050002在这里表示请求成功,但支付尚未完成,不是最终支付成功状态。PaymentStatus = I和PaymentStep = AC同样说明交易仍在认证流程中。- 不要在日志中记录完整的
CReq、ChallengeHtml、JWT、卡片信息或其他认证数据。问题中的日志只应在受控调试环境中使用。 - challenge 数据只能短期保存,并应遵守
paymentExpiryDateTime。数据过期后,不要再次提交旧的 CReq。 - 不要把 OTP 发送到商户服务器,也不要自行制作 OTP 输入框。
- 回调必须使用 HTTPS,并校验 2C2P 通知的签名、JWT 或文档规定的其他认证机制。
- 查询交易状态时,必须同时核对订单号、金额和币种,不能只检查一个 session 值。
- 示例中的
notificationURLs为空,需要检查创建支付请求时是否遗漏了 3DS notification/return URL,或者该 URL 是否已经在 2C2P 商户后台配置。具体配置字段以当前接入版本的官方文档为准。 challengeWindowSize = "03"通常对应 500×600 的 challenge 窗口,但页面布局不能以此作为判断交易结果的依据。
备注:内容仅供参考。
