如何使用 Core PHP 和 Curl 集成 3D Secure
结论
3D Secure 不能通过一次 curl 请求直接“附加”到现有支付接口中。它是独立的持卡人认证流程,通常涉及以下环节:
- PHP 服务端调用 Centinel/Cardinal 的认证接口;
- 浏览器收集设备信息,或跳转到发卡行的挑战页面;
- 认证完成后,服务端把
ECI、CAVV、交易 ID 等结果传给现有支付网关; - 支付网关使用这些认证数据完成授权或扣款。
具体的接口地址、字段名和签名方式,取决于所用的 Cardinal/Centinel 产品版本和支付网关。下面的代码只展示通用集成结构,其中的占位字段必须根据供应商文档替换,不能直接用于生产环境。
推荐的处理流程
典型的 3D Secure 2 流程如下:
创建订单
↓
服务端初始化 3DS
↓
浏览器执行设备信息收集
↓
服务端发起认证
↓
无感认证成功 ───────────────┐
↓ │
需要 Challenge │
↓ │
浏览器打开银行认证页面 │
↓ │
银行回调商户 │
└───────────────────────┤
↓
服务端查询并验证认证结果
↓
携带 ECI/CAVV 等数据请求支付授权
不要根据浏览器传回的参数判断支付是否成功。浏览器回调只能说明用户完成了交互,最终认证状态需要由服务端向 3DS 服务商查询或验证。
通用 cURL 请求函数
可以先封装一个专门处理 HTTPS JSON 请求的函数:
<?php
function postJson(string $url, array $payload, array $headers = []): array
{
$ch = curl_init($url);
$defaultHeaders = [
'Content-Type: application/json',
'Accept: application/json',
];
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => array_merge($defaultHeaders, $headers),
CURLOPT_POSTFIELDS => json_encode(
$payload,
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
),
]);
$body = curl_exec($ch);
if ($body === false) {
$message = curl_error($ch);
curl_close($ch);
throw new RuntimeException('cURL request failed: ' . $message);
}
$statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
if ($statusCode < 200 || $statusCode >= 300) {
throw new RuntimeException(
sprintf('Remote API returned HTTP %d: %s', $statusCode, $body)
);
}
return $decoded;
}
生产环境还需要加入请求追踪 ID、脱敏日志、重试策略和幂等控制。不要记录完整卡号、CVV、认证令牌或完整的 CAVV。
第一步:创建本地订单
调用 3DS 接口前,先生成不可重复的订单号,并在服务端固定保存金额:
<?php
session_start();
$orderId = bin2hex(random_bytes(16));
$amountMinor = 1099; // 10.99,使用最小货币单位
$currency = 'EUR';
$_SESSION['orders'][$orderId] = [
'amount_minor' => $amountMinor,
'currency' => $currency,
'status' => 'PENDING_3DS',
];
处理回调时,必须从数据库或服务端会话中重新读取金额,不能接受浏览器重新提交的金额。
实际项目应把订单持久化到数据库,并通过事务或条件更新修改订单状态,防止重复支付。
第二步:初始化 3D Secure
不同版本的 Cardinal/Centinel API 差异较大。下面的请求只用于说明数据结构和调用位置:
<?php
$threeDsInit = postJson(
'https://3ds-provider.example.com/api/init',
[
'merchantOrderId' => $orderId,
'amount' => $amountMinor,
'currency' => $currency,
'returnUrl' => 'https://merchant.example.com/3ds/callback.php',
],
[
'Authorization: Bearer ' . getenv('THREEDS_API_TOKEN'),
'Idempotency-Key: 3ds-init-' . $orderId,
]
);
$threeDsTransactionId = $threeDsInit['transactionId'] ?? null;
$deviceDataToken = $threeDsInit['deviceDataToken'] ?? null;
if (!$threeDsTransactionId) {
throw new RuntimeException('3DS initialization did not return a transaction ID.');
}
$_SESSION['orders'][$orderId]['three_ds_transaction_id']
= $threeDsTransactionId;
如果供应商要求浏览器采集设备数据,需要把服务端返回的 JWT、token 或表单参数交给前端处理。不要自行猜测 Cardinal 的字段,应以当前账户对应的官方集成文档和测试环境示例为准。
第三步:发起认证
设备数据收集完成后,由服务端提交订单、账单地址和浏览器信息。为了便于阅读,示例使用支付令牌表示卡片信息:
<?php
$authentication = postJson(
'https://3ds-provider.example.com/api/authenticate',
[
'transactionId' => $threeDsTransactionId,
'merchantOrderId' => $orderId,
'paymentMethodToken' => $paymentMethodToken,
'amount' => $amountMinor,
'currency' => $currency,
'billingAddress' => [
'firstName' => 'Test',
'lastName' => 'Customer',
'address1' => '1 Example Street',
'city' => 'London',
'postalCode' => 'SW1A 1AA',
'countryCode' => 'GB',
],
'browser' => [
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'acceptHeader' => $_SERVER['HTTP_ACCEPT'] ?? '',
'language' => 'en-GB',
'javaEnabled' => false,
'javascriptEnabled' => true,
'colorDepth' => 24,
'screenWidth' => 1920,
'screenHeight' => 1080,
'timeZone' => 0,
],
'returnUrl' => 'https://merchant.example.com/3ds/callback.php',
],
[
'Authorization: Bearer ' . getenv('THREEDS_API_TOKEN'),
'Idempotency-Key: 3ds-auth-' . $orderId,
]
);
屏幕尺寸、时区、JavaScript 状态等浏览器字段通常由前端采集,再提交给 PHP。示例中的固定值只表示字段所在的位置,不能当作真实数据发送。
认证响应通常分为两种:
- 无感认证已经完成,可以进入支付授权;
- 需要持卡人完成 Challenge,此时应打开供应商返回的挑战页面。
各供应商使用的响应状态名称并不统一,请以 Centinel/Cardinal 当前版本的文档为准。
第四步:处理 Challenge
如果认证接口返回挑战地址和挑战参数,可以生成自动提交的 HTML 表单:
<?php
function renderChallengeForm(string $url, array $fields): void
{
$safeUrl = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
echo '<!doctype html>';
echo '<html lang="zh-CN"><head><meta charset="utf-8">';
echo '<title>正在验证</title></head>';
echo '<body>';
echo '<form id="challenge-form" method="post" action="' . $safeUrl . '">';
foreach ($fields as $name => $value) {
echo '<input type="hidden" name="'
. htmlspecialchars((string) $name, ENT_QUOTES, 'UTF-8')
. '" value="'
. htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8')
. '">';
}
echo '</form>';
echo '<script>document.getElementById("challenge-form").submit();</script>';
echo '</body></html>';
}
具体调用方式取决于供应商的响应。例如:
<?php
if (($authentication['action'] ?? null) === 'CHALLENGE') {
$_SESSION['orders'][$orderId]['status'] = '3DS_CHALLENGE';
renderChallengeForm(
$authentication['challengeUrl'],
$authentication['challengeFields']
);
exit;
}
部分 3DS2 方案要求在 iframe 中完成 Challenge,并对 iframe 尺寸、postMessage、内容安全策略和返回地址作出明确规定。实现时应严格遵循供应商提供的前端 SDK 或集成规范。
第五步:处理回调并查询认证结果
回调脚本不能直接信任 POST 中的认证状态。应使用之前保存的交易 ID 向服务商查询:
<?php
session_start();
$orderId = $_GET['order_id'] ?? $_POST['order_id'] ?? '';
if (
$orderId === ''
|| !isset($_SESSION['orders'][$orderId])
) {
http_response_code(400);
exit('Invalid order.');
}
$order = $_SESSION['orders'][$orderId];
$threeDsTransactionId = $order['three_ds_transaction_id'];
$result = postJson(
'https://3ds-provider.example.com/api/result',
[
'transactionId' => $threeDsTransactionId,
'merchantOrderId' => $orderId,
],
[
'Authorization: Bearer ' . getenv('THREEDS_API_TOKEN'),
]
);
$authenticationStatus = $result['authenticationStatus'] ?? null;
if ($authenticationStatus !== 'SUCCESS') {
$_SESSION['orders'][$orderId]['status'] = '3DS_FAILED';
exit('Authentication failed or could not be verified.');
}
SUCCESS 也只是示意值。真实系统可能返回协议定义的状态码,例如成功、尝试认证、失败、无法认证或需要挑战。每种状态是否允许继续支付,都必须按照支付网关和收单机构的规则处理。
第六步:将认证数据交给支付网关
3DS 认证成功后,再调用原有的支付接口,并附上网关要求的认证数据:
<?php
$paymentRequest = [
'merchantOrderId' => $orderId,
'amount' => $order['amount_minor'],
'currency' => $order['currency'],
'paymentMethodToken' => $paymentMethodToken,
'threeDSecure' => [
'version' => $result['protocolVersion'] ?? null,
'eci' => $result['eci'] ?? null,
'cavv' => $result['cavv'] ?? null,
'dsTransactionId' => $result['dsTransactionId'] ?? null,
'authenticationTransactionId'
=> $result['authenticationTransactionId'] ?? null,
],
];
$paymentResult = postJson(
'https://payment-gateway.example.com/api/authorize',
$paymentRequest,
[
'Authorization: Bearer ' . getenv('PAYMENT_API_TOKEN'),
'Idempotency-Key: payment-' . $orderId,
]
);
if (($paymentResult['status'] ?? null) === 'APPROVED') {
$_SESSION['orders'][$orderId]['status'] = 'PAID';
echo 'Payment completed.';
} else {
$_SESSION['orders'][$orderId]['status'] = 'PAYMENT_FAILED';
echo 'Payment was not approved.';
}
支付网关可能使用不同的字段名,例如:
ECI或eci;CAVV、AAV或authenticationValue;dsTransID;threeDSServerTransID;XID,主要见于旧版 3DS1;- 3DS 协议版本;
- 认证响应状态。
这些字段不能自行映射。必须先向支付网关确认,它是否支持外部 3DS 服务商,以及需要接收哪些认证结果。如果网关只接受由自身完成的 3DS 流程,Centinel 的认证结果可能无法直接用于授权。
如果使用的是旧版 3DS1
旧版流程通常围绕 PaReq、PaRes、TermUrl 和 MD 展开:
服务端执行 Enrollment Check
↓
浏览器向 ACS 提交 PaReq、TermUrl、MD
↓
ACS 向 TermUrl 返回 PaRes 和 MD
↓
服务端验证 PaRes
↓
携带 ECI、CAVV、XID 发起支付
3DS1 已经属于旧协议,许多地区和卡组织已转向 3DS2。只有现有供应商和收单渠道明确要求时,才需要继续维护这套流程。新项目不建议自行从头实现。
需要特别注意的问题
不要自己保存或记录卡片敏感数据
如果 PHP 服务端直接接收完整卡号和 CVV,PCI DSS 合规范围会明显扩大。应优先使用支付网关提供的 Hosted Fields、托管支付页或前端令牌化方案,让服务端只处理 payment token。
不要把 CVV 写入数据库、日志、异常信息或会话。
3DS 成功不等于支付成功
3DS 只负责持卡人认证。即使认证成功,支付仍可能因余额不足、风控拒绝、卡片状态异常或收单行拒绝而失败。订单状态至少需要区分:
PENDING_3DS
3DS_CHALLENGE
3DS_FAILED
PENDING_PAYMENT
PAID
PAYMENT_FAILED
必须实现幂等
用户刷新回调页面、银行重复通知或网络重试,都可能让同一个订单被多次授权。初始化认证和支付授权应分别使用稳定且唯一的幂等键,服务端也要检查订单是否已经支付。
校验金额、币种和交易关联关系
最终支付前,需要确认认证结果中的订单号、金额和币种与本地订单完全一致,同时检查 3DS transaction ID 是否属于当前订单。
不要关闭 TLS 验证
不能通过下面的配置绕过证书检查:
CURLOPT_SSL_VERIFYPEER => false
生产环境必须开启证书和主机名验证,并妥善保管 API 密钥。
最稳妥的接入方式
开始编码前,先向 Centinel/Cardinal 和支付网关确认三个问题:
- 当前账户使用 3DS1、3DS2,还是同时兼容两者的方案;
- 支付网关是否接受外部 3DS 认证结果;
- 网关要求提交哪些字段,以及各卡组织的字段如何映射。
如果支付网关已经提供 3DS2 接口或 SDK,通常直接使用网关的一体化流程更可靠。认证结果和支付授权由同一家服务商关联,可以减少字段映射错误、认证结果不被收单行接受,以及责任转移判断不一致等问题。
备注:内容仅供参考。
