前言
本文主要是分享一个挺不错的,可行的,结合 hmac 增加 http 请求安全性的方案。
🍃 小贴士: HMAC 之前我写过文章了,不了解的小伙伴可以去看看。
小目标:看完之后要能用自己的语言描述一遍 HMAC 签名方案的设计思路。
正文
问题背景
在传统的 HTTP 请求中,我们常常面临这样的安全挑战问题:
- 数据篡改:攻击者可以在传输过程中修改请求内容
- 重放攻击:合法的请求被恶意重复发送
- 身份伪造:无法确认请求的真正来源
虽然 HTTPS 解决了传输过程中的加密问题,但它无法防止【重放攻击】,也无法验证业务数据的【完整性】。
🤔 为什么?
✍️ 因为 HTTPS 只保障传输过程的安全,而【重放攻击】和【业务数据完整性】是针对已接收的合法报文进行的攻击,需要在应用层通过时序戳、流水号、数字签名等额外机制来防护。
补充知识
在网络通信的七层或四层(TCP/IP)模型中,应用层位于最顶层,直接为用户的应用程序提供网络服务(如 HTTP、API 接口),其下方是负责可靠传输的传输层(如 TCP/TLS)和负责寻址路由的网络层等。
HTTPS 是在传输层之上对通信管道进行加密和身份认证,它确保了数据在传输过程中不被窃听或篡改。然而,一旦数据被目标服务器正确接收,HTTPS 的使命就结束了,它无法辨别一个被完整接收的请求是来自用户的"一次合法操作",还是攻击者截获后重放的重复请求,也无法验证业务逻辑参数(如订单金额、用户 ID)在应用层逻辑中是否被恶意篡改。
因此,必须在应用层设计额外的安全机制,如签名、时效验证和非重复令牌,来防御此类针对业务逻辑的攻击。
HMAC 签名方案设计
下面介绍一个基于 【HMAC】 的 HTTP 请求签名方案,能有效解决上述问题:
核心思路
每个 HTTP 请求都携带一个基于请求内容和共享密钥计算出的 HMAC 签名,服务端通过验证签名来确保请求的合法性和完整性。
签名生成步骤
-
准备签名要素
- 时间戳(防止重放攻击)
- 随机数(进一步增强唯一性)
- 请求方法(GET/POST/PUT/DELETE)
- 请求路径
- 请求参数(URL 参数 + Body 参数)
- 共享密钥(服务端和客户端预先约定)
-
构造待签名字符串
text待签名字符串 = 时间戳 + "|" + 随机数 + "|" + 请求方法 + "|" + 请求路径 + "|" + 排序后的参数字符串
-
计算 HMAC 签名
javascriptsignature = crypto.createHmac('sha256', secretKey).update(待签名字符串).digest('hex')
请求头设置
将签名相关信息放入 HTTP 头中:
http
X-Auth-Timestamp: 1697012400000
X-Auth-Nonce: abc123def456
X-Auth-Signature: 7a8f9b3c4d5e6f...
服务端验证流程
服务端收到请求后的验证过程:
-
基础检查
- 检查时间戳是否在允许的时间窗口内(如 ±5 分钟)
- 检查随机数是否在近期使用过(防止重放),这个一般如何实现呢?一个可行的操作:服务端维护一个最近使用的随机数缓存,每次验证时检查随机数是否已存在于缓存中。如果存在,拒绝请求;如果不存在,将随机数加入缓存。
-
重新计算签名
- 按照相同的规则构造待签名字符串
- 使用相同的密钥计算 HMAC
-
签名比对
- 比较计算出的签名与请求头中的签名是否一致
- 一致则通过验证,不一致则拒绝请求
实际应用示例
以下是一个 Node.js 的简单实现:
javascript
// 客户端签名生成
function generateSignature(secretKey, method, path, params, timestamp, nonce) {
// 对参数按key排序并序列化
const sortedParams = Object.keys(params)
.sort()
.map(key => `${key}=${params[key]}`)
.join('&')
const stringToSign = `${timestamp}|${nonce}|${method}|${path}|${sortedParams}`
return crypto.createHmac('sha256', secretKey).update(stringToSign).digest('hex')
}
// 服务端验证
function verifySignature(request, secretKey) {
const {headers, method, path, query, body} = request
const timestamp = headers['x-auth-timestamp']
const nonce = headers['x-auth-nonce']
const receivedSignature = headers['x-auth-signature']
// 检查时间窗口
if (Math.abs(Date.now() - parseInt(timestamp)) > 5 * 60 * 1000) {
return false // 超过5分钟
}
// 检查随机数是否重复(需要维护一个近期nonce缓存)
if (nonceCache.has(nonce)) {
return false
}
nonceCache.add(nonce)
// 合并所有参数
const allParams = {...query, ...body}
const calculatedSignature = generateSignature(secretKey, method, path, allParams, timestamp, nonce)
return crypto.timingSafeEqual(Buffer.from(calculatedSignature, 'hex'), Buffer.from(receivedSignature, 'hex'))
}
这个方案的优势
- 防篡改:任何对请求内容的修改都会导致签名验证失败
- 防重放:时间戳 + 随机数机制有效防止请求被重复使用
- 身份认证:只有拥有正确密钥的客户端才能生成有效签名
- 性能高效:HMAC 计算相比非对称加密性能更好
- 易于实现:方案简单,各种编程语言都有现成的 HMAC 库
注意事项
- 密钥管理:密钥需要安全存储和定期轮换
- 时间同步:确保客户端和服务端时间基本同步
- 随机数存储:服务端需要维护一个短期的随机数缓存
- 敏感信息:即使有签名保护,敏感数据仍应通过 HTTPS 传输
最后
这个 HMAC 签名方案在实际项目中表现相当不错,既保证了安全性,又不会对性能造成太大影响。特别适合用在内部 API、微服务间调用等需要较强身份认证和防篡改的场景。
当然,没有绝对的安全方案,安全不是一劳永逸的事情,世界上没有绝对安全的系统,只能通过不断的完善和升级来提高安全等级。
这边回答下文章开头的目标:用自己的语言描述一遍 HMAC 签名方案的设计思路。
【✍️ 回答】HMAC 签名方案通过在请求中包含基于请求内容和共享密钥计算出的签名,服务端通过验证签名来确保请求的合法性和完整性。
相关链接