ASP.NET MVC 3 支付确认页如何安全保存支付数据?
结论
不要在 hidden fields、Session、TempData、日志或数据库中保存完整的信用卡号和 CVV/CVC。ASP.NET MVC 3 的 Session 默认没有可用于保护支付数据的加密机制。如果使用 State Server 或 SQL Server Session,数据还可能经过序列化后存入进程外的存储系统。
建议采用以下方案:
- 通过支付服务商的托管支付页、Hosted Fields 或 JavaScript SDK 接收卡号。
- 由支付服务商返回一次性 token、payment method id 或 transaction id。
- 应用在服务器端只保存这个标识,以及订单号、卡品牌和末四位。
- 用户确认后,服务器使用该标识发起授权或扣款。
如果支付服务商支持”授权后捕获”,也可以使用 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 可以继续保存普通的购物车信息,但不应保存信用卡数据。
备注:内容仅供参考。
