开放平台权限认证通用方法:基于签名算法的 API 认证体系
开放平台权限认证通用方法:基于签名算法的 API 认证体系
多数开放平台的接口调用并非简单地使用 Token,而是每次请求都需要重新认证。本文介绍一套通用的签名认证方案,涵盖从密钥分发到签名生成、服务端验签的全流程。
一、为什么不用 Token?
在很多人的印象中,API 认证就是"登录 → 拿到 Token → 每次请求带着 Token"。但对开放平台来说,这套方案存在几个硬伤:
| 问题 | 说明 |
|---|---|
| Token 泄露风险 | Token 一旦泄露,攻击者可无限期调用接口 |
| 无请求完整性保障 | 请求参数在传输过程中可能被篡改,Token 无法感知 |
| 重放攻击 | 攻击者截获一个合法请求即可重复提交 |
| Token 生命周期管理复杂 | 刷新、过期、踢下线——全是状态维护成本 |
所以开放平台通常会采用每次请求独立认证的方式,即基于签名(Signature)的认证体系。客户端每次请求时根据参数动态生成签名,服务端接收后重新计算签名进行比对,一次一密。
二、核心设计思路
要建立一个通用的签名认证体系,我们需要以下几个要素:
2.1 密钥体系
平台为每个接入方分配一对密钥:
apiKey— 接入标识,相当于用户名,可明文传输apiSecret— 签名密钥,仅服务端和接入方各自持有,永不参与网络传输
密钥在创建时仅展示一次,后续不再显示完整密钥(只显示掩码后的前缀/后缀),与各大云厂商的 AccessKey/SecretKey 机制一致。
密钥需要持久化到数据库中,表结构大致如下:
-- auto-generated definitioncreatetablesys_oauth(idintauto_incrementprimarykey,namevarchar(32)null,apikeyvarchar(64)null,apisecretvarchar(255)null,remarkvarchar(255)null,callbackvarchar(255)null,create_byvarchar(64)collateutf8mb4_general_cidefault''nullcomment'创建者',create_timedatetimenullcomment'创建时间',update_byvarchar(64)collateutf8mb4_general_cidefault''nullcomment'更新者',update_timedatetimenullcomment'更新时间');apikey— 接入标识,传输时放在请求参数中apisecret— 签名密钥,仅服务端和接入方各自持有,永不参与网络传输callback— 回调地址,用于异步通知等场景
注意:
apisecret在数据库中建议加密存储(如 MD5 哈希),但在签名计算时服务端解密出原文后用于签名计算,接入方用的则是创建时拿到的原文。
2.2 参数结构
每次 API 请求需要携带以下参数:
| 参数 | 类型 | 是否必传 | 说明 |
|---|---|---|---|
apiKey | String | ✅ | 平台分配的接入标识 |
timestamp | String | ✅ | 当前时间的毫秒时间戳(13位) |
nonce | String | ✅ | 32位随机字符串,每次请求唯一 |
signType | String | ✅ | 签名算法类型,如HMAC-SHA256 |
bizContent | String | ✅ | 业务参数的 JSON 字符串 |
sign | String | ✅ | 根据签名算法生成的签名值 |
2.3 为什么要这些参数?
timestamp— 限制请求的有效时间窗口,防止旧请求被截获后无限期重放nonce— 每次请求唯一的随机串,配合sign实现防重放apiKey— 识别调用者身份,服务端据此查到对应的apiSecretsignType— 明确签名算法,方便后续切换(如从 HMAC-SHA256 切换到 HMAC-SHA512),当前固定为HMAC-SHA256bizContent— 业务参数的 JSON 序列化,确保参数完整性受签名保护
三、签名生成流程(客户端)
这是整个方案的核心,客户端按以下步骤生成sign:
步骤 1:收集除sign外的所有参数
apiKey = "D3430CD0DC39DEA3" nonce = "oqCznjHGWW8VTW5j4KA4yI4rxT2DKXjW" signType = "HMAC-SHA256" timestamp = "1783300481445" bizContent = "{\"orderSn\":\"open1783300481445705511\"}"步骤 2:按字典序(ASCII 升序)排序
将所有参数名按 ASCII 字典序升序排列,sign字段本身不参与:
apiKey bizContent nonce signType timestamp关于排序顺序:这里采用的是升序排列,但降序同样可行,只要客户端和服务端约定一致即可。推荐使用字典序升序,因为这是业界通用做法(AWS、阿里云、腾讯云均采用)。
步骤 3:拼接待签名字符串
按排序后的顺序拼接为key=value&格式,末尾追加apiSecret=密钥值:
apiKey=D3430CD0DC39DEA3& bizContent={"orderSn":"open1783300481445705511"}& nonce=oqCznjHGWW8VTW5j4KA4yI4rxT2DKXjW& signType=HMAC-SHA256& timestamp=1783300481445& apiSecret=0b5d4a2a6ce24b9494d32c8e623f044f拼接规则:
- 每个参数格式为
key=value& - 参数值为
null或空字符串则跳过该参数 - 末尾追加
apiSecret=<密钥>(不带&)
步骤 4:HMAC-SHA256 加密
用apiSecret作为密钥,对拼接字符串进行 HMAC-SHA256 加密,输出大写 64 位十六进制字符串:
HMAC-SHA256(待签名字符串, apiSecret) → "74FC465E993681496A472ED68B3A5454096202B29A411FDF628B27B4960A0272"HMAC-SHA256 是一种带密钥的哈希算法,它比普通哈希多了一层安全性——没有apiSecret就无法正确生成签名。相同的输入和密钥必然产生相同的输出,输入或密钥只要有一个字符不同,输出就完全不同。
注意:拼接字符串末尾已包含
apiSecret,但 HMAC-SHA256 要求将apiSecret同时作为 HMAC 的密钥传入,而不是只靠字符串拼接。这是因为 HMAC 算法内部会对密钥进行专门的派生处理,安全性更高。
步骤 5:组装请求
将生成的sign加入请求参数,发送 POST 请求:
{"apiKey":"D3430CD0DC39DEA3","bizContent":"{\"orderSn\":\"open1783300481445705511\"}","nonce":"oqCznjHGWW8VTW5j4KA4yI4rxT2DKXjW","signType":"HMAC-SHA256","timestamp":"1783300481445","sign":"74FC465E993681496A472ED68B3A5454096202B29A411FDF628B27B4960A0272"}四、服务端验签流程(三重校验)
服务端收到请求后,按顺序执行三重校验:
请求到达 │ ▼ ┌─────────────────────────┐ │ 1. 时间戳校验(±2分钟) │ 防旧请求 │ |serverTime - timestamp| │ │ <= 120000ms ? │ └───────────┬─────────────┘ │ 通过 ▼ ┌─────────────────────────┐ │ 2. 防重放校验(SETNX) │ 防重复提交 │ Redis SETNX sign:{sign} │ │ 返回 true ? │ └───────────┬─────────────┘ │ 通过(首次) ▼ ┌─────────────────────────┐ │ 3. 签名校验 │ 防篡改 │ 重新生成签名比对 │ │ expectedSign == sign ? │ └───────────┬─────────────┘ │ 通过 ▼ 放行请求4.1 第一步:时间戳校验
服务端拿到timestamp后,与当前服务器时间对比:
longrequestTime=Long.parseLong(timestamp);longdiff=Math.abs(System.currentTimeMillis()-requestTime);if(diff>120000){return"请求时间戳无效,超出2分钟时间窗口";}- 时间窗口通常设为±2分钟,足以抵消客户端与服务器之间的时钟偏差(NTP 同步后通常偏差在秒级)
- 超过时间窗口的请求直接拒绝,防止攻击者截获旧请求后重放
4.2 第二步:防重放校验(Redis SETNX)
使用 Redis 的SETNX(Set If Not eXists)原子操作,以sign值为 key:
Booleanexisted=redis.opsForValue().setIfAbsent("sign:"+sign,// key"1",// value2,TimeUnit.MINUTES// TTL 与时间戳窗口一致);if(Boolean.FALSE.equals(existed)){return"签名已被使用,请勿重复提交";}为什么用 sign 做 Redis key 而不是 nonce?
sign = HMAC-SHA256(apiKey + nonce + timestamp + bizContent + apiSecret, apiSecret)中已经包含了随机nonce。每次合法请求的 nonce 不同 → sign 不同 → key 不冲突;重放请求的 sign 相同 → key 已存在 → 被拦截。效果等价于用 nonce 做 key,但用 sign 更安全——攻击者即使修改 nonce 也会导致签名不匹配。同时使用sign做 key,还可以做到幂等性,相同的业务请求发起多次依旧只有一次会通过。
为什么 TTL 设为 2 分钟?
与时间戳窗口对齐,2 分钟后签名自动过期失效,Redis key 也会自动清理,不需要手动维护。每个 sign 只占用 Redis 2 分钟的内存,压力极小。
4.3 第三步:签名校验
服务端根据接入方的apiKey查到对应的apiSecret,用相同的算法重新生成签名并比对:
StringexpectedSign=generateSignature(params,apiSecret,"HMAC-SHA256");if(!expectedSign.equals(sign)){return"签名验证失败";}- 所有参与签名的参数(
apiKey、nonce、signType、timestamp、bizContent)都不能被篡改 - 只要有一个字符不同,HMAC-SHA256 生成的签名就完全不同
五、核心代码实现
5.1 签名生成工具类(当前通用:HMAC-SHA256)
以下为当前开放平台所有接口通用的签名算法。MD5 相关代码保留在下方,标注为无使用/预留。
publicclassSignUtil{/** * 生成签名 * * @param data 参数集合(不含 sign) * @param secret apiSecret * @param signType 签名算法类型:当前通用 "HMAC-SHA256" * @return 大写十六进制签名 */publicstaticStringgenerateSignature(Map<String,Object>data,Stringsecret,StringsignType){// 1. 参数名按字典序排序String[]keyArray=data.keySet().toArray(newString[0]);Arrays.sort(keyArray);// 2. 拼接待签名字符串StringBuildersb=newStringBuilder();for(Stringk:keyArray){if(k.equals("sign"))continue;// sign 本身不参与Stringvalue=data.get(k)==null?null:data.get(k).toString().trim();if(value!=null&&!value.isEmpty()){sb.append(k).append("=").append(value).append("&");}}// 末尾追加 apiSecretsb.append("apiSecret=").append(secret);// 3. 根据 signType 选择加密方式StringsourceStr=sb.toString();Stringresult;try{if("HMAC-SHA256".equalsIgnoreCase(signType)){// ===== 当前通用:HMAC-SHA256 =====// Mac.getInstance("HmacSHA256") + apiSecretMacsha256HMAC=Mac.getInstance("HmacSHA256");SecretKeySpeckeySpec=newSecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),"HmacSHA256");sha256HMAC.init(keySpec);byte[]array=sha256HMAC.doFinal(sourceStr.getBytes(StandardCharsets.UTF_8));result=bytesToHex(array);}elseif("MD5".equalsIgnoreCase(signType)){// ===== 无使用 / 预留:普通 MD5 =====MessageDigestmd5=MessageDigest.getInstance("MD5");byte[]array=md5.digest(sourceStr.getBytes(StandardCharsets.UTF_8));result=bytesToHex(array);}else{thrownewIllegalArgumentException("不支持的签名算法: "+signType);}}catch(Exceptione){thrownewRuntimeException("签名生成失败",e);}returnresult.toUpperCase();}/** 字节数组转十六进制字符串 */privatestaticStringbytesToHex(byte[]bytes){StringBuilderresult=newStringBuilder();for(byteb:bytes){result.append(Integer.toHexString((b&0xFF)|0x100).substring(1,3));}returnresult.toString();}}5.2 随机串生成
publicstaticStringgenerateNonce(){Stringsymbols="0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";SecureRandomrandom=newSecureRandom();char[]nonceChars=newchar[32];for(inti=0;i<32;i++){nonceChars[i]=symbols.charAt(random.nextInt(symbols.length()));}returnnewString(nonceChars);}5.3 服务端验签核心逻辑
publicbooleanverifySignature(Map<String,Object>params){Stringsign=(String)params.get("sign");StringapiKey=(String)params.get("apiKey");Stringtimestamp=(String)params.get("timestamp");StringsignType=(String)params.get("signType");// 1. 查密钥SysOauthcredential=sysOauthMapper.selectByApikey(apiKey);if(credential==null){thrownewAuthException("无效的 apiKey");}StringapiSecret=decryptSecret(credential.getApisecret());// 2. 时间戳校验longrequestTime=Long.parseLong(timestamp);longdiff=Math.abs(System.currentTimeMillis()-requestTime);if(diff>120000){thrownewAuthException("请求已过期,时间戳偏差超过2分钟");}// 3. 防重放校验Booleanexisted=redisTemplate.opsForValue().setIfAbsent("sign:"+sign,"1",2,TimeUnit.MINUTES);if(Boolean.FALSE.equals(existed)){thrownewAuthException("签名已被使用,请勿重复提交");}// 4. 签名校验StringexpectedSign=SignUtil.generateSignature(params,apiSecret,signType);if(!expectedSign.equals(sign)){thrownewAuthException("签名验证失败");}returntrue;}六、安全要点总结
6.1 为什么三重校验缺一不可?
| 校验层 | 缺失风险 | 单独够用吗 |
|---|---|---|
| 时间戳 | 攻击者可永久重放旧请求 | ❌ 时钟偏差允许 2 分钟窗口 |
| 防重放 | — | ❌ 若无时间戳,Redis key 需永不过期,内存压力大 |
| 签名 | 参数可被篡改 | ❌ 无法防重放 |
三者配合形成完整防护链:
时间戳 → 限制有效时间窗口(2分钟) 防重放 → 窗口内只允许使用一次 签名 → 保证参数不被篡改6.2 apiSecret 安全要求
apiSecret仅在服务端和接入方之间安全分发,创建时展示一次- 绝不在网络传输中传递
apiSecret - 绝不写入日志
- 数据库存储时需加密(如 MD5 哈希)
- 建议定期更换密钥(例如每 90 天一次)
- 支持多密钥轮换:新旧密钥共存期(如 24 小时)内,两个密钥均可通过验签
6.3 bizContent 序列化一致性
这是最容易踩坑的地方。bizContent是业务参数的 JSON 序列化字符串,生成签名和验签时必须使用相同的 JSON 库和序列化方式:
✅ Fastjson: JSONObject.toJSONString(obj) ✅ Gson: new Gson().toJson(obj) ❌ 混合使用不同库可能导致字段顺序不一致 → 签名不匹配解决方式:客户端和服务端强制使用相同的 JSON 库,或对 JSON 序列化后的字符串进行规范化(如按 key 排序后再序列化)。
6.4 其他注意事项
- 参数值为空:
null或空字符串不参与签名 sign字段:自身不参与签名计算,否则形成循环依赖nonce生成:必须使用安全的随机数生成器(SecureRandom),不要用Math.random()- 排序顺序:本文采用字典序升序,实际可根据团队约定选择其他排序方式,只要前后端一致即可
七、常见问题排查
Q1:签名验证失败怎么办?
检查清单:
apiSecret是否正确(尤其是创建时是否完整复制)bizContent的序列化方式是否一致(JSON 字段顺序)- 参数值前后是否有空格
- 参数名是否拼写正确(区分大小写)
timestamp是否为 13 位毫秒时间戳- 待签名字符串末尾是否正确追加了
apiSecret=<密钥>
Q2:返回"签名已被使用"?
说明同一签名在 2 分钟内已被提交过。每次请求必须生成新的nonce,从而产生新的sign。检查客户端是否在nonce生成逻辑上出了问题(如 nonce 未刷新、定时器粒度不够)。
Q3:返回"请求时间戳无效"?
服务器时间与客户端时间差异超过 2 分钟。解决方案:
- 客户端校准系统时间(NTP 同步)
- 检查
timestamp是否为 13 位毫秒级时间戳(秒级时间戳是 10 位,会直接导致时间差在 40 年以上) - 如果确实存在较大时钟偏差,可适当放宽时间窗口(不推荐超过 5 分钟)
Q4:并发请求怎么办?
如果同一 client 在同一毫秒发起多个请求,确保每个请求的nonce不同即可——nonce 不同 → 签名不同 → Redis key 不同 → 互不冲突。这是 sign-as-key 相比于 nonce-as-key 的又一优势。
八、完整交互流程
接入方 平台 │ │ │ 1. 准备业务参数 bizContent │ │ 2. 生成 nonce(32位随机串) │ │ 3. 获取 timestamp(毫秒时间戳) │ │ 4. 按字典序拼接参数 + apiSecret │ │ 5. HMAC-SHA256 生成 sign │ │ │ │ ─────── POST 请求 ──────────────► │ │ {apiKey,nonce,signType, │ │ timestamp,bizContent,sign} │ │ │ │ │ 6. 时间戳校验(±2分钟) │ │ 7. 防重放校验(SETNX) │ │ 8. 签名校验(重新生成比对) │ │ │ ◄─────── 响应结果 ─────────────── │ │ {code,msg,data} │ │ │九、拓展与变体
9.1 签名算法:HMAC-SHA256(当前通用)
当前开放平台所有接口统一使用HMAC-SHA256:
sign = HMAC-SHA256(待签名字符串, apiSecret)| 特性 | 说明 |
|---|---|
| Java 实现 | Mac.getInstance("HmacSHA256") |
| 密钥处理 | apiSecret 同时作为 HMAC 密钥传入 |
| 输出长度 | 64 位大写十六进制 |
| 安全性 | 抗碰撞、抗长度扩展攻击 |
为什么选择 HMAC-SHA256 而非普通 MD5?
| 对比项 | HMAC-SHA256 | 普通 MD5 |
|---|---|---|
| 密钥隔离 | ✅ 密钥通过 HMAC密钥派生函数处理 | ❌ 密钥直接拼接到字符串末尾 |
| 抗碰撞性 | 强 | 已知碰撞攻击 |
| 输出长度 | 64位(256bit) | 32位(128bit) |
| 安全性等级 | 当前推荐 | 不推荐用于新系统 |
9.2 MD5 代码保留说明
工具类中保留的 MD5 分支(标注无使用 / 预留)主要用于以下场景:
- 历史兼容:老版本接入方可能使用 MD5
- 性能敏感场景:MD5 计算速度比 HMAC-SHA256 快约 2-3 倍,可在内部系统低安全要求下使用
- 预留扩展:通过
signType参数动态切换算法
9.3 支持不同的排序方式
- 字典序升序(推荐)— 通用、可预测
- 字典序降序— 同样可行,约定好即可
关键原则:客户端和服务端用同一种排序规则。
9.4 RESTful API 的 Query 参数签名
对于 GET 请求,bizContent可以改为 query string 拼接。签名规则不变,但需要将所有 query 参数(包括路径参数)纳入签名计算:
GET /api/order/detail?orderSn=xxx×tamp=xxx&nonce=xxx&sign=xxx 待签名字符串: orderSn=xxx×tamp=xxx&nonce=xxx&apiSecret=xxx9.5 多语言接入示例
HMAC-SHA256(当前通用):
| 语言 | 实现方式 |
|---|---|
| Java | Mac.getInstance("HmacSHA256") |
| Python | hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest().upper() |
| Go | crypto/hmac+crypto/sha256 |
| Node.js | crypto.createHmac('sha256', key).update(data).digest('hex').toUpperCase() |
| PHP | strtoupper(hash_hmac('sha256', $data, $key)) |
| C# | new HMACSHA256(key).ComputeHash().转十六进制+.ToUpper() |
MD5(预留 / 无使用):
| 语言 | 实现方式 |
|---|---|
| Java | MessageDigest.getInstance("MD5") |
| Python | hashlib.md5(data.encode()).hexdigest().upper() |
| Go | crypto/md5 |
| Node.js | crypto.createHash('md5').update(data).digest('hex').toUpperCase() |
| PHP | strtoupper(md5($data)) |
十、总结
开放平台的 API 认证不需要复杂的 OAuth2.0 流程,一套基于HMAC-SHA256 签名 + 时间戳 + nonce 防重放的通用方案就足以覆盖绝大多数场景。
核心要点就是三句话:
- 密钥分发一次,永不泄露
- 每次请求独立签名,参数全部参与
- 服务端三重校验:时间窗口 + 防重放 + 签名比对
这套方案已经在阿里云、腾讯云、AWS 等各大云厂商的开放平台中广泛验证,成熟可靠。对于自建的开放平台来说,它足够轻量、足够安全、足够通用。
