在开放接口或者前后端分离的项目里,请求一旦发出去就可能被中间人截获。即便用了HTTPS,内部员工或测试人员也能拿到真实报文原样重发,造成重复下单、越权查询等问题。靠账号密码或Token alone还不够,必须在应用层加上签名校验和时间戳防重放。

一、签名校验的基本思路
签名校验的核心,是让客户端和服务端持有同一份密钥,把请求参数按约定规则拼起来,再用哈希算法算出一个不可逆的摘要串,这就是签名。服务端收到请求后,用同样的规则自己算一遍,如果和传来的签名不一致,就说明参数被改过或者密钥不对。
常见的做法是使用HMAC-SHA256,它比单纯的MD5或SHA1更安全,因为引入了密钥参与运算,无法被简单碰撞。参数拼接前必须排序,否则客户端A用a=1&b=2,服务端按b=2&a=1拼,算出来就不一样。下面是一段生成签名的Python代码:
import hashlib
import hmac
def gen_sign(params, secret):
# params是字典,secret是服务端下发的密钥
sorted_items = sorted(params.items(), key=lambda x: x[0])
raw_str = ''
for k, v in sorted_items:
raw_str += '{0}={1}&'.format(k, v)
raw_str += 'secret={0}'.format(secret)
# 使用hmac_sha256
sign = hmac.new(secret.encode('utf-8'), raw_str.encode('utf-8'), hashlib.sha256).hexdigest()
return sign
params = {'user_id': '1001', 'amount': '50', 'ts': '1710000000'}
secret = 'my_private_key'
print(gen_sign(params, secret))
这段代码先把字典按key排序,拼成类似amount=50&ts=1710000000&user_id=1001&secret=my_private_key的字符串,再用HMAC算摘要。注意secret不能参与业务传输,只用在本地计算。
服务端校验时,要先把请求体或查询参数转成字典,去掉sign字段本身,再调同样的函数。如果结果和前端传的sign不同,直接返回403。这样即便被抓包,攻击者也改不了金额,因为改完签名对不上。
二、时间戳防重放机制
签名只能保证参数没被改,但拦不住别人把一模一样的包再发一次。比如攻击者截获了转账请求,原样重发十次,签名依然正确。这时就要引入时间戳(timestamp)和随机串(nonce)。
时间戳让服务端判断请求是不是太老。假设约定五分钟内有效,服务端拿到ts后和当前时间比,差值的绝对值超过300秒就拒绝。随机串则保证同一次合法请求不会被重复处理,服务端把用过的nonce存进Redis,设置过期时间和时间戳窗口一致,下次再来相同的nonce直接丢。
import time
import redis
r = redis.Redis(host='127.0.0.1', port=6379, db=0)
def check_replay(ts, nonce):
now = int(time.time())
if abs(now - int(ts)) > 300:
return False, 'timestamp expired'
if r.exists('nonce:' + nonce):
return False, 'replay attack'
# 记录nonce,五分钟过期
r.setex('nonce:' + nonce, 300, '1')
return True, 'ok'
print(check_replay('1710000000', 'abc123'))
上面的逻辑把时间和随机串结合起来。即便攻击者在有效期内重发,nonce已经在Redis里,第二次就被拦下。如果超过五分钟,时间戳本身过期,包自动失效。
实际部署时,时间戳最好由客户端用标准UTC秒数,服务端也用UTC比,避免时区错乱。随机串要有足够长度,比如16位以上,由客户端用uuid4生成,不要写死。
三、完整校验流程与常见坑
把签名和时间戳合在一起,标准流程是:客户端组装业务参数,加ts和nonce,算sign,随请求发出;服务端先查重放,再验签名,最后才执行业务。顺序不能反,先验重放能挡掉大部分垃圾流量。
很多团队把secret写死在前端JS里,这等于公开密钥,签名形同虚设。正确做法是对不同客户端发不同app_secret,或者走OAuth拿临时密钥。另一个坑是忽略参数排序,有人用JSON直接序列化,字段顺序在浏览器和服务端不一致,导致签名失败难以排查。
def verify_request(data, secret):
sign = data.pop('sign', '')
ts = data.get('ts', '')
nonce = data.get('nonce', '')
ok, msg = check_replay(ts, nonce)
if not ok:
return False, msg
local_sign = gen_sign(data, secret)
if local_sign != sign:
return False, 'sign error'
return True, 'pass'
data = {'user_id': '1001', 'amount': '50', 'ts': '1710000000', 'nonce': 'abc123', 'sign': 'x'}
print(verify_request(data, 'my_private_key'))
这个verify_request把前面说的两步包在一起。注意data.pop('sign')是为了不参与签名复算,否则自己算的串里多了sign字段,永远对不上。函数返回是否通过以及原因,方便打日志。
性能上,Redis去重和HMAC计算开销极小,单机能扛很高QPS。如果接口量极大,可以把nonce校验放本地LRU缓存,牺牲一点严格性换速度。但金融类接口建议老老实实用集中式存储。
四、小结与落地建议
签名校验加时间戳防重放,是接口安全里投入产出比很高的一招。它不依赖复杂基建,几段Python代码就能让刷接口的成本大幅上升。上线前务必用Postman模拟改参、延时重发、同包重发三种情况,确认都被拦下。
后续可以再叠加限流、设备指纹等策略。但无论如何,先把签名和时间戳这套地基打牢,别让应用在裸奔状态下暴露给公网。