身份验证
InfraIO Pay 有两套 API 表面,鉴权模型不同。挑选与调用方匹配的那一套:
| 表面 | 路径前缀 | 受众 | 鉴权 |
|---|---|---|---|
| 商户 B2B | /b2b/v1/* | 你的服务器 | HMAC-SHA256 请求签名 |
| 仪表板 | 按服务划分:/auth/*、/payment/*、/merchant/*、/event/*、/user/* 等 | 商户仪表板的浏览器会话 | Bearer JWT |
本页面介绍 B2B 表面 — 也就是你用 API 密钥对从服务端调用的那一套。 如果你在嵌入 InfraIO 仪表板或构建内部工具,请使用仪表板表面(文档单独 维护,尚未公开)。
gateway 按照前导前缀为每个表面路由,并在转发前 剥离 该前缀:
/b2b/v1/checkout-sessions/quick 到达 payment-service 时是
/v1/checkout-sessions/quick,而仪表板的 /payment/v1/orders 到达时是
/v1/orders。所以如果你在别处看到光秃秃的 /v1/* 路径,那是公共前缀
被移除之后的 后端内部 路径 — 你的客户端始终发送带前缀的形式。(对
签名的一个影响:B2B canonical 字符串签名的路径 仍带着 /b2b 前缀
— 见下文。)
端点
| 环境 | Base URL |
|---|---|
| Test | https://api-dev.infraio.xyz |
| Live | https://api.infraio.xyz |
URL 模式相同 — 环境由密钥前缀(pk_test_… vs pk_live_…)控制,
而不是 URL。
密钥对
你从商户仪表板(Developers → API keys → + Add key)获得两个值:
- Publishable key (
pk_test_…或pk_live_…)— 标识你的账户。 作为X-Client-ID发送。可安全嵌入浏览器 bundle(SDK 已经在用)。 - Secret key (
sk_test_…或sk_live_…)— HMAC 签名密钥。仅限 服务端。请像数据库密码一样对待。
如果 secret key 不慎进入浏览器 bundle、git 仓库、日志行,或被分享 到聊天 — 立刻从仪表板吊销。没有重叠窗口;吊销立即生效。请重新 签发并重新部署。
对请求签名
每次 /b2b/v1/* 调用都携带三个标头:
X-Client-ID: pk_live_70d0becbd7a53047b0dfe88a960d10ad
X-Timestamp: 1715990400
X-Signature: 9a8b7c6d… (hex HMAC-SHA256)签名在一个规范化字符串上计算:
METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + BODYMETHOD— 大写的 HTTP 动词(POST、GET、……)。PATH— 请求路径包含/b2b前缀,不含 host 且不含查询字符串 (例如/b2b/v1/checkout-sessions/quick)。Gateway 在剥离/b2b之前就用原始入站路径验证签名,所以前缀必须在。查询参数不参与签名 — 对于GET …?cursor=…&limit=20,只对路径签名,不要包含?…部分。TIMESTAMP— Unix 秒,十进制字符串(例如"1715990400"),与X-Timestamp完全一致。BODY— 原始请求 body 字节。GET/DELETE为空字符串。
用 secret key 作为密钥的 HMAC-SHA256 签名,输出 hex:
Node / TS
import { createHmac } from "node:crypto";
function sign({ method, path, body, secret }: {
method: string; path: string; body: string; secret: string;
}) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const input = [method.toUpperCase(), path, timestamp, body].join("\n");
const signature = createHmac("sha256", secret).update(input).digest("hex");
return { timestamp, signature };
}为什么选 HMAC,而不是 Bearer?
裸 Bearer-token API 会在每次请求里把你唯一的 secret 经线传输。任何 拿到某个 TLS 终止代理日志的人都能拿到你账户的钥匙。HMAC 签名意味着 secret 永远不上路 — 上路的只是其衍生签名,而且一次性(绑定到该次 请求 + 该分钟)。
代价是:每次调用都得算签名。服务端 SDK 会隐藏这部分;在我们发布之前, 上面的 helper 大约每种语言 15 行。
时间戳容差
绑定容差为 ±5 分钟(300 秒),由 merchant-service 在校验签名时
强制执行。Gateway 自身略宽松一些(310s)作为纵深防御,但通过 gateway
却在内层检查失败的请求仍以 401 invalid_signature 结束 — 把契约当作
300s。两点含义:
- 同步服务器时钟到 NTP。时钟漂移的长跑 cron 会间歇失败。
- 不要预先计算并入队签名。 如果请求在重试队列里坐了 > 5 分钟, 它的签名就过期了。
密钥 scope
Secret key 携带一个或多个下面的 scope 包:
| Scope | 预期用途 |
|---|---|
read | 列出/读取订单、会话、退款 |
write_order | 创建结账会话、订单 |
write_refund | 发起退款、铸造退款申请 token |
webhook_manage | 创建/更新/删除 Webhook 端点 |
仪表板默认签发”全权限”密钥(全部四个 scope)。你可以从 Developers → API keys → + Add key 铸造受限 scope 的密钥,仅勾选 集成需要的 scope。
scope 强制目前为建议性,并未实际拦截。 scope 会记录在密钥上,
并在仪表板回显给你,但 gateway 中间件尚未拒绝超出 scope 的调用 —
任何有效的 sk_… 密钥今天表现为全权限。按端点的 scope 闸门将在
下一个版本上线。在此之前请勿把 scope 当作安全边界;先把它当作标签,
通过轮换 / 吊销密钥来限制访问。
签名在哪里被验证
HMAC 校验只在 gateway 进行一次。Gateway 的步骤:
- 读取
X-Client-ID、X-Timestamp、X-Signature。 - 用
pk_…查找商户 + secret,运行时间戳窗口检查,重新计算签名, 常量时间比较。 - 成功后,剥离鉴权标头,给请求打上内部标头(
X-B2B-Auth: 1、X-Merchant-ID、X-Merchant-Domain),然后转发到下游服务 (payment-service、merchant-service 等)。环境与解析后的 scope 今天不注入到标头里 — 下游需要环境时,从请求 body / 每商户 配置推导,而不是从标头。 - 失败则返回 401
INVALID_SIGNATURE,完全不触达后端。
下游服务不重新跑 HMAC — 它们信任 gateway 注入的标头,并对 gateway
解析出的商户进行操作。它们也不按端点执行 scope 闸门:如上所述,
密钥的 scope 不会被注入,因此任何通过认证的 sk_… 都能触达其商户的
任意端点(scope 强制目前只是建议性的 — 见 密钥 scope
下的提示框)。这有两个影响:
- 如果你在 InfraIO Pay 前面运营自己的反向代理,请勿剥离
X-B2B-Auth/X-Merchant-ID(也不要伪造它们 — gateway 会拒绝 公网入口携带这些标头的请求)。 - 公网路径(
/b2b/v1/*)是唯一跑 HMAC 步骤的表面。我们服务之间的 内部 gRPC 使用 mTLS — 一种不接受X-Client-ID的不同信任模型。
下一步
- 错误 — 4xx/5xx 的响应形态。
- 安全 → API 密钥 — 轮换、吊销,以及 secret 泄露时的处理。
- Webhooks → 签名验证 —
使用不同的 HMAC 方案(标头
X-Signature: sha256=…,签名X-Timestamp + "." + raw_body,加上 24 小时轮换宽限窗口内可选的X-Signature-Prev)。不要把两种方案搞混 — 它们共用哈希算法, 但签名字节和 secret 家族(whsec_…vssk_…)不同。