Beego API签名校验必须用中间件统一拦截并提前读取Body;否则签名原文与后续解析的body字节不一致,导致90%验签失败。需在中间件中用io.ReadAll读取后重置c.Request.Body为可复用流,并严格按顺序拼接signing string。

Beego 中做 API 签名校验,不能靠基类或模板逻辑兜底,必须用中间件统一拦截并提前读取 Body;否则签名原文和后续业务解析用的 body 字节不一致,90% 的验签失败都卡在这一步。
Beego 中间件里怎么安全读取并复用 Request.Body
Beego 的 Controller 默认不缓存原始请求体,c.Request.Body 是单次可读流。签名验证要读一次算 HMAC,后续 c.ParseForm() 或 json.NewDecoder(c.Request.Body) 再读就为空——这是最常导致 “签名对但解析失败” 或 “验签永远 mismatch” 的根源。
- 必须在中间件中用
io.ReadAll(c.Request.Body)一次性读完原始字节 - 立即用
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))重置 body 流,供后续 controller 使用 - 别用
c.Ctx.Input.RequestBody:它只对 POST/PUT 的表单或 JSON 自动触发,且不保证原始字节(可能已被 decode 过) - 如果接口支持 GET 查询参数参与签名,记得从
c.Ctx.Input.URI()解析RawQuery,而非QueryEscape后再拼
签名原文拼接必须严格按顺序和编码规则
客户端和服务端拼出的 signing string 差一个空格、少一个换行、url.PathEscape 和 url.QueryEscape 混用,都会导致 HMAC 不一致。Beego 中需显式控制每一段:
-
method:小写,如"get",别直接用c.Ctx.Input.Method(),要转小写 -
path:用c.Ctx.Input.URI()取完整路径,再调url.URL{Path: path}.EscapedPath()(不是.Path) -
timestamp:必须是time.Now().Unix()秒级整数,字符串化后不带小数点 -
nonce:16 字节随机数,hex 编码后传参,服务端用hex.DecodeString()还原 -
sortedQuery:用url.ParseQuery(c.Ctx.Input.RawQuery)解析,key 字典序排序,每个 key/value 单独url.PathEscape(),拼成key1=val1&key2=val2 -
bodyHash:若非 GET 请求,对已读取的bodyBytes做sha256.Sum256().Sum(nil),再 hex 编码 - 最终拼接用
\n分隔,末尾无换行:method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sortedQuery + "\n" + bodyHashHex
Beego 路由与中间件注册的两个关键点
签名校验中间件必须在路由分发前生效,且不能被 controller 的 Prepare() 干扰。Beego v2.x 默认加载 controllers/base.go 里的 BaseController,它自带未定义的 CheckLogin(),会 panic。
- 删掉
controllers/base.go文件(留空文件也会触发加载) - 中间件注册用
beego.InsertFilter("/api/*", beego.BeeApp.FilterType, authMiddleware),路径通配要覆盖所有 API 前缀 - 不要在
Router()或RESTRouter()的 handler 里做验签——那已经进到 controller 层,body 可能已被消费 - 验签失败统一返回
c.Abort(401),别用c.Data["json"] = ...; c.ServeJSON(),避免和后续逻辑冲突
时间戳、nonce 和密钥管理的实际约束
光比对 HMAC 值没有意义,三者缺一不可,且必须在中间件里一次性做完校验:
-
X-Timestamp头必须是纯数字字符串,abs(time.Now().Unix() - ts) > 300直接Abort(401) -
nonce必须查 Redis 或本地 LRU cache(如golang-lru),确认未在 5 分钟内出现过;查之前先 trim 空格,防止前后空格导致误判 - 密钥绝不能硬编码:
os.Getenv("API_SECRET_KEY")是底线,若配置为 base64 字符串,启动时就要base64.StdEncoding.DecodeString()一次解码并全局缓存 - 调试时打印
len(secretKey)和fmt.Printf("%q", secretKey),确认没 BOM、不可见字符或意外换行
真正容易被忽略的是:Body 读取和重置必须在中间件第一行完成,且不能依赖任何 controller 生命周期钩子;一旦进到 Get() 或 Post() 方法,原始请求体大概率已不可逆地损坏。


















