PAYATHON 2026

如何使用 Core PHP 和 Curl 集成 3D Secure

支付老李

结论

3D Secure 不能通过一次 curl 请求直接“附加”到现有支付接口中。它是独立的持卡人认证流程,通常涉及以下环节:

  1. PHP 服务端调用 Centinel/Cardinal 的认证接口;
  2. 浏览器收集设备信息,或跳转到发卡行的挑战页面;
  3. 认证完成后,服务端把 ECI、CAVV、交易 ID 等结果传给现有支付网关;
  4. 支付网关使用这些认证数据完成授权或扣款。

具体的接口地址、字段名和签名方式,取决于所用的 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 和支付网关确认三个问题:

  1. 当前账户使用 3DS1、3DS2,还是同时兼容两者的方案;
  2. 支付网关是否接受外部 3DS 认证结果;
  3. 网关要求提交哪些字段,以及各卡组织的字段如何映射。

如果支付网关已经提供 3DS2 接口或 SDK,通常直接使用网关的一体化流程更可靠。认证结果和支付授权由同一家服务商关联,可以减少字段映射错误、认证结果不被收单行接受,以及责任转移判断不一致等问题。

备注:内容仅供参考。