PAYATHON 2026

ASP.NET MVC 3 支付确认页如何安全保存支付数据?

支付阿杰

结论

不要在 hidden fields、Session、TempData、日志或数据库中保存完整的信用卡号和 CVV/CVC。ASP.NET MVC 3 的 Session 默认没有可用于保护支付数据的加密机制。如果使用 State Server 或 SQL Server Session,数据还可能经过序列化后存入进程外的存储系统。

建议采用以下方案:

  1. 通过支付服务商的托管支付页、Hosted Fields 或 JavaScript SDK 接收卡号。
  2. 由支付服务商返回一次性 token、payment method id 或 transaction id。
  3. 应用在服务器端只保存这个标识,以及订单号、卡品牌和末四位。
  4. 用户确认后,服务器使用该标识发起授权或扣款。

如果支付服务商支持”授权后捕获”,也可以使用 Authorize/PreAuth 和 Capture/PostAuth,但必须妥善处理额度冻结、超时、取消及重复提交。

为什么不应依赖 Session

Session 的作用是在多个请求之间保存服务器端状态,不等于安全存储。

ASP.NET MVC 3 常见的 Session 模式包括:

  • InProc:数据保存在 Web 进程的内存中,默认没有字段级加密。
  • StateServer:数据序列化后保存在独立的状态服务中。
  • SQLServer:数据序列化后保存在数据库中。

Session cookie 通常只保存 Session ID,不包含 Session 中的具体数据。但如果攻击者窃取了 Session ID,仍然可能劫持会话。Session 后端、内存转储、日志、调试器以及运维权限也可能暴露敏感信息。

即使自行加密 Session 中的卡号,应用仍然会接触完整卡数据,并且能够将其解密,因此通常不会脱离 PCI DSS 合规范围。加密只能减少部分存储风险,无法取代访问控制、密钥管理、审计、网络隔离和数据生命周期管理。

TempData 也不能解决这个问题。MVC 3 默认通常使用 Session 实现 TempData,其安全属性没有根本变化。

推荐流程:先令牌化,再确认和付款

建议流程如下:

浏览器输入卡信息
        ↓
支付服务商的 Hosted Fields / SDK
        ↓
返回一次性 token 或 payment method id
        ↓
服务器创建待确认订单
        ↓
确认页只显示卡品牌和末四位
        ↓
用户单击 Confirm
        ↓
服务器根据订单号和 token 发起付款

确认页可以显示:

Name on Credit Card: Name
Credit Card Number: Visa ending in 1111
Expiration Date: 10/2031
Total to be Charged: $50.86

不要显示 CVV/CVC,即使只是原样回显。用户不需要长期核对这项信息,也不应把它写入页面、Session、数据库或日志。

服务端模型示例

具体字段名称由支付服务商决定,下面的代码仅用于说明整体结构:

public class PendingPayment
{
    public int OrderId { get; set; }
    public string PaymentToken { get; set; }
    public string CardBrand { get; set; }
    public string CardLast4 { get; set; }
    public string CardholderName { get; set; }
}

第一次提交时,浏览器应提交支付服务商生成的 token,而不是完整卡号:

public class PaymentForm
{
    public string CardholderName { get; set; }
    public string PaymentToken { get; set; }
}

控制器据此创建待确认订单:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult AddressAndPayment(PaymentForm formData)
{
    if (!ModelState.IsValid)
    {
        return View(formData);
    }

    Cart cart = GetCurrentCart();

    if (cart == null || !cart.Items.Any())
    {
        ModelState.AddModelError("", "Sorry, your cart is empty.");
        return View(formData);
    }

    // 必须根据服务器端商品数据重新计算,不能相信浏览器提交的金额。
    decimal total = CalculateTotalOnServer(cart);

    var paymentMethod = paymentGateway.GetPaymentMethod(formData.PaymentToken);

    var order = orderRepository.CreatePendingOrder(
        User.Identity.Name,
        cart,
        total,
        formData.PaymentToken,
        paymentMethod.Brand,
        paymentMethod.Last4);

    return View("Confirm", new ConfirmPaymentViewModel
    {
        OrderId = order.Id,
        CardholderName = formData.CardholderName,
        CardBrand = paymentMethod.Brand,
        CardLast4 = paymentMethod.Last4,
        Total = total
    });
}

确认页面只提交订单标识:

@using (Html.BeginForm("Confirm", "Checkout", FormMethod.Post))
{
    @Html.AntiForgeryToken()
    @Html.HiddenFor(model => model.OrderId)

    <button type="submit">Confirm</button>
}

收到确认请求后,服务器根据当前用户和订单号重新读取订单数据:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Confirm(int orderId)
{
    var order = orderRepository.GetPendingOrderForUser(
        orderId,
        User.Identity.Name);

    if (order == null)
    {
        return HttpNotFound();
    }

    if (order.Status == OrderStatus.Paid)
    {
        return RedirectToAction("Complete", new { id = order.Id });
    }

    if (order.Status != OrderStatus.PendingPayment)
    {
        return View("PaymentUnavailable");
    }

    decimal amount = RecalculateAndValidateOrderTotal(order);

    var result = paymentGateway.Charge(
        order.PaymentToken,
        amount,
        order.Currency,
        order.Id.ToString());

    if (!result.Succeeded)
    {
        ModelState.AddModelError("", result.DisplayMessage);
        return View("Confirm", BuildConfirmViewModel(order));
    }

    orderRepository.MarkPaid(order.Id, result.TransactionId);

    return RedirectToAction("Complete", new { id = order.Id });
}

paymentGateway 的具体方法需要按照所选支付服务商的 API 调整。有些 token 只能使用一次,也可能在几分钟后失效,具体规则应以服务商文档为准。

PreAuth/PostAuth 是否合适

这种方案通常比应用自行保存卡号更合适,不过需要分清以下三个动作:

  • Tokenization:将卡信息换成 token,通常不会冻结额度。
  • Authorization/PreAuth:验证交易并冻结相应额度。
  • Capture/PostAuth:对已经授权的金额执行实际扣款。

如果只是让用户在最后一步确认,通常可以采用以下顺序:

Tokenization → 显示确认页 → Authorization/Capture

这样,在用户单击 Confirm 之前不会冻结额度。

如果业务需要提前确认资金是否可用,也可以采用:

Authorization → 显示确认页 → Capture

此时需要处理以下问题:

  • 用户放弃确认时,应执行 void/cancel,不能让授权冻结继续保留。
  • 授权有有效期,不能无限期等待捕获。
  • 捕获金额可能受到原授权金额的限制。
  • 银行可能会向用户显示一笔待处理交易。
  • 用户刷新页面或重复单击时,不能再次授权或捕获。
  • void、capture 和过期规则由支付服务商及卡组织决定。

如果服务商赋予 PreAuth 不同的含义,应以其 API 文档为准,不能只根据名称判断其行为。

如果暂时无法使用令牌化

只有当支付服务商要求服务器直接接收 PAN 时,才应考虑在服务器端短暂保存卡数据。这样会使应用进入更严格的支付卡合规范围,相关设计应交由具备相应经验的安全人员负责。

至少需要做到:

  • 全程使用 HTTPS。
  • 仅在完成交易所需的最短时间内保留数据。
  • 使用经过审查的加密方案和独立的密钥管理系统。
  • 客户端只保存高强度、不可预测的一次性引用 ID。
  • 将引用 ID 与当前用户、订单和过期时间绑定。
  • 支付成功、失败超限或超时后,立即删除临时数据。
  • 禁止记录请求正文、模型内容、卡号和 CVV。
  • 授权后不得保存 CVV/CVC,即使经过加密也不能保留。
  • 限制后台、数据库、缓存和监控系统的访问权限。

不要直接使用通用 Session 代替这类临时存储。即使数据已经加密,仍然需要完整的密钥轮换、权限隔离、审计和失效机制。

还需要修正的几个问题

原来的 return View("Confirm", viewModel) 只会在第一次 POST 时直接渲染确认视图。用户提交确认表单后会产生一个新请求,前一个请求中的 formData 不会自动保留下来。如果不使用隐藏字段,就必须通过服务器端订单记录或支付服务商 token 重新获取所需信息。

还需要注意以下事项:

  • 不要接受客户端提交的最终金额,应根据服务器端的商品价格重新计算。
  • 不要把整个 Cart 作为可信的支付依据。
  • 使用 [ValidateAntiForgeryToken] 防止跨站请求伪造。
  • 查询订单时必须同时校验当前用户,不能只根据可能被猜出的 orderId 查询。
  • 为确认操作设置幂等键,或者在数据库中以事务方式锁定订单状态。
  • 使用支付服务商的 transaction id 进行对账。
  • 页面和日志中只保留卡品牌及末四位。
  • 支付完成后采用 Post/Redirect/Get,避免用户刷新页面时再次提交。

更稳妥的设计是让支付服务商处理原始卡数据,应用只管理订单状态和支付 token。Session 可以继续保存普通的购物车信息,但不应保存信用卡数据。

备注:内容仅供参考。