PAYATHON 2026

如何在 Angular Web application 中安全持久化支付信息

支付老李

结论

不要在 AngularJS 前端持久化完整的信用卡信息,包括 localStorage、sessionStorage、Cookie、IndexedDB,以及任何可恢复的 JavaScript 状态。

页面刷新后让用户重新输入卡片信息,是更安全、更合规的做法。如果业务要求刷新后仍能继续支付,应使用支付服务商提供的 Hosted Fields、iframe 或前端 Tokenization。卡号和安全码直接提交给支付服务商,应用只保存不能直接用于支付的临时 Token,以及卡品牌、末四位等脱敏信息。

AngularJS 并不存在特殊的“安全前端 Session”。真正的 Session 应由后端维护,浏览器只保存一个随机且不可猜测的会话标识,并通过设置了 Secure、HttpOnly 和 SameSite 的 Cookie 传递。

为什么不能保存在浏览器中

如果数据需要在刷新后恢复,就必须写入浏览器的持久化介质。无论使用哪一种,都不能保证信用卡信息的安全:

  • localStorage、sessionStorage 和 IndexedDB 都能被同源 JavaScript 读取。一旦出现 XSS,攻击代码可能直接获取卡号。
  • 普通 Cookie 也能被 JavaScript 读取,还可能自动附加到请求中。
  • 即使先在前端加密,解密密钥或获取密钥的逻辑仍要交给浏览器,无法构成可靠的安全边界。
  • AngularJS 的 service/factory 只存在于当前 JavaScript 运行环境,页面刷新后一定会丢失。
  • CVV/CVC 不应持久化。即使服务端做了加密,也不应保存它以供后续恢复。

sessionStorage 中的“session”只表示浏览器标签页的生命周期,并不等同于后端 Session,也不会让敏感数据变得更安全。

推荐的支付流程

可以把三个 State 的数据边界调整如下。

State1:创建后端结算会话

用户选择 invoices 后,将 invoice ID 提交给后端。后端重新查询账单状态和金额,再创建一个短期有效的 Checkout Session。

前端提交的金额、折扣和应付总额都不能作为可信依据。

// AngularJS 1.x
app.service('CheckoutService', function ($http) {
  this.create = function (invoiceIds) {
    return $http.post('/api/checkout-sessions', {
      invoiceIds: invoiceIds
    }).then(function (response) {
      return response.data;
    });
  };

  this.getCurrent = function () {
    return $http.get('/api/checkout-sessions/current')
      .then(function (response) {
        return response.data;
      });
  };
});

后端可以通过 Cookie 维护会话标识:

Set-Cookie: checkout_session=random-opaque-id; Path=/; Secure; HttpOnly; SameSite=Lax

各属性的作用如下:

  • Secure:只通过 HTTPS 发送。
  • HttpOnly:禁止 JavaScript 读取。
  • SameSite:降低跨站请求伪造风险。
  • Cookie 中只保存随机会话 ID,不保存卡号、CVV 或支付 Token。

页面刷新后,AngularJS 调用 /api/checkout-sessions/current,后端根据 Cookie 恢复 invoice 选择和结算进度。

State2:使用 Hosted Fields 或 Tokenization

卡号、有效期和 CVV 最好交给支付服务商提供的 iframe 或 Hosted Fields 收集。这样,完整的卡片信息不会进入 AngularJS Controller、DOM 数据绑定、日志或应用后端。

下面是一个不绑定具体支付服务商的结构示例。实际使用时,需要将方法名称替换为所选支付 SDK 的真实 API:

<form name="paymentForm" ng-submit="submitPayment()" novalidate>
  <div id="card-number-field"></div>
  <div id="expiry-field"></div>
  <div id="security-code-field"></div>

  <button type="submit" ng-disabled="submitting">
    继续
  </button>
</form>
app.controller('PaymentController', function (
  $scope,
  $http,
  $state,
  PaymentProvider
) {
  var paymentFields = PaymentProvider.mountHostedFields({
    number: '#card-number-field',
    expiry: '#expiry-field',
    securityCode: '#security-code-field'
  });

  $scope.submitPayment = function () {
    $scope.submitting = true;
    $scope.errorMessage = null;

    paymentFields.tokenize()
      .then(function (result) {
        // result.token 是临时引用,不是卡号
        return $http.post('/api/checkout-sessions/payment-method', {
          paymentMethodToken: result.token
        });
      })
      .then(function () {
        $state.go('confirmation');
      })
      .catch(function () {
        $scope.errorMessage = '无法验证支付信息,请检查后重试。';
      })
      .finally(function () {
        $scope.submitting = false;
      });
  };
});

不要通过 ng-model 将卡号绑定到 AngularJS Scope。否则,卡号仍会进入应用自身的 JavaScript 内存,Hosted Fields 提供的隔离效果也会被削弱。

支付服务商返回 Token 后,可以在后端 Checkout Session 中保存以下信息:

{
  "checkoutSessionId": "opaque-session-id",
  "paymentMethodToken": "temporary-provider-token",
  "cardDisplay": {
    "brand": "Visa",
    "last4": "4242"
  },
  "status": "awaiting_confirmation"
}

Token 应满足这些条件:

  • 有效期较短;
  • 与当前用户或 Checkout Session 绑定;
  • 有明确的用途和使用次数限制;
  • 不能通过客户端参数修改支付金额;
  • 在支付完成、取消或超时后失效。

Token 能否保存、可以保存多久、是否允许重复使用,取决于支付服务商的具体产品设计。不能假设所有 Token 都有相同的安全属性。

State3:只展示脱敏摘要

确认页只需显示账单、金额和脱敏后的卡片信息:

支付卡:Visa •••• 4242
支付金额:¥1,280.00

不要再从浏览器存储中读取完整卡号。最终确认时,前端只提交 Checkout Session 标识或确认操作:

app.controller('ConfirmationController', function ($scope, $http) {
  $scope.confirm = function () {
    $scope.processing = true;

    return $http.post('/api/checkout-sessions/current/confirm', {})
      .then(function (response) {
        $scope.paymentResult = response.data;
      })
      .finally(function () {
        $scope.processing = false;
      });
  };
});

服务端根据已经保存的 Checkout Session 完成支付:

app.post('/api/checkout-sessions/current/confirm', async function (req, res) {
  const checkout = await loadCheckoutFromSession(req);

  // 从数据库重新读取账单,不能采用浏览器提供的金额
  const invoices = await loadPayableInvoices(
    req.user.id,
    checkout.invoiceIds
  );

  const amount = calculateAmount(invoices);

  const result = await paymentProvider.charge({
    paymentMethodToken: checkout.paymentMethodToken,
    amount: amount,
    currency: 'CNY',
    idempotencyKey: checkout.id
  });

  res.json({
    status: result.status,
    receiptId: result.receiptId
  });
});

idempotencyKey 可以防止用户重复点击、刷新确认页或网络重试造成重复扣款。具体字段名称应以支付服务商的 API 为准。

刷新页面时应该发生什么

刷新后的恢复策略可以这样设计:

  • State1 刷新:从后端 Checkout Session 恢复已选 invoices。
  • State2 刷新且尚未 Tokenize:要求用户重新输入卡片信息。
  • State2 已完成 Tokenize:如果临时 Token 仍然有效,可以从后端恢复到 State3,但只能显示末四位等脱敏信息。
  • State3 刷新:查询后端支付状态,不要重复创建支付请求。
  • Session 过期:清除结算状态,要求用户重新开始。

这种设计恢复的是支付流程,而不是完整卡号。两者需要明确区分。

如果不能接入 Hosted Fields

如果无法接入 Hosted Fields,可以通过 HTTPS 将卡片信息直接提交到自己的后端,但只能在单次请求的内存中使用,不得写入数据库、Session 或日志。处理完成后,只保存支付服务商返回的 Token。

这种做法通常会扩大系统承担的支付安全责任和 PCI DSS 合规范围。日志、APM、异常追踪、请求录制、反向代理和调试工具都可能意外记录请求内容,因此不能只检查数据库是否保存了卡号。

除非团队已经具备相应的合规能力,否则应优先使用支付服务商托管的卡片输入组件。

还需要注意的安全措施

  • 全站强制使用 HTTPS,并启用 HSTS。
  • 防范 XSS,避免注入不可信的 HTML,谨慎使用 AngularJS 的 $sce 和 ng-bind-html。
  • 配置严格的 Content Security Policy,只允许加载必要的支付服务商脚本和 iframe。
  • 对修改结算状态和确认支付的接口实施 CSRF 防护。
  • 不要在 URL、错误信息、日志、分析事件或监控系统中记录卡号、CVV 和支付 Token。
  • Checkout Session 应与当前登录用户绑定,并设置较短的过期时间。
  • 服务端必须重新验证 invoice 的所有权、状态、币种和金额。
  • 支付确认接口应具备幂等控制和并发保护。
  • 支付完成后,应及时使临时 Token 和 Checkout Session 失效。
  • AngularJS 1.x 已经属于旧技术栈,应使用项目能够获得的最新安全修复版本,并尽量减少第三方依赖。

原则是让浏览器负责收集和展示,让后端维护结算状态,由支付服务商保存并处理卡片数据。页面刷新后可以恢复支付流程,但不能恢复完整的信用卡信息。

备注:内容仅供参考。