ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

商品详情API安全接入:签名验证与参数排序实战避坑指南

商品详情API安全接入:签名验证与参数排序实战避坑指南

最近在配合排查一个商品详情 API 的安全接入问题,现象很典型:某个服务商的应用在调用详情接口时,偶尔返回“签名验证失败”,时好时坏。一开始以为是偶发网络问题,后来把客户端生成的签名串和服务端期望的签名串打出来对比,才发现是参数排序规则不一致——SDK 里用的是字典序,平台文档里写的却是按 ASCII 码排,个别大写字母参与排序时结果就完全不同。这种问题在商品详情接口这类高频接口上尤其容易踩。

商品详情接口一般承载着价格、库存、SKU、图文详情等核心数据,一旦接入方式不对,轻则验签失败调试到怀疑人生,重则被刷数据、越权读取、参数篡改。做开放平台或接入第三方商品详情 API 的同学,都应该把签名验证和安全接入这件事当成一等公民对待。我会把签名算法选型、服务端验签细节、密钥管理、客户端 SDK 封装,以及实际调试中遇到的各种坑串起来讲一遍。

1. 为什么商品详情接口必须做签名验证,而不是只靠 Token

1.1 裸奔的详情接口有多危险

商品详情接口跟普通查询接口不一样,它返回的是商品最核心的经营数据:当前售价、实时库存、SKU 规格、富文本详情,甚至供应商信息。对电商业务来说,这些数据就是命根子。假设我们现在只用一个简单的 Token 做鉴权,客户端拿到 Token 后请求/api/v1/product/detail?productId=10001,服务端检查 Token 合法就返回数据。问题来了:Token 只能证明“这个请求来自一个合法登录的调用方”,它不能证明“productId 没有被改成别的东西”。如果调用方把自己应用能看到的数据范围外的 productId 传进来,服务端又没有做数据权限校验,这就是典型的越权漏洞。这种漏洞在商品详情接口上出现的概率并不低,因为很多团队默认 Token 能搞定一切。

另一个风险是篡改。请求在传输过程中如果被中间人改掉某个参数,比如把 productId 从 10001 改成 10002,或者把返回里的价格参数改掉,Token 完全无能为力。虽然现在普遍用 HTTPS 能解决传输加密问题,但在内部网关、服务间调用、CDN 边缘节点等场景下,我们不应该默认链路一定安全。签名机制等于给请求内容做了一次防篡改封印:参数只要动一个字符,签名就对不上。加上时间戳和 nonce 之后,还能有效防止请求被原样截获后重放。对商品详情这种高频接口来说,重放的危害不只是数据泄露,还有可能被人用来批量刷接口、拖垮后端。

1.2 签名验证与单纯鉴权的差异

很多人把“签名”和“鉴权”混在一起,实际上它们是两件事。鉴权解决的是“你是谁”,签名解决的是“你发的内容是不是你发了之后没被人改过”。打个比方,Token 像门禁卡,证明这人是公司员工;签名像是员工在文件上盖的骑缝章,文件每一页都被改过都能看出来。门禁卡能让你进大楼,但没法保证你手里的合同跟离开老板办公室时一模一样。商品详情接口的安全接入,两个都要有:先用 Token 或 AppKey 识别调用方身份,再通过签名验证请求参数完整性和时效性。光有 Token,参数被人篡改你查不出来;光有签名,没有身份体系你不知道该验哪把钥匙。有团队在接入时觉得“我们用的是内部系统,加个 Token 就够了”,结果内部服务之间发生参数篡改或重放时完全没有招架能力。

还有一个容易忽略的点:签名验证本身也是一种对调用方的约束。一旦签名机制上线,客户端必须按照规范生成请求,服务端可以统一校验,这意味着所有接入方有了统一的“安全基线”。商品详情接口往往同时被 App、H5、小程序、服务端脚本调用,如果没有统一签名规范,每个端的接入姿势都不一样,安全水位参差不齐。签名验证就像强制要求大家按同一种锁和钥匙进场,管理成本反而更低。

2. 签名算法怎么选:HMAC-SHA256 与 RSA 的取舍

2.1 HMAC-SHA256 对称签名的完整流程

HMAC-SHA256 是目前开放接口里最常见的签名方式,核心就是客户端和服务器共享同一个密钥(通常叫 AppSecret),用 HMAC-SHA256 算法对规范化后的请求字符串计算摘要,然后把摘要作为签名传过来。服务端用同样的密钥、同样的规则重新计算一遍,如果两边算出来的一致,说明参数没有被篡改,而且调用方确实持有正确的密钥。

具体流程一般是这样的:

  1. 收集除签名外的所有请求参数,包括 AppKey、商品 ID、时间戳、nonce 等。
  2. 对所有参数按参数名做 ASCII 码升序排序。
  3. 把排序后的参数拼接成key1=value1&key2=value2形式的字符串。
  4. 用 AppSecret 作为 HMAC 密钥,对上一步的字符串做 HMAC-SHA256 计算,结果转成十六进制字符串。
  5. 把生成的签名放到请求的sign参数里,连同其他参数一起发送。

一个简单的生成示例,代码风格偏 Python:

import hashlib import hmac import time def generate_sign(params: dict, secret: str) -> str: # 过滤空值和 sign 本身 filtered = {k: v for k, v in params.items() if v not in (None, "") and k != "sign"} # ASCII 码升序排序 sorted_keys = sorted(filtered.keys()) # 拼接 k=v&... raw = "&".join(f"{k}={filtered[k]}" for k in sorted_keys) # HMAC-SHA256 digest = hmac.new(secret.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256).hexdigest() return digest

我故意没在排序前做 URL 编码,因为很多 SDK 为了简化,会要求参数本身已经经过编码后再参与签名。这里的关键是服务端和客户端必须用同一套规则,尤其是“要不要编码”“要不要排除空值”“数组怎么拼”这三件事,往往就是签名对不齐的根源。

2.2 RSA 非对称签名的应用场景

HMAC 有一个天生的弱点:密钥是共享的。AppSecret 一旦在某个客户端或合作伙伴那边泄露,别人就能用这个密钥伪造任何请求。所以如果需要把商品详情接口开放给大量第三方开发者,并且无法保证对方安全水平时,可以考虑 RSA 非对称签名。客户端用私钥对规范化字符串做签名,服务端用对应的公钥验签。私钥只存在于客户端,即使服务端数据库被拖走,攻击者拿到了公钥也无法伪造签名,因为签名必须用私钥生成。

RSA 的问题在于性能和成本。RSA 签名计算比 HMAC 慢一个数量级,而且密钥对管理更复杂:要为每个接入方生成独立的密钥对、证书过期、私钥安全存储,都是运营层面的负担。在商品详情接口这种 QPS 很高的场景下,服务端验签需要做 CPU 预算。如果同样采用 RSA,建议用 SHA256withRSA 这类标准算法,同时做好性能压测。我的建议是:如果调用方是自家服务或可控的合作伙伴,HMAC-SHA256 够用且高效;如果要做完全开放的生态,RSA 更稳妥,且可以考虑在签名基础上叠加 Token 或 IP 白名单。

2.3 参数排序与规范化:最容易踩坑的地方

这里值得单独拎出来说,因为我在实际项目中看到的签名失败案例,绝大多数不是算法有问题,而是参数规范化没有对齐。先说排序。绝大多数平台规定按参数名的 ASCII 码升序排序,注意不是字典序,字典序更多按 Unicode 或字母顺序处理,ASCII 码里大写字母排在数字和小写字母之前。如果参数名里出现大小写混合,两边排序结果很容易不一致。

编码更是重灾区。参数值里有中文、空格、+、&、=这类特殊字符时,必须先统一编码规则。我推荐遵循 RFC 3986,即空格编码为%20而不是+,/编码为%2F,&编码为%26。很多语言自带的urlencode结果并不一样,比如 Python 的quote_plus会把空格编码成+,而 Java 的URLEncoder也有自己的规则。如果文档没有写清楚,最好在签名前先用统一的编码方法处理完参数,再拼接到规范字符串里。

数组参数是另一个坑。商品详情接口里经常有“指定字段列表”这类数组参数,比如fields=title,price,stock。有的平台要求把数组展开成fields=title&fields=price&fields=stock,排序时按每个键独立排;有的要求把数组序列化成 JSON 字符串后作为一个普通参数参与签名。这两种规则生成的签名完全不同。所以接入任何商品详情 API 前,第一件事就是找到文档里的“签名规范”,最好能找到一个平台官方的调试工具,先用一组固定参数把服务端的期望签名算出来,再对照自己的实现逐个字段核对。

3. 服务端验签与重放攻击防护的实现细节

3.1 验签的先后顺序:先验时间窗还是先验签名

服务端拿到请求后,验签顺序其实是有讲究的。先说结论:我一般按“参数完整性 → 时间戳时间窗 → nonce 唯一性 → 签名验证”的顺序来。有人会问,签名验证不是最核心吗?为什么不先验签名?因为签名验证需要做字符串拼接和 HMAC/RSA 计算,是有 CPU 开销的。而大量恶意请求往往会在时间戳这一关就被淘汰掉,没必要浪费计算资源。更关键的是,时间戳是明文传过来的,如果攻击者把时间戳改到窗口内同时伪造一个新签名,先验时间戳也拦不住?不一定,但如果客户端持有正确密钥,签名自然生成;如果攻击者没有密钥,改完时间戳后签名必然失败,验签还是会兜底。所以先做轻量校验,再做重量校验,是更工程化的做法。

参数完整性检查是第一步:AppKey、时间戳、nonce、sign 缺失,直接返回参数错误。注意这个时候还不能验证签名,所以返回的错误信息不要暴露太多内部逻辑,统一说“参数校验失败”即可。然后检查时间戳是否在可接受的时间窗口内,比如abs(now - timestamp) > 300秒就拒绝。这个窗口要综合考虑客户端时钟误差和网络延迟,5 分钟是比较常见的值。如果应用本身的调用链跨很多跳,网络延迟较大,可以适当放宽到 10 分钟,但同时 nonce 的存储时间也要跟着变长,权衡一下。

3.2 时间戳与 nonce 的防重放设计

时间窗口只能防“很久之前”的请求重放,比如把昨天的请求原封不动再发一次。但窗口内的重放靠时间戳就防不住了,必须引入 nonce。nonce 是客户端每次请求生成的随机字符串,服务端在时间窗口内缓存已使用过的 nonce,如果收到相同的 nonce,说明这个请求可能是重放的,直接拒绝。nonce 只需要在窗口期内唯一,过期后可以从缓存中淘汰。在 Redis 里很常见,可以用SET nonce_{hash} 1 EX 300 NX,如果返回失败说明 nonce 已经被使用过。伪代码如下:

SET nx_result = Redis.SET("nonce:" + nonce, "1", "EX", 300, "NX") if nx_result == nil: return error(40003, "nonce repeated")

不引入额外 Redis 的话,也可以用本地 LRU 缓存,但要注意多实例部署时 nonce 状态必须集中存储,否则不同实例各记各的,重放请求分发到不同实例就漏了。这一点在微服务架构下很容易忽略,很多团队在单机自测时没问题,一上多节点就发现重放防护失效。

nonce 生成也要讲究,不要用时间戳 + 随机数这种可预测方式,最好用uuid4或者足够长度的随机字节。有些客户端还会把 nonce 和某个操作上下文绑定,比如同一批商品详情的批量请求使用同一 nonce,这也可以,但需要平台文档明确说明。否则默认每次请求都生成全新 nonce。

3.3 验签失败时的错误码与响应策略

当签名校验失败时,响应设计直接决定了调用方排查问题的效率。过于笼统的“Invalid signature”会让客户端不知道是时间戳问题、nonce 问题还是签名算法问题;过于详细的“sign mismatch, your raw string is xxx”又会把内部规范字符串泄露给攻击者,反而降低了安全性。一个平衡的做法是定义清晰的错误码,但返回信息保持克制。

我常用的错误码设计如下:

错误码含义说明
40001缺少必要参数检查是否传了 appKey、timestamp、nonce、sign
40002请求已过期时间戳超出允许窗口
40003重复的请求nonce 已被使用
40004签名校验失败优先检查排序和编码规则
40005调用方无权限AppKey 存在但未授权该商品详情接口

返回体统一是 JSON 格式,包含code、message、requestId和当前服务端时间serverTime。serverTime很重要,客户端可以拿它校准本地时间,减少时间窗口误杀。与此同时,服务端要把验签失败请求的关键信息写入审计日志:AppKey、来源 IP、请求路径、错误码、原始参数(去掉 sign 后)。这样后续追溯攻击来源或定位接入问题都能派上用场。对于短时间内连续失败的同一个 AppKey,可以先触发限流而非直接封禁,防止误伤正常客户端的时钟偏移或网络重试冲击。

4. 密钥管理与下发机制:比签名算法本身更重要

4.1 密钥分级:平台密钥与应用密钥

很多团队在设计开放平台时,只给开发者一对 AppKey/AppSecret,然后所有接口都用同一对密钥签名。这在商品详情这类接口上很容易造成权限失控。更好的做法是密钥分级。平台密钥用来完成应用注册、凭证申请、Token 刷新等基础操作;应用密钥或业务签名密钥才用来给具体的商品详情接口加签。业务密钥可以进一步按权限范围划分,比如 A 应用只有价格和库存查询权限,B 应用才有图文详情权限。这样即使某个密钥泄露,攻击者也只影响对应权限范围,不会把所有商品数据一次性泄露。

对于更细的粒度,还可以在商品维度上做隔离,但那就不是签名机制本身的事了,而是授权策略。签名密钥和授权策略是两回事:密钥证明“你是你”,授权策略决定“你能看什么”。在设计密钥体系时,最好把这两层拆开,否则后面每加一个新权限,都要重新发一把密钥,管理成本直线上升。我见过一个项目,刚开始图省事,所有应用共用一把密钥,结果某天发现一个合作伙伴的 AppSecret 被人泄露到了代码仓库,没办法区分具体是哪个应用的行为,只能全平台强制轮换,折腾了一整晚。

4.2 密钥轮换与提前失效处理

密钥不可能永远不泄露,合规审计、人员离职、合作伙伴账号冻结都可能要求密钥作废。所以平台在设计接入规范时就应该把密钥轮换机制纳入考虑。最简单的支持方式是“双密钥并行”:每个应用同时保留新旧两把密钥,签名验证时先尝试新密钥,失败再尝试旧密钥。轮换流程建议这样操作:

  1. 平台生成新密钥,状态标记为“生效中”,但旧密钥仍然有效。
  2. 客户端在过渡期内切换到新密钥,服务端持续观察验签成功率。
  3. 确认所有调用方都切换完毕后,平台将旧密钥标记为“已废弃”,不再接受旧密钥签名。
  4. 如果出现切换困难的调用方,可以延长过渡期,但不能无限制延长,否则轮换形同虚设。

过渡期长短取决于调用方规模,一般 7 到 30 天。这里有个细节:双密钥并行时,nonce 和时间窗口规则不变,只是验签时多试一把密钥,代码逻辑不算复杂,但要注意日志里要标明最终命中的是哪个密钥,方便轮换期间判断调用方是否还在用旧密钥。很多团队轮换失败是因为没有监控,等到旧密钥彻底下线才发现有一批客户端还在用旧逻辑调用,商品详情接口直接全部验签失败,业务受损才后知后觉。

4.3 调试密钥与生产密钥隔离

商品详情接口涉及的业态通常链路很长,开发、联调、测试、预发、生产可能是完全不同的环境。每个环境最好有独立的密钥池,不要拿生产密钥去联调。联调环境可以关闭一些严格的限制,比如时间窗口放宽到 30 分钟、不强制 nonce 唯一性等,但生产环境必须按最严标准来。调试密钥的权限也要控制住,不能让所有开发者都拿到能查询全部商品详情的密钥,必要的时候按店铺或品牌商范围做授权。

密钥本身的存储安全同样重要。服务端密钥应该放在环境变量、配置中心或密钥管理服务中,而不是硬编码在代码里。客户端侧的 AppSecret 如果嵌在 App 内,很容易被反编译提取。对纯客户端场景,更稳妥的方式是由自己的后端代理转发商品详情请求,真正带私钥的签名只发生在服务端,App 里只持有临时凭证。这一点在做安全接入评审时值得重点提醒,很多团队为了省事让移动端直接持有 AppSecret,等于把保险柜钥匙贴在门外。

5. 客户端 SDK 接入的工程化落地

5.1 签名工具类的封装思路

无论语言是什么,签名逻辑都应该收敛到一个工具类或函数里,不要让业务代码到处拼字符串。一个好的签名工具类应该暴露三个方法:generateNonce()、buildCanonicalString(params)、sign(params, secret)。业务层只需要把业务参数传给签名方法,拿到签名后放到要发送的请求里。这样可以保证所有调用点使用同一套排序和编码逻辑,避免不同人写出来的代码风格各异。

以 Java 为例,核心方法可以长这样:

public class SignUtil { public static String sign(Map<String, String> params, String secret) { // 移除 sign、空键值 Map<String, String> sorted = new TreeMap<>(params); sorted.remove("sign"); StringJoiner joiner = new StringJoiner("&"); for (Map.Entry<String, String> entry : sorted.entrySet()) { if (entry.getValue() == null || entry.getValue().isEmpty()) { continue; } joiner.add(entry.getKey() + "=" + SignUtil.urlEncode(entry.getValue())); } return hmacSha256Hex(secret, joiner.toString()); } }

注意这里用了TreeMap来保证自然排序。如果用HashMap,每次签名结果可能都不一样,因为遍历顺序不固定。urlEncode一定要是自己实现的 RFC 3986 编码,而不是直接调URLEncoder.encode,后者默认把空格编码成+,容易和服务端不一致。工具类里最好还有一段单元测试,用固定的参数和固定密钥,断言签名结果等于已知的期望值,这样以后谁改坏了逻辑,单测立刻能发现。

5.2 网络层自动重试与签名刷新

商品详情接口通常被打在 CDN、网关或业务服务上,网络抖动导致的超时很难完全避免,所以客户端 SDK 一般都会做重试。但重试有个大坑:同一个请求的签名如果连时间戳和 nonce 都不变,重试时还带着旧签名,服务端如果已经处理过一次,nonce 会被标记为已使用,重试必然失败。所以 SDK 在重试前必须重新生成时间戳和 nonce,重新计算签名。简单说,每一次 HTTP 请求发出时,签名都必须是最新的,不能因为“业务上还是同一个请求”就一直复用第一次的签名。

重试策略建议使用指数退避加随机抖动,比如第一次失败后等 1 秒、第二次 2 秒、第三次 4 秒,每次加上 0 到 500 毫秒的随机偏移,最大重试次数控制在 3 次以内。商品详情接口对实时性要求高,重试太多次会拖垮用户端等待时间。还要注意,如果调用的是批量商品详情接口,重试时最好只重发失败的那一批,不要整个批次重新请求,否则签名重新生成、部分商品可能在别人那边已经被计费或限流,处理起来更麻烦。

客户端的时间校准也不能忽略。签名里的时间戳是客户端生成的,一旦客户端设备时间慢了 5 分钟以上,哪怕密钥完全正确,服务端也会把请求当作过期请求拒绝。SDK 可以在每次成功响应后读取服务端返回的serverTime,计算偏移量并缓存,后续生成时间戳时自动加上偏移。很多接入方不知道有这一招,设备时间一乱就大面积验签失败。

5.3 接入自查清单

给一个实用的自查清单,我建议任何团队接入商品详情 API 前都按这个表过一遍:

检查项预期结果
参数排序按 ASCII 升序,字母大小写行为与文档一致
参数编码统一 RFC 3986,空格是 %20 而非 +
空值过滤空字符串/null 不参与签名,但文档可能有特殊说明
时间戳单位秒,不是毫秒
nonce 生成随机且唯一,不能可预测
密钥保存位置不硬编码在代码里,日志中不可见
重试策略每次请求重新生成时间戳和 nonce
服务端 serverTime客户端已做时钟偏移校准

如果你拿着这份清单去逐项核对,大多数“签名验签失败”的问题在接入前就能被消灭。

6. 实测中的意外情况与排查思路

6.1 时间不一致导致验签失败

这类问题在接入阶段特别常见。有一次,一个客户端在测试环境调用商品详情接口,时不时返回 40002。我看请求日志里的时间戳和服务端时间差了 4 分多钟,一开始以为是网络延迟,但实际是客户端所在服务器的系统时间没做 NTP 同步,已经慢了很久。服务端的 5 分钟时间窗比较宽容,但有时刚好在边界上就失败了。排查的时候,可以对比服务端返回的serverTime和客户端本地时间,看看差值。如果服务端已经有serverTime字段,客户端可以直接用这个差值做本地校准,或者让运维把所有服务器都挂上 NTP 服务,只靠代码容忍时钟漂移不是长久之计。

还需要注意,时间戳的“当前时间”应该取发起请求那一刻,而不是取生成参数之后、经过复杂计算之后的时间。有些 SDK 在构造参数时记录时间戳,结果后面因为拼字符串太慢,等到真正发送时已经接近窗口边界。如果接口响应极慢或者本地 CPU 在签名期间被占用高,这类问题会被放大。解决办法是在真正要发送的那一轮循环里再生成时间戳和 nonce,而不是在业务入口处提前取。

6.2 URL 编码与大小写问题

另一个反复出现的坑是 URL 编码不一致。客户端在签名时对商品名称里的中文做了 UTF-8 编码,服务端验签时却按平台的编码规范解码后重新拼串,两边只要有一个字符的编码规则略有偏差,签名就完全对不上。最典型的是空格:有的语言库编码空格为%20,有的为+。在传输时这两种形式可能在 HTTP 请求里被服务端自动解码,但在签名字符串里必须严格统一。还有十六进制摘要的大小写问题。HMAC-SHA256 生成的是十六进制字符串,有的库默认大写,服务端如果转成小写再比较,永远对不上。这类问题排查起来也很简单,打印出来逐字节对比,不要靠肉眼猜。

通常我会建议在服务端验签失败时,临时开启“打印规范化字符串”的调试开关,将服务端期望的待签字符串原样打印到日志。当然这只在生产环境关闭的调试手段,输出时要脱敏掉密钥本身。两边一对比,往往一眼就能看出是编码、大小写还是排序问题。定位完后立刻关掉开关,不要长期开着。

6.3 网关层服务间调用如何继续传递签名

商品详情接口后面通常不是直接查数据库,而是经过 API 网关再转发到商品服务。客户端签名的目的,是让网关能验证请求的来源和完整性。那网关验签之后,再往下游转发商品服务时,要不要把客户端的签名也透传过去?这里要分情况。如果是内部服务之间已经处于可信网络,网关验签后可以剥掉客户端签名,替换成内部服务调用的鉴权信息,比如内部 AppKey 加上服务间签名。如果业务上需要下游继续校验原始签名的字段,那么就不能把原始sign参数简单去掉,而是要把参与签名的参数原样透传,并且保证 nonce 的全局唯一性。

这里有一个很容易踩的坑:网关验签通过后,习惯性地把请求体里的sign和timestamp去掉,然后转发给下游。下游如果还想做安全审计,发现原始调用者的身份信息已经丢失。更合理的做法是网关把验签后的结果映射到统一的调用上下文,比如在 Header 里附加X-Auth-AppKey、X-Request-Id,下游根据这些头来决定内部信任策略。对于重放防护,最好在网关这一层统一做掉,不要让每个下游服务各自实现 nonce 缓存,否则同一客户端在不同服务上重放,nonce 很难做到全局去重。我遇到过的情况是:网关已经验签并核查过 nonce,但下游服务不知道网关做过这层防护,又把同一个请求转发到两个后端实例,结果因为各自缓存不共享,重放请求被其中一个实例放行。后来统一改成在网关层处理 nonce,下游不再重复检查,问题才解决。

如果你在接入商品详情 API 时也遇到类似的签名问题,建议按我上面说的方法一步步来,先把规范化字符串对比清楚,再检查密钥和时间戳,最后看网关链路,很快就能定位。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进