如何在 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 已经属于旧技术栈,应使用项目能够获得的最新安全修复版本,并尽量减少第三方依赖。
原则是让浏览器负责收集和展示,让后端维护结算状态,由支付服务商保存并处理卡片数据。页面刷新后可以恢复支付流程,但不能恢复完整的信用卡信息。
备注:内容仅供参考。
