一、Token 解决了什么,又没解决什么
很多团队做接口安全时的第一反应是:「上了 HTTPS + 登录拿到 Token,就安全了吧?」
Token 解决的是身份认证(Authentication)——你是谁、是否登录。但它通常不绑定「这一次请求的具体内容」。这意味着有两类风险它兜不住:
- 篡改:请求里的参数被改动。比如客户端构造一笔转账
amount=100,被改成amount=1(或被恶意客户端绕过前端校验直接构造非法参数)。HTTPS 能防网络链路上的中间人篡改,但挡不住:客户端自身被逆向、TLS 在网关/负载均衡处终止后内网是明文、请求参数进了日志/监控后被污染等场景。 - 重放(Replay):攻击者抓到一次合法的请求,原封不动再发一次。最典型的就是重复扣款、重复下单、重复触发回调。
所以完整的接口安全其实是三件事,缺一不可:
| 能力 | 解决的问题 | 手段 |
|---|---|---|
| 身份认证 | 你是谁 | Token |
| 请求完整性 | 内容没被改 | HMAC 签名 |
| 请求新鲜度 | 不是重放 | 时间戳 + Nonce |
一句话:Token 管身份,签名管完整性,时间戳+Nonce 管新鲜度——三者互补,不是替代。
二、HMAC 签名:防篡改的核心
HMAC(Hash-based Message Authentication Code)= 「带密钥的哈希」。它和普通哈希(如 MD5/SHA256)的关键区别是:需要一个只有通信双方知道的 Secret。
- 普通哈希没有密钥,任何人都能算,所以无法防伪造。
- HMAC 必须有 Secret,没有 Secret 就算不出正确的签名,所以能验证「这条消息确实是由持有 Secret 的一方发出的,且没被改动」。
待签名串(Canonical String)怎么构造
签名不是对「整个 HTTP 报文」随便哈希一下就完事,而是要把所有需要保护的内容按一套确定、无歧义的规则拼成一个字符串,再做 HMAC。典型要素:
HTTP Method
请求路径 path
排序后的 query 参数
body 的哈希(通常用 SHA256)
app_id
timestamp
nonce
一个常见的拼接规则示例:
待签名串 = METHOD + "\n"
+ PATH + "\n"
+ QUERY(按 key 字典序) + "\n"
+ SHA256(body) + "\n"
+ APP_ID + "\n"
+ TIMESTAMP + "\n"
+ NONCE
signature = HMAC_SHA256(secret, 待签名串) → 十六进制 / Base64
三、时间戳 + Nonce:防重放的核心
只靠其中一个都不够:
- 只靠时间戳:在窗口期(比如 5 分钟)内的请求仍可被重复提交。
- 只靠 Nonce:Nonce 要永久存储才能彻底防重放,存储会无限增长,不现实。
两者结合才是工业界标准做法:
- 时间戳:拒绝超过 N 分钟的请求,划定一个绝对时间窗(比如 ±5 分钟)。
- Nonce:只要求在窗口期内不重复,划定相对去重。
这样一来,服务端只需要在「窗口期」内存储 Nonce,过期就能清理,存储开销是可控的。
四、一次完整的签名请求长什么样
POST /api/v1/transfer HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
X-App-Id: client_001
X-Timestamp: 1753300000
X-Nonce: 3f2a8b9e7c1d4f6a
X-Signature: 9a7f2c4b8e1d...
Content-Type: application/json
{"amount":100,"to":"alice"}
Authorization:原有 Token,继续做身份认证;X-App-Id:标识是哪个客户端/应用,服务端据此查它的 Secret;X-Timestamp/X-Nonce:新鲜度材料,也参与签名;X-Signature:对上面所有材料 + 业务参数算出的 HMAC。
五、客户端:生成签名(Python)
import hashlib, hmac, json, time, uuid, requests
APP_ID = "client_001"
SECRET = b"super-secret-key-from-kms" # 千万别硬编码、别进仓库
def call(method, url, params, body_obj):
ts = str(int(time.time()))
nonce = uuid.uuid4().hex
# 关键:预先序列化成「确定字节」,服务端直接对这串字节算哈希才能对上
body_bytes = b"" if body_obj is None else \
json.dumps(body_obj, separators=(",", ":"), sort_keys=True).encode()
body_hash = hashlib.sha256(body_bytes).hexdigest() if body_bytes else ""
# query 按 key 字典序拼接
sorted_query = "&".join(f"{k}={params[k]}" for k in sorted(params))
# 待签名串
string_to_sign = "\n".join([
method.upper(), "/api/v1/transfer", sorted_query, body_hash,
APP_ID, ts, nonce,
])
# HMAC-SHA256
signature = hmac.new(SECRET, string_to_sign.encode(), hashlib.sha256).hexdigest()
headers = {
"X-App-Id": APP_ID,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": signature,
"Authorization": "Bearer <access_token>",
"Content-Type": "application/json",
}
return requests.request(method, url, params=params, data=body_bytes, headers=headers)
六、服务端:校验流程(Python)
核心是五步,顺序很重要:
import hashlib, hmac, time
ALLOWED_SKEW = 300 # 允许 ±5 分钟时钟偏差
def verify(method, path, query, raw_body, headers):
app_id = headers["X-App-Id"]
ts = int(headers["X-Timestamp"])
nonce = headers["X-Nonce"]
signature = headers["X-Signature"]
secret = get_secret(app_id) # 1. 按 app_id 从配置中心/KMS 取密钥
if not secret:
raise Unauthorized("unknown app")
# 2. 时间窗口校验 —— 过期请求直接拒绝
if abs(int(time.time()) - ts) > ALLOWED_SKEW:
raise Unauthorized("expired timestamp")
# 3. Nonce 去重 —— 窗口期内不能重复(防重放)
key = f"nonce:{app_id}:{nonce}"
if not redis.set(key, "1", ex=ALLOWED_SKEW, nx=True): # SETNX + TTL
raise Unauthorized("replay detected")
# 4. 按相同规则重算签名
body_hash = hashlib.sha256(raw_body or b"").hexdigest()
sorted_query = "&".join(f"{k}={v}" for k in sorted(query))
string_to_sign = "\n".join([
method.upper(), path, sorted_query, body_hash, app_id, str(ts), nonce,
])
expected = hmac.new(secret, string_to_sign.encode(), hashlib.sha256).hexdigest()
# 5. 恒定时间比较 —— 防时序攻击,千万不要用 ==
if not hmac.compare_digest(expected, signature):
raise Unauthorized("bad signature")
return app_id # 通过,继续走业务 + Token 身份认证
为什么顺序是「时间戳 → Nonce → 签名」?前两步是廉价的过滤,能先把明显非法/重放的请求挡掉,避免对它们做(相对昂贵的)HMAC 计算,也算一种简单的防刷策略。
七、容易踩的坑(实战经验)
- Secret 管理是命门:不要硬编码、不要进 Git、不要进日志。用配置中心/KMS,按
app_id查询,并支持密钥轮换(双密钥共存一段时间再切换)。 - 签名比对必须用恒定时间比较(
hmac.compare_digest),不要用==。==在字节不一致时会提前返回,攻击者可据此做时序攻击逐字节爆破签名。 - 待签名串必须「无歧义」:用换行/分隔符甚至「长度前缀」分隔字段。否则
a=1,b=23和a=12,b=3可能拼出同样的字符串,造成歧义。 - body 参与签名要用哈希,且客户端要预先序列化成确定字节再发送(
sort_keys=True+ 固定separators)。否则客户端和服务端对 body 的字节理解不一致,签名永远对不上——这是最常见的「为什么我签名老失败」。 - query 参数要统一编码规则:按 key 字典序,值是否 URL-encode、空值怎么处理,双方必须一致,最好有明确的规范文档。
- 时钟同步:服务端和客户端时钟可能不同步,窗口放宽到 ±5 分钟较稳妥,但也别太大(太大等于削弱防重放)。
- Nonce 存储用 Redis 的
SETNX + TTL,轻量高效;别落数据库。 - HTTPS 仍然是底线:签名不替代加密。Secret 绝不能在网络明文传输,所以 TLS 是前提,签名是在其之上再加一层应用层的完整性与新鲜度保障。
- 影响业务的参数都要签:漏签任何一个关键参数,该参数就可能被篡改。判断标准是「这个参数被改了,业务结果会不会变」。
- 别把 Secret 放进待签名串,也别让签名字段本身漏掉——签名覆盖的集合要和校验时完全一致。
八、适用场景
这套方案尤其适合:
- 开放平台 / 对外 API(第三方接入,按 app 分发密钥);
- 支付、转账、下单等对幂等和防重放要求极高的接口;
- Webhook / 第三方回调(验证回调确实来自声称的发送方,且未被重放);
- 任何接口参数一旦被篡改会造成资损或数据污染的场景。
九、总结
记住那个分工表就够了:
Token 管身份,HMAC 管完整性,时间戳 + Nonce 管新鲜度。
把三者叠加,你的接口才真正具备了「认证 + 防篡改 + 防重放」的完整安全闭环。而其中最容易掉链子的,往往不是算法本身,而是 Secret 管理、body/query 序列化一致性、以及恒定时间比较这些工程细节——把这几处守住,方案才立得住。
附:示例用 Python 是因为它最短最清晰,算法(HMAC-SHA256 + 时间戳窗口 + Nonce 去重)在 Java(javax.crypto.Mac)、Go(crypto/hmac)、Node(crypto.createHmac)里逻辑完全一致,迁移时只需替换对应 SDK 即可。