接口签名认证详解:从原理到代码
适用场景:两个系统之间对接,一方(下文称 A 系统)调用另一方(下文称 B 系统)的 HTTP 接口。 阅读前置:了解 HTTP 请求的基本概念即可,不需要密码学基础。
目录
- 一、先看一个问题:为什么不直接传密码
- 二、核心原理
- 三、四个字段各解决什么问题
- 四、签名算法怎么选
- 五、完整代码示例
- 六、为什么不能只靠 HTTPS
- 七、六个常见坑
- 八、nonce 到底要不要存?
- 九、进阶:非对称签名
- 十、落地检查清单
- 十一、一句话总结
一、先看一个问题:为什么不直接传密码?
假设 A 系统要调用 B 系统的接口下单。最直觉的做法是:
POST /api/order?user=sys-a&password=123456
这个做法有个致命问题:密码每次请求都在网络上裸奔。
请求会经过 Nginx、网关、日志系统、APM 探针……链路上任何一个环节留下了明文,密码就泄露了。更糟的是,密码通常是长期有效的——一旦泄露,攻击者可以永久冒充你。
那么把密码加密一下再传呢?
password = AES(123456) // 固定密钥加密
也不行:加密结果是固定的,攻击者拿到这个密文照样可以原样重放。
于是就有了下面这套方案。
二、核心原理
2.1 两个凭证,一个公开一个保密
双方事先线下交换一对凭证(由"发一个工单,对方私下给你"这类方式提供):
| 凭证 | 作用 | 类比 | 是否随请求传输 |
|---|---|---|---|
appKey | 身份标识 | 用户名 | ✅ 明文传 |
appSecret | 密钥 | 密码 | ❌ 永不传输 |
双方各自保存一份 appSecret,两边必须完全一致(这是对称密钥)。
2.2 签名 = 报文的"指纹"
调用方在发送前,用 appSecret 对本次请求的内容做一次运算,得到一个结果,叫做签名。
⚠️ 先强调一点:签名是摘要(指纹),不是加密。
sign里不含报文,也无法还原出报文。这一点很容易误解,详见 2.5。
服务端收到请求后,用自己那份 appSecret 把同样的运算重做一遍,比对两个结果是否一致。
这个方案依赖两个关键性质:
- 雪崩效应:报文里改动任意一个字节,算出的签名会完全不同 → 改数据必然被发现
- 不可伪造:不知道
appSecret的人,算不出正确签名 → 冒充不了
所以服务端只要验签通过,就能同时确认两件事:"你确实是持有密钥的那一方" + "报文在传输途中一个字节都没被改"。
一次调用中,签名的输入和输出长这样:
输入(待签名字符串):
amount=100.00&appKey=sys-a&nonce=a1b2c3d4&orderNo=SO20261008001×tamp=1730000000
加盐(密钥):
appSecret = "s3cr3t-x9f2..."
输出(签名):
sign = "K7fQ2mZ0pL8vR3nX1wY6tA4cE5gH9jB2dF0sM7qW="
注意:appSecret 本身没有出现在"输入"里,它是作为密钥参与运算的。这正是它永不传输的原因。
2.3 完整流程
sequenceDiagram
autonumber
participant C as 调用方 (A 系统)
participant S as 服务方 (B 系统)
Note over C: 组装业务参数
Note over C: 加 appKey/timestamp/nonce
Note over C: 用 appSecret 计算 sign
C->>S: 请求(appKey, timestamp, nonce, 业务参数, sign)
Note over S: 用 appKey 查出对应的 appSecret
Note over S: ① 校验 timestamp 是否在时间窗口内
Note over S: ② 校验 nonce 是否已用过
Note over S: ③ 重算 sign 并比对
alt 全部通过
S-->>C: 业务处理结果
else 任一项失败
S-->>C: 401 签名校验失败
end
2.4 漏洞:签名永远相同
如果只用 appKey + appSecret,还有一个大漏洞:
同一个报文,算出的签名永远相同。
这意味着攻击者只要抓到一次合法请求(比如"给 A 转账 100 元"),就能把这段报文原样重发 1000 次。服务端每次验签都会通过,因为签名确实是对的。
这叫 重放攻击(Replay Attack)。
补丁就是后面的 timestamp 和 nonce 两个字段。
2.5 澄清:sign 是报文摘要,不是加密后的报文
这是一个非常常见的误解:以为
sign里装的是加密后的报文,服务端把它解密就能拿到数据。不是的。
sign里没有报文,报文还原不出来。
HMAC-SHA256 的输出是固定 32 字节,Base64 后只有 44 个字符:
报文: { "orderNo": "SO20261008001", "amount": "100.00", ... } ← 10 KB
sign: K7fQ2mZ0pL8vR3nX1wY6tA4cE5gH9jB2dF0sM7qW= ← 44 字符
一个 10 KB 的报文不可能被压进 44 个字符再还原回来。这不是"难破解",而是信息量根本不够——数学上不可能。
因为它是单向摘要,不是加密:
报文 ────► sign ✅ 容易算
sign ────► 报文 ❌ 不可能(信息已经丢了)
加密 vs 签名
| 加密(AES / RSA) | 签名(HMAC-SHA256) | |
|---|---|---|
| 输出长度 | 与原文一样长 | 固定 32 字节 |
| 可逆吗 | ✅ 有密钥就能还原出原文 | ❌ 永远还原不回来 |
| 目的 | 保密——别人看不到内容 | 证明——内容没被改过,且是你发的 |
| 类比 | 保险箱(有钥匙就能取出里面的东西) | 火漆印(证明是谁封的,但印里没有信) |
一句话记住:sign 是火漆印,不是保险箱。
那 body 还要不要传原始报文?
必须传,而且必须原样传。
因为服务端验签的方式是"重算一遍":
Map<String,String> params = readParams(req); // ← 报文来自 body,不是来自 sign
params.remove("sign"); // sign 自身不参与签名
String expected = ApiSignUtil.sign(params, appSecret); // 用同样的规则重算
if (!ApiSignUtil.safeEquals(expected, sign)) {
return reject("签名校验失败");
}
如果 body 里只有 sign,服务端手里就只有一个孤零零的字符串,没有任何东西可以拿去比对——验签这个动作根本无法执行。
反过来说:sign 单独拿出来是毫无意义的,它的全部价值只在于"能和重算结果对得上"。
顺带解释一个现象
这也说明了为什么"签名对不上"是对接中最常见的故障,而且往往只差一个空格或编码:
服务端是拿你传的报文重算的。你改动了报文的任何一个字节,重算结果就跟着变,验签必然失败。
这不是 bug,这正是机制在起作用——它恰好证明了"报文在传输途中没有被改动过"。
那报文在链路上是明文,不会泄露吗?
是明文(TLS 保护之外的部分)。签名不提供机密性——中间人能看到 amount=100.00 是多少,但改不了(改一个字节签名就对不上,服务端直接拒)。
需要"报文本身也不让人看见"时,那要靠加密,是另一件事,并且可以和签名叠加:
{
"encryptedData": "<AES(原始报文)>",
"sign": "<HMAC(encryptedData)>"
}
注意此时签名的对象变成了密文:服务端先验签(确认密文没被篡改),再解密拿到明文。
| 场景 | 需要额外加密吗 |
|---|---|
| 内部系统对接,走内网 / HTTPS | ❌ 不需要,TLS 足够 |
| 报文含身份证、银行卡号等敏感信息 | ⚠️ 可考虑(TLS 已覆盖,若担心对方落盘则加) |
| 不信任传输链路(如经过第三方网关) | ✅ 需要 |
| 合规要求"端到端加密" | ✅ 需要 |
对于内网系统对接,通常不需要——TLS 做链路保密 + 签名做完整性与身份认证,各管一段就够了。
三、四个字段各解决什么问题
| 字段 | 解决的问题 | 攻击场景 | 怎么做到的 |
|---|---|---|---|
appKey | 你是谁 | 匿名伪造请求 | 服务端据此查出对应密钥,查不到直接拒 |
sign | 报文有没有被改 | 中间人把 金额=100 改成 金额=100000 | 签名是报文内容的函数,改了就验不过 |
timestamp | 请求是不是太旧了 | 抓到包,一小时后重放 | 超出时间窗口(如 60 秒)直接拒 |
nonce | 请求是不是发过一遍了 | 在 60 秒窗口内把同一请求连发 1000 次 | 服务端记录窗口内用过的随机串,用一次作废 |
理解它们的分工,可以记成一句话:
appKey 解决「身份」 —— 你是谁
sign 解决「完整性」 —— 你发的内容没被改过
timestamp 解决「时效」 —— 这个请求是新鲜的
nonce 解决「唯一性」 —— 这个请求只发过一次
四个里 timestamp 是性价比最高的:零存储成本,却能挡住绝大多数重放。nonce 最贵(需要共享存储),只在高价值写接口上才必要——详见第八章。
四、签名算法怎么选
4.1 两种常见写法
写法 A:拼接密钥式(支付宝老版 API 的风格)
String raw = appKey + timestamp + 业务参数 + appSecret;
String sign = MD5(raw).toUpperCase();
把 appSecret 直接拼在字符串末尾,然后做一次摘要。
写法 B:HMAC 式(推荐)
String raw = 待签名字符串; // 不含 appSecret
String sign = Base64(HMAC-SHA256(raw, appSecret)); // appSecret 作为密钥
4.2 为什么推荐 HMAC-SHA256
| 维度 | 写法 A(MD5 拼接) | 写法 B(HMAC-SHA256) |
|---|---|---|
| 摘要算法强度 | MD5 已被证明存在实用碰撞 | SHA-256 目前安全 |
| 结构安全性 | 拼接式存在长度扩展攻击风险 | HMAC 有严格的密钥/消息分离结构,抗此类攻击 |
| 性能 | 略快 | 完全够用(微秒级) |
| 业界趋势 | 逐步淘汰 | 主流选择 |
结论:新对接一律用 HMAC-SHA256 + Base64。除非对方系统很老只支持 MD5,才被迫兼容——那就至少保证业务参数顺序、编码、空值规则严格统一。
五、完整代码示例
5.1 第一步:双方约定规则
这是整个方案里最容易出故障的地方。 签名规则必须写成文档,双方一字不差地实现。任何一处细节不一致,都会导致"签名永远对不上"。
约定示例(本文后续代码均按此实现):
- 参与签名的字段:所有请求参数,但不包括
sign本身 - 排序:按参数名的 ASCII 码升序(Java 里就是
TreeMap<String,String>的默认顺序) - 空值处理:值为
null或空字符串的参数 一律剔除,不参与拼接 - 拼接格式:
k1=v1&k2=v2&k3=v3,没有结尾的& - 字符编码:统一 UTF-8
- 编码:参数值不做 URL 编码(用原始值拼接;如果走 form 表单传输,传输时可以编码,但计算签名时用原始值)
- 算法:
Base64(HMAC-SHA256(待签名字符串, appSecret)) - 时间窗口:双方约定 60 秒
- nonce:32 位随机十六进制字符串
5.2 客户端:生成签名
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
public final class ApiSignUtil {
private ApiSignUtil() {}
public static final String APP_KEY = "sys-a";
public static final String APP_SECRET = "s3cr3t-x9f2k8m4p1q7w3e5";
/** 生成签名 */
public static String sign(Map<String, String> params, String appSecret) {
return hmacSha256Base64(canonicalize(params), appSecret);
}
/**
* 生成待签名字符串(客户端和服务端必须完全一致)
* 规则:剔除 sign 与空值 → 按 key 升序 → 拼成 k=v&k=v
*/
public static String canonicalize(Map<String, String> params) {
TreeMap<String, String> sorted = new TreeMap<>();
params.forEach((k, v) -> {
if (k == null || "sign".equals(k)) return; // sign 自身不参与签名
if (v == null || v.isEmpty()) return; // 空值剔除,双方必须一致
sorted.put(k, v);
});
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> e : sorted.entrySet()) {
if (sb.length() > 0) sb.append('&');
sb.append(e.getKey()).append('=').append(e.getValue());
}
return sb.toString();
}
private static String hmacSha256Base64(String data, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(raw);
} catch (Exception e) {
throw new IllegalStateException("签名计算失败", e);
}
}
/** 固定时间比较,防时序攻击 */
public static boolean safeEquals(String a, String b) {
if (a == null || b == null) return false;
return MessageDigest.isEqual(
a.getBytes(StandardCharsets.UTF_8),
b.getBytes(StandardCharsets.UTF_8));
}
// ---------- 使用示例 ----------
public static void main(String[] args) {
Map<String, String> params = new HashMap<>();
params.put("appKey", APP_KEY);
params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
params.put("nonce", UUID.randomUUID().toString().replace("-", ""));
params.put("orderNo", "SO20261008001");
params.put("amount", "100.00");
// 注意:sign 必须在其他所有字段都放好之后,最后计算
params.put("sign", sign(params, APP_SECRET));
System.out.println(params);
// 待签名字符串 = amount=100.00&appKey=sys-a&nonce=<32位>&orderNo=SO20261008001×tamp=1730000000
}
}
5.3 服务端:校验签名
服务端通常做成一个拦截器,在业务逻辑之前统一处理:
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.util.Map;
@Component
public class ApiSignInterceptor implements HandlerInterceptor {
/** 时间窗口:60 秒 */
private static final long WINDOW_SECONDS = 60;
@Resource private AppKeyRepository appKeyRepository; // 按 appKey 查 appSecret
@Resource private StringRedisTemplate redis;
@Override
public boolean preHandle(HttpServletRequest req,
HttpServletResponse resp,
Object handler) throws Exception {
Map<String, String> params = readParams(req); // 从 body / query 中取出所有参数
String appKey = params.get("appKey");
String timestamp = params.get("timestamp");
String nonce = params.get("nonce");
String sign = params.get("sign");
if (appKey == null || timestamp == null || nonce == null || sign == null) {
return reject(resp, "缺少签名参数");
}
// ① 查密钥
String appSecret = appKeyRepository.findSecret(appKey);
if (appSecret == null) {
return reject(resp, "appKey 无效");
}
// ② 校验时间窗口
long ts;
try {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
return reject(resp, "timestamp 格式错误");
}
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - ts) > WINDOW_SECONDS) {
return reject(resp, "请求已过期");
}
// ③ 验签(必须在 nonce 落库之前!)
String expected = ApiSignUtil.sign(params, appSecret);
if (!ApiSignUtil.safeEquals(expected, sign)) {
return reject(resp, "签名校验失败");
}
// ④ nonce 去重
String nonceKey = "api:nonce:" + appKey + ":" + nonce;
Boolean first = redis.opsForValue()
.setIfAbsent(nonceKey, "1", Duration.ofSeconds(WINDOW_SECONDS));
if (!Boolean.TRUE.equals(first)) {
return reject(resp, "重复请求");
}
return true; // 全部通过,放行到业务逻辑
}
private boolean reject(HttpServletResponse resp, String msg) throws Exception {
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write("{\"code\":401,\"msg\":\"" + msg + "\"}");
return false;
}
private Map<String, String> readParams(HttpServletRequest req) {
// 实现要点:把 JSON body 和 query string 合并成 Map<String,String>
// 若 body 是 JSON,需要先用 ContentCachingRequestWrapper 包装请求才能重复读取
throw new UnsupportedOperationException("按项目实际情况实现");
}
}
注册拦截器:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource private ApiSignInterceptor apiSignInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiSignInterceptor)
.addPathPatterns("/openapi/**"); // 只对开放接口生效,别拦内部接口
}
}
顺序很重要:先验签,再消费 nonce。 如果反过来,攻击者只要知道你的
appKey(这是公开的),就能疯狂发假请求,把nonce全占掉,导致正常的请求被判定为重复——这是一种低成本拒绝服务攻击。先验签,假请求在第 ③ 步就被挡掉了,根本走不到 ④。
5.4 nonce 去重的 Redis 实现
核心就是一行——SET key value NX EX 60,原子地将"检查"和"写入"合并:
Boolean first = redis.opsForValue()
.setIfAbsent(nonceKey, "1", Duration.ofSeconds(WINDOW_SECONDS));
setIfAbsent= Redis 的SET ... NX,key 不存在才写入- 返回
true→ 首次出现,放行 - 返回
false→ 窗口内已用过,判定为重放
TTL 为什么只需要 60 秒?
因为 timestamp 窗口也是 60 秒。一旦请求的时间戳过期,第 ② 步就把它拒了,根本走不到 nonce 校验——这条 nonce 记录留着也永远不会再被查到。所以 nonce 的 TTL = timestamp 的窗口 即可,Redis 会自动淘汰。
内存占用是有界的:
峰值 key 数 = 窗口内 QPS × 窗口秒数
| QPS | 窗口 | 峰值 key 数 | Redis 内存(约 100 B/key) |
|---|---|---|---|
| 100 | 60 s | 6000 | ~0.6 MB |
| 1000 | 60 s | 60000 | ~6 MB |
| 1000 | 300 s | 300000 | ~30 MB |
跟运行时长无关——跑一年和跑一天占用一样。
5.5 用 curl 手工验证
对接调试时,用脚本自己算一遍签名,比写代码快得多:
APP_KEY="sys-a"
APP_SECRET="s3cr3t-x9f2k8m4p1q7w3e5"
TS=$(date +%s)
NONCE=$(uuidgen | tr -d '-' | tr 'A-Z' 'a-z')
# 注意:参数必须按 ASCII 升序排列(amount < appKey < nonce < orderNo < timestamp)
PARAMS="amount=100.00&appKey=${APP_KEY}&nonce=${NONCE}&orderNo=SO20261008001×tamp=${TS}"
# HMAC-SHA256 后 Base64
SIGN=$(printf '%s' "$PARAMS" | openssl dgst -sha256 -hmac "$APP_SECRET" -binary | base64)
echo "待签名串: $PARAMS"
echo "签 名 : $SIGN"
curl -X POST https://system-b.example.com/openapi/order \
-H 'Content-Type: application/json' \
-d "{
\"appKey\":\"${APP_KEY}\",
\"timestamp\":\"${TS}\",
\"nonce\":\"${NONCE}\",
\"orderNo\":\"SO20261008001\",
\"amount\":\"100.00\",
\"sign\":\"${SIGN}\"
}"
排查技巧:签名对不上时,让服务端把收到的"待签名字符串"打日志(注意脱敏),和调用方本地算的对比。差异通常在空格、编码、空值这几处,一眼就能看出来。
六、为什么不能只靠 HTTPS
一个常见疑问:HTTPS 已经把整个链路加密了,为什么还要额外做签名?
因为二者保护的层次不同:
| HTTPS (TLS) | 接口签名 | |
|---|---|---|
| 保护范围 | 传输链路 | 报文内容本身 |
| 保护对象 | 防止链路上被窃听/篡改 | 防止报文在服务端内部被篡改,以及识别调用方身份 |
| 身份认证 | 只验证"服务器是真的"(单向) | 验证"调用方是谁" |
| 失效点 | 请求到达网关被解密后,就是明文了 | 签名一直有效直到被业务消费 |
具体来说,HTTPS 管不到这些:
- 调用方身份:普通 HTTPS 只做服务端单向认证,客户端是谁它不知道。双向 TLS(mTLS)能做到,但证书发放/吊销/轮换的运维成本远高于一个
appSecret。 - 服务端内部信任边界:请求被网关解密后,参数以明文在服务内部流转。签名让每一层都能独立验证报文未被篡改。
- 弱信任的开放平台场景:第三方接入方众多,需要"请求自带可验证凭证",而不是靠运维维护一堆证书。
- 可审计:每个请求携带独立签名,出问题可以精确追溯到具体报文。
结论:不是二选一,而是 HTTPS 做链路加密 + 签名做业务级认证,两者互补。
七、六个常见坑
坑 1:签名对不上(最常见的故障,占 90%)
几乎总是这几处不一致:
| 不一致点 | 表现 |
|---|---|
| 参数排序方式不同 | 完全对不上 |
空值处理不同(一方剔除、一方保留 "") | 有参数为空时才对不上 |
| 编码不同(UTF-8 vs GBK) | 有中文时才不对上 |
拼接多了/少了结尾的 & | 完全对不上 |
数字格式不同(100 vs 100.00) | 金额类字段对不上 |
数组序列化不同(a,b vs ["a","b"]) | 有数组参数时才对不上 |
对策:服务端把待签名字符串打日志,直接对比。
坑 2:用 equals 比较签名(时序攻击)
普通字符串比较是逐字节短路的——发现第一个不同的字符就返回。攻击者可以通过测量响应时间,一个字节一个字节地爆破出正确签名。
必须用固定时间比较:
// ❌ 错误
if (expected.equals(sign)) { ... }
// ✅ 正确
if (MessageDigest.isEqual(expected.getBytes(UTF_8), sign.getBytes(UTF_8))) { ... }
坑 3:服务器时钟不同步
客户端时间快 5 分钟,服务端窗口设 60 秒 → 所有请求都被判为过期。
对策:部署 NTP 时钟同步;窗口设为 60 秒(给时钟漂移留余量);返回错误时明确提示"请求已过期",方便定位。
坑 4:appSecret 硬编码进代码仓库
一旦进了 Git,就等于公开了。
对策:密钥放配置中心 / 密钥管理服务,环境变量注入。并且支持轮换——设计上允许同时存在新旧两把密钥(双密钥并存过渡期),否则换密钥需要双方同时停机。
坑 5:nonce 存在本地内存
服务有 3 个实例,请求被负载均衡轮询到不同实例 → 每个实例都认为自己是第一次见到这个 nonce → 去重完全失效。
对策:nonce 必须存共享存储(Redis),不能存 JVM 内存。
坑 6:先消费 nonce 再验签
假请求也能占掉 nonce 名额,形成拒绝服务。必须先验签,再消费 nonce。
八、nonce 到底要不要存?
这是最常被质疑的一点:"那这个随机数岂不是服务端一直得存着?"
答案是:只存一个时间窗口,不是永久存(见 5.4)。但更值得说的是——很多场景根本不需要 nonce。
8.1 按场景判断
| 场景 | 需要 nonce 吗 | 原因 |
|---|---|---|
| 查询类接口(GET 订单、拉列表) | ❌ 不需要 | 重放一个读请求没有任何危害,timestamp 挡住过期重放就够了 |
| 天然幂等的写接口(按外部单号 upsert) | ❌ 不需要 | 业务层用唯一键已经挡了 |
| 有状态机保护的写接口(状态只能 A→B→C) | ⚠️ 弱需要 | 重放通常会在状态校验时失败 |
| 非幂等的高价值写接口(扣款、发货、生成流水号) | ✅ 需要 | 必须挡 |
现实中,支付宝老版 API 根本没有 nonce 字段,靠 timestamp + 业务幂等;微信支付 v3 的 nonce_str 主要作用是参与签名和排查问题,并非严格去重。严格意义的 nonce 去重是一道可选的加强防线。
8.2 更省的替代方案
方案 A:用签名本身当去重 key
不额外生成随机串,直接 SET api:dedup:{appKey}:{sign}。效果相同,存储开销一样,但协议更干净,还避免了客户端 nonce 生成不规范(用低熵随机数、甚至写死常量)导致的漏洞。
方案 B:交给业务幂等(最省,推荐作为主力)
要求所有写接口都带业务唯一键(外部单号、请求 ID),在数据库唯一索引或分布式锁那一层去重。
这是做系统集成本来就该有的东西——即使没有签名机制也需要它来防止业务重复。做好之后,nonce 的边际价值就很低了。
方案 C:单调递增序列号(O(1) 存储)
if (seq <= lastSeq) reject;
lastSeq = seq;
服务端只存一个数字。但代价明显:客户端多实例部署时需要分布式发号(又变成瓶颈),且网络乱序到达会误杀合法请求。REST 场景基本不用。
方案 D:布隆过滤器 — 内存极小,但无法删除单个元素,TTL 到期没法精确淘汰,误判会拒绝合法请求。支付类接口不可接受。
8.3 推荐组合
| 层次 | 做法 | 成本 |
|---|---|---|
| 必做 | appKey + timestamp(60s) + HMAC-SHA256 | 零存储 |
| 必做 | 写接口的业务幂等键(DB 唯一索引) | 一个索引 |
| 按需 | 高价值写接口加 Redis nonce 去重,TTL 60s | ~几 MB |
| 不要做 | 给查询接口加 nonce | 纯浪费 |
九、进阶:非对称签名
对称密钥方案有一个固有特点:服务端也持有 appSecret,所以服务端理论上可以伪造调用方的签名。
在大多数内部系统对接里这不是问题(双方互信)。但有些场景需要更强的保证:
- 不可否认性:调用方事后不能抵赖"我没发过这个请求"
- 服务端不可伪造:服务端自己造不出调用方的签名
- 一对多分发:一个服务端对接大量第三方,不想给每人维护一把共享密钥
这时改用非对称密钥:
| 对称(HMAC) | 非对称(RSA / SM2 / ECDSA) | |
|---|---|---|
| 密钥 | 双方共享 appSecret | 调用方持私钥,服务方持公钥 |
| 签名 | HMAC(报文, appSecret) | Sign(报文, 私钥) |
| 验签 | 重算一遍 HMAC | Verify(报文, sign, 公钥) |
| 服务端能否伪造请求 | 能 | 不能 |
| 性能 | 快(微秒级) | 慢 10–100 倍 |
| 密钥管理 | 简单 | 复杂(需要分发公钥、处理轮换) |
选型建议:
- 内部系统对接(如 A 系统 ↔ B 系统)→ 对称 HMAC,简单高效,是正确选择
- 金融场景 / 需要强不可否认性 / 对外开放平台 → 考虑非对称
十、落地检查清单
对接新接口时,逐项确认:
协议约定
- 签名规则写成文档,双方确认:排序、空值、编码、拼接格式、算法
- 时间窗口明确(建议 60 秒)
-
nonce生成规则明确(建议 32 位随机十六进制) - 错误码定义清晰(缺参数 / appKey 无效 / 已过期 / 签名错误 / 重复请求)
客户端
-
sign字段在其他所有字段组装完成后才计算 -
timestamp用秒级还是毫秒级,与对方一致 - 时间源准确(NTP)
服务端
-
appKey无效、时间过期、签名错误、重复请求,响应明确区分 - 先验签,再消费 nonce
- 签名比较用固定时间比较
- nonce 存 Redis 共享存储,TTL = 时间窗口
- 待签名字符串打日志(脱敏),便于排查对不上的问题
- 拦截器只作用于开放接口路径(
/openapi/**),不误伤内部接口
安全
-
appSecret不进代码仓库,走配置中心 - 支持密钥轮换(双密钥并存过渡)
- 密钥传输走安全通道(线下 / 加密信道),绝不通过即时通讯明文发
- 高价值写接口有业务幂等键
十一、一句话总结
签名方案的本质是:不传密码,传"密码的运算结果"。
appKey告诉服务端"我是谁",服务端据此找到密钥sign让服务端能确认"报文没被改过,且你确实持有密钥"timestamp让旧请求作废,挡住重放nonce让窗口内的重复请求作废,挡住窗口内重放(按需使用,只存一个窗口)
为什么这样设计:因为对称密钥永不传输,攻击者既拿不到密钥,也改不了报文,还无法重放——一次性解决了身份认证、数据完整性、防重放三个问题,而成本只是几个字段和一次哈希运算。
相关文档
- JWT与OAuth2.0详解.md —— 面向用户登录场景的令牌方案(本文档讲的是系统间接口调用场景,两者解决的问题不同)
- 登录认证机制说明.md