强认证
有几件事不可逆,光有一把 API Key 不够 —— 必须让终端用户本人当场再证明一次
是他。我方的做法是:把这一步放在我方托管的一屏上完成,你不接触任何因子。
红线:我方不接受你的自证
请求体里 "step_up_passed": true、请求头里 x-verified: 1 这类字段,
我方一个都不认,也永远不会加。
这不是不信任你。受这道闸保护的是提现地址新增、卡片激活、查看完整卡号 CVV
这几件不可逆的事 —— 一旦这道闸能由调用方自己声明通过,它就不是一道闸了,
而是一个字段。你那边任何一次 SSRF、任何一个能构造请求体的注入点,
都会直接变成「往任意地址提币」。
所以这条流程里你要做的只有两件事:把 URL 交给终端用户,
以及拿到票据后重发。
哪些动作要
| 动作 | 端点 | action |
|---|---|---|
| 新增提现地址 | POST /v1/withdraw-addresses | withdraw_address_add |
| 查看完整卡号 / CVV | POST /v1/cards/{id}/secure-session | card_secure |
| 激活实体卡 | POST /v1/cards/{id}/activate | card_activate:<卡 id> |
⚠ 删除提现地址(DELETE /v1/withdraw-addresses/{id})不挂强认证 ——
删除是往安全方向走的动作,不该设障碍。
⚠ 部分兑换币对在我方后台配了「需提权」。那种币对在开放 API 上一律拒
(返回 step_up_required,且不带 hosted_url),不是「跳过」。
本期开放 API 不开放兑换的提权档 —— 碰到它请与你的客户经理确认这个币对的配置。
交互
第一次调用(不带票据)→ 400:
{
"type": "invalid_request_error",
"code": "step_up_required",
"message": "Strong authentication is required. See hosted_url / challenge_id.",
"request_id": "…",
"challenge_id": "chl_9f2c…",
"hosted_url": "https://…/hosted/step-up/chl_9f2c…",
"expires_at": 1754872500
}
你把 hosted_url 交给终端用户(App 内浏览器打开、或短信/推送发给他)。
那一页在我方域内,我方向他的注册邮箱发一个 6 位验证码,他填对即通过。
expires_at 是 Unix 秒,有效期 5 分钟。
⚠ 我方不会在他完成之后回调你。这一页不签发任何会话、不返回任何东西给你 ——
它只把那张票据置成「已通过」。你那边的做法是让用户点一个「我已完成验证」,
或者在页面关闭后重发。
第二次调用:同一把幂等键、同一份请求体,加一个头:
POST /v1/withdraw-addresses
x-idempotency-key: <与第一次完全相同>
x-step-up: chl_9f2c…
请求体也要与第一次逐字节相同(签名要重算 —— 时间戳和 nonce 都变了)。
这是幂等表里唯一一处「同键重发应当真的执行」
平时的规矩是:同一把幂等键得到同一个结果,包括错误。
第一次拿到 400,第二次拿同一把键发过去,拿回的还是那个 400 的原样回放。
强认证这条是唯一的例外,我方在服务端专门为它开了一个口子。
理由是:同键判据只算请求体、不含任何请求头,而 x-step-up 是请求头 ——
带不带它算出来的哈希一模一样。不开这个口子的话,你按文档重发的那一次会命中
回放分支,把那份已经过期的 step_up_required 原样回放,
业务处理器根本不会执行。也就是说这条流程会在代码层面永远走不完。
所以:
- 重发就用原来那把键。 换新键在提现/发卡这类端点上 == 第二笔真实操作;
- 例外只对
step_up_required那一档开。其余任何错误(余额不足、参数不对)
照常回放 —— 那种情况下你要做的是改正之后换一把新键。
票据的绑定与有效期
一张票据绑死三样,任何一样对不上都当作没有这张票(于是你会拿到一张新的
step_up_required 而不是一个说明原因的错误):
| 绑定 | 少了它会怎样 |
|---|---|
| 会员 | 你能拿 A 完成的票据去动 B 的钱 |
| 动作 | 一张「激活卡片」的票据能用来查看卡密 |
| 已通过标志 | 签发即可用 —— 整条链退化成「你自己声明通过了」 |
外加两条:
- 一次性:验过即删。同一张票据不能用于第二次请求;
- 5 分钟:从签发那一刻算,不是从终端用户打开那一刻算。用户在托管屏上
磨蹭超过 5 分钟,票据就没了,你会拿到一张新的 step_up_required。
这是刻意的短。
查看卡密这一档不一样
POST /v1/cards/{id}/secure-session 过了强认证之后,返回的不是卡号,
而是第二个 hosted_url:
{ "issued": true, "hosted_url": "https://…/hosted/card/chl_…", "expires_at": … }
完整卡号、有效期、CVV 永不经过你的服务器。它们由我方在那一页上从上游实时拉取、
只渲染给那一个浏览器,不落库、不进日志、不进缓存,60 秒后页面自动遮蔽。
你拿到的只有「票据已签发」这个事实。这是刻意的:卡密经过你的服务器一次,
你就落进了持卡人数据的合规范围里。
排查
| 现象 | 多半是 |
|---|---|
重发之后还是 step_up_required,challenge_id 换了一个新的 | 终端用户其实没走完那一页;或票据已过 5 分钟;或 x-step-up 里填的是整条 hosted_url 而不是 challenge_id |
重发拿回 409 idempotency_key_reused | 请求体与第一次不一致(哪怕只差一个空格),或这把键打到了另一个端点 |
| 换了新幂等键之后成功了,但对账多了一笔 | 你换键重发了一次已经完成的操作。这一档必须沿用原键 |
⚠ 还有一条与平时相反的:重发成功之后,那把键不会缓存成功结果。
再拿它发同一个请求,拿到的是 409 idempotency_in_progress(而不是回放那次成功),
且这个状态会一直持续到 24 小时窗口结束。
所以强认证这条链上,幂等键只用于「第一次 → 带票据重发」这一对。
重发拿到明确响应之后就不要再用它了;真的没拿到响应(网络断在半路),
去用 GET 类端点回查结果,不要盲目再发一次。