如何在 Go 语言中实现基于 HMAC 签名的 API 请求验证?

文章导读
在 Go 语言中实现 HMAC 签名验证,最推荐的做法是使用标准库 crypto/hmac 结合 sha256 算法,并在服务端使用恒定时间比较函数防止时序攻击,适用于开放 API 或内部服务间的身份鉴别。
📋 目录
  1. 核心原理与代码实现
  2. 如何验证签名生效
  3. 进阶:防重放 Nonce 机制
  4. 常见坑与排查
  5. 参考来源
A A

在 Go 语言中实现 HMAC 签名验证,最推荐的做法是使用标准库 crypto/hmac 结合 sha256 算法,并在服务端使用恒定时间比较函数防止时序攻击,适用于开放 API 或内部服务间的身份鉴别。

先说结论:实现 HMAC 验证是为了防篡改和认证,配合时间戳或 Nonce 才能防重放,必须配合 HTTPS 传输才能构成完整的安全闭环。

  • 先判断:确认业务场景是否需要防重放,单纯内网可信环境可能过度设计
  • 优先做:统一签名字符串的拼接顺序,确保客户端与服务端生成逻辑完全一致
  • 再验证:使用 hmac.Equal 进行比对,严禁直接使用 == 操作符比较字节切片

核心原理与代码实现

核心流程分为三步:客户端构造签名字符串并计算摘要,服务端收到请求后复现计算过程,最后比对结果。以下是完整的服务端 Handler 示例,包含时间戳校验与签名比对。

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "strconv"
    "time"
)

const secretKey = "your-secret-key"
const maxTimeDiff = 5 * time.Minute

func VerifyHandler(w http.ResponseWriter, r *http.Request) {
    // 1. 获取 Header
    receivedSig := r.Header.Get("X-Signature")
    timestampStr := r.Header.Get("X-Timestamp")
    
    // 2. 校验时间戳
    timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
    if err != nil || time.Now().Unix()-timestamp > int64(maxTimeDiff.Seconds()) {
        http.Error(w, "Invalid timestamp", http.StatusForbidden)
        return
    }

    // 3. 读取 Body (注意:读取后需缓存以便后续业务使用)
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusBadRequest)
        return
    }

    // 4. 复现签名字符串 (需与客户端完全一致)
    // 建议格式:Timestamp\nBodyHash 或 Timestamp\nBody
    stringToSign := fmt.Sprintf("%d\n%s", timestamp, string(body))
    
    // 5. 计算期望签名
    h := hmac.New(sha256.New, []byte(secretKey))
    h.Write([]byte(stringToSign))
    expectedSig := hex.EncodeToString(h.Sum(nil))

    // 6. 恒定时间比对
    if !hmac.Equal([]byte(receivedSig), []byte(expectedSig)) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte("Success"))
}

客户端生成签名的辅助函数如下,确保拼接逻辑与服务端完全一致。

如何在 Go 语言中实现基于 HMAC 签名的 API 请求验证?
func GenerateSignature(secret, data string) string {
    h := hmac.New(sha256.New, []byte(secret))
    h.Write([]byte(data))
    return hex.EncodeToString(h.Sum(nil))
}

如何验证签名生效

由于 curl 无法直接计算 HMAC 签名,建议编写一个简单的脚本生成签名,再配合 curl 发送请求。

步骤 1:生成签名(Python 示例)

如何在 Go 语言中实现基于 HMAC 签名的 API 请求验证?

保存为 gen_sig.py,用于生成当前时刻的有效签名。

import hmac, hashlib, time
secret = "your-secret-key"
timestamp = str(int(time.time()))
body = "request body content"
data = f"{timestamp}\n{body}"
sig = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest()
print(f"Timestamp: {timestamp}")
print(f"Signature: {sig}")

步骤 2:发送请求

运行脚本获取签名后,填入 curl 命令。

如何在 Go 语言中实现基于 HMAC 签名的 API 请求验证?
curl -X POST \
  -H "X-Signature: 填入生成的签名" \
  -H "X-Timestamp: 填入生成的时间戳" \
  -d "request body content" \
  https://api.example.com/data

查看服务端日志,确认签名校验失败的请求被正确拦截,合法请求返回 200。

进阶:防重放 Nonce 机制

仅依赖时间戳窗口可能存在窗口期内的重放风险。建议引入 Nonce(一次性随机数),在服务端缓存已使用的 Nonce,过期时间等于时间戳窗口。

// 伪代码示例:结合 Redis 实现 Nonce 校验
nonce := r.Header.Get("X-Nonce")
if redis.Exists(nonce) {
    return Error("Replay attack")
}
// 设置过期时间与时间戳窗口一致
redis.Set(nonce, "1", maxTimeDiff)
// 继续后续签名校验

常见坑与排查

  • 时间同步问题:服务器与客户端时间偏差过大会导致合法请求被拒,建议允许一定的误差窗口(如 5 分钟)。
  • URL 编码差异:路径中的特殊字符在不同语言或库中编码可能不同,导致签名字符串不一致,建议统一使用 RFC 3986 标准。
  • 密钥泄露:一旦密钥泄露,所有历史通信都可能被伪造,需具备密钥轮换机制。
  • Body 读取:服务端读取 Body 用于签名校验后,后续业务逻辑可能无法再次读取,需使用 io.TeeReader 或缓存 Body。

参考来源

  • Go 官方文档 - crypto/hmac 包说明,https://pkg.go.dev/crypto/hmac
  • Go 官方文档 - crypto/subtle 包说明(恒定时间比较原理),https://pkg.go.dev/crypto/subtle