验证回调请求
回调地址必须能被公网访问,任何人都可以向它发送伪造的请求。IM 服务发出的每个请求(事件回调、同步回调、URL 验证和测试发送)都带有签名,接收端必须先校验签名、检查时间戳,通过后再处理,否则请求可能是伪造的或被截获后重放的。
使用 Go、Python 或 Node.js 时,服务端 SDK 已经实现了下面的校验、防重放和去重,见 Go、Python 和 Node.js 的“接收回调”。
一定要用原始请求体计算签名
签名覆盖完整的原始请求体。请读取 HTTP 请求的原始字节来计算,不要先把 JSON 解析成对象、再重新序列化:字段的顺序、空格、中文和 <、> 等字符的编码方式都可能改变,签名随之不符。
签名密钥
每个回调地址有一个签名密钥,形如 cbs_ 加 43 个字符,共 47 个字符,由系统生成,你不能自己设置:
- 新建地址和轮换密钥时,控制台显示一次密钥的完整内容,只显示这一次,请立即保存到接收端的配置或密钥管理系统中;之后控制台只显示它的前 8 个字符(如
cbs_oSi8),用于辨认是哪一个; - 密钥不会自动过期,需要更换时轮换,见轮换密钥;
- 每个地址的密钥不同。多个地址共用一个接收端时,按请求头
X-IM-Endpoint-Id(或请求体中的endpoint_id)选择对应的密钥。
签名算法
签名串 = "v1:" + X-IM-Timestamp + ":" + X-IM-Nonce + ":" + 原始请求体
签名 = 小写十六进制( HMAC-SHA256( 密钥, 签名串 ) )
X-IM-Signature: v1=<签名>- 密钥:完整的密钥字符串(包括
cbs_前缀)的 ASCII 字节,不需要做 Base64 解码; - 签名串:
v1:、请求头X-IM-Timestamp的原值、:、请求头X-IM-Nonce的原值、:,后面紧跟原始请求体的字节; - 签名:HMAC-SHA256 的结果编码为 64 个小写十六进制字符,请求头中写作
v1=加签名。轮换密钥的宽限期内同时用新旧两个密钥签名,请求头中有两个签名,用逗号分隔,新密钥的在前,如v1=3be2...9c07,v1=787b...6b9a。
接收端按以下步骤校验,任何一步不通过都返回 401,不要处理请求:
- 读取原始请求体;
- 检查
X-IM-Timestamp与本机当前时间相差不超过 5 分钟(请保持服务器时钟同步); - 用你保存的密钥按上面的算法计算签名,与
X-IM-Signature中每一个v1=后面的签名逐个比较,有一个相同即通过。请使用常数时间的比较函数(如 Python 的hmac.compare_digest、Go 的hmac.Equal、Node.js 的crypto.timingSafeEqual),防止通过响应时间猜出签名; - 记住 10 分钟内见过的
X-IM-Nonce,拒绝重复的随机数,防止请求在 5 分钟内被原样重放; - 事件回调再按事件的
id去重:同一个事件的重试是新的请求,时间戳、随机数和签名都不同,但事件 ID 相同。
每次发送(包括重试、重新投递、URL 验证和测试发送)都使用当时的时间和新的随机数重新签名,几天前失败的事件重新投递时也能通过时间戳的检查。签名使用发送时的当前密钥:轮换之后,已经排队的事件也改用新密钥签名。
计算示例
下面是一个真实的 URL 验证请求,签名所用的密钥已经作废,可以用来核对你的实现:
| 项目 | 值 |
|---|---|
| 密钥 | cbs_oSi8UFoUx4RQ8TlhuDzI06dQGntx4oJ6dOaloTrcCCE |
X-IM-Timestamp | 1791141598 |
X-IM-Nonce | c90a695d9294c5f757dd88350e0e3187 |
| 请求体(213 字节) | {"kind":"verify","request_id":"100310966629040128","app":"6195295143#doc-callback","endpoint_id":"100310966557736960","sent_at":"2026-10-04T19:19:58.132Z","challenge":"J9Kk_hKp_H74ZScznGSzU_-kL8b4r1MTACmzTFsNVP4"} |
X-IM-Signature | v1=2e5cebe9c6c67af85d62c4cc0250579223acc3e89549eabf036d3c523cb08610 |
用 OpenSSL 计算(请求体保存在 body.json 中):
printf 'v1:1791141598:c90a695d9294c5f757dd88350e0e3187:%s' "$(cat body.json)" \
| openssl dgst -sha256 -hmac 'cbs_oSi8UFoUx4RQ8TlhuDzI06dQGntx4oJ6dOaloTrcCCE'SHA2-256(stdin)= 2e5cebe9c6c67af85d62c4cc0250579223acc3e89549eabf036d3c523cb08610示例代码
以下三个接收端示例都实现了完整的校验(签名、时间戳、随机数),并回应 URL 验证、同步回调和事件回调,可以直接运行,再按业务修改 handle_hook、handle_event 等处理部分。它们都已用测试服务器发来的真实请求验证过:URL 验证通过,测试发送和同步回调测试成功;用错误的密钥时返回 401,控制台显示验证失败。
示例在内存中记住随机数,只适合单个实例。部署多个实例时,请把随机数保存在 Redis 等共享存储中,如 SET im:nonce:<nonce> 1 NX EX 600 返回成功才处理;事件 ID 的去重同样应保存在共享存储或数据库的唯一键中。
示例监听 /im/callback,请把回调地址配置为 https://your-server.example.com/im/callback,由你的 HTTPS 网关或负载均衡转发到这个端口。
Python
Python 3.8 以上,使用 Flask:
# 回调接收端示例:Python 3.8+,Flask。
# 运行:pip install flask && CALLBACK_SECRET=cbs_... python callback_server.py
import hashlib
import hmac
import os
import threading
import time
from flask import Flask, abort, jsonify, request
SECRET = os.environ["CALLBACK_SECRET"] # 回调地址的签名密钥,cbs_ 开头
TOLERANCE = 300 # 时间戳与本机时间最多相差 5 分钟
NONCE_TTL = 600 # 随机数记住 10 分钟
app = Flask(__name__)
_nonces = {}
_lock = threading.Lock()
def verify_signature(secret, headers, body, now=None):
"""校验时间戳和签名。headers 不区分大小写,body 是原始请求体(bytes)。"""
now = time.time() if now is None else now
timestamp = headers.get("X-IM-Timestamp", "")
nonce = headers.get("X-IM-Nonce", "")
if not timestamp.isdigit() or abs(now - int(timestamp)) > TOLERANCE:
return False
message = b"v1:" + timestamp.encode() + b":" + nonce.encode() + b":" + body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
for part in headers.get("X-IM-Signature", "").split(","):
part = part.strip()
if part.startswith("v1=") and hmac.compare_digest(part[3:], expected):
return True
return False
def first_seen(nonce, now=None):
"""随机数第一次出现时返回 True。部署多个实例时改用 Redis:SET im:nonce:<nonce> 1 NX EX 600。"""
now = time.time() if now is None else now
with _lock:
for key in [key for key, at in _nonces.items() if now - at > NONCE_TTL]:
del _nonces[key]
if nonce in _nonces:
return False
_nonces[nonce] = now
return True
@app.post("/im/callback")
def callback():
body = request.get_data() # 用原始字节校验,不要先解析再重新序列化
if not verify_signature(SECRET, request.headers, body):
abort(401)
if not first_seen(request.headers.get("X-IM-Nonce", "")):
abort(409)
payload = request.get_json()
if payload["kind"] == "verify":
return jsonify(challenge=payload["challenge"])
if payload["kind"] == "hook":
return jsonify(handle_hook(payload["hook"], payload["data"]))
for event in payload["events"]:
handle_event(event)
return "", 204
def handle_hook(hook, data):
# 按业务判断,例如 {"action": "reject", "reason": "not_same_org", "message": "只能给同事发消息"}
return {"action": "allow"}
def handle_event(event):
# 先按 event["id"] 去重;耗时的处理放进自己的队列异步执行,尽快返回 2xx
print(event["id"], event["type"], event["data"])
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))使用其他框架时,同样要取原始请求体:Django 用 request.body,FastAPI 用 await request.body()。
Go
Go 1.22 以上,只用标准库(go.mod 中的 go 版本不低于 1.22):
// 回调接收端示例:Go 1.22+,只用标准库。
// 运行:CALLBACK_SECRET=cbs_... go run main.go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
const (
tolerance = 5 * time.Minute // 时间戳与本机时间最多相差 5 分钟
nonceTTL = 10 * time.Minute // 随机数记住 10 分钟
)
// verifySignature 校验时间戳和签名,body 是原始请求体。
func verifySignature(secret string, header http.Header, body []byte, now time.Time) bool {
timestamp := header.Get("X-IM-Timestamp")
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if diff := now.Sub(time.Unix(seconds, 0)); diff > tolerance || diff < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte("v1:" + timestamp + ":" + header.Get("X-IM-Nonce") + ":"))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
for _, part := range strings.Split(header.Get("X-IM-Signature"), ",") {
signature, ok := strings.CutPrefix(strings.TrimSpace(part), "v1=")
if ok && hmac.Equal([]byte(signature), []byte(expected)) {
return true
}
}
return false
}
// nonceStore 记住最近 10 分钟的随机数。部署多个实例时改用 Redis:SET im:nonce:<nonce> 1 NX EX 600。
type nonceStore struct {
mu sync.Mutex
seen map[string]time.Time
}
// firstSeen 在随机数第一次出现时返回 true。
func (s *nonceStore) firstSeen(nonce string, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, at := range s.seen {
if now.Sub(at) > nonceTTL {
delete(s.seen, key)
}
}
if _, ok := s.seen[nonce]; ok {
return false
}
s.seen[nonce] = now
return true
}
type callbackRequest struct {
Kind string `json:"kind"`
Hook string `json:"hook"`
Challenge string `json:"challenge"`
Data json.RawMessage `json:"data"`
Events []struct {
ID string `json:"id"`
Type string `json:"type"`
OccurredAt string `json:"occurred_at"`
Attempt int `json:"attempt"`
Test bool `json:"test"`
Data json.RawMessage `json:"data"`
} `json:"events"`
}
func main() {
secret := os.Getenv("CALLBACK_SECRET") // 回调地址的签名密钥,cbs_ 开头
nonces := &nonceStore{seen: map[string]time.Time{}}
http.HandleFunc("POST /im/callback", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
now := time.Now()
if !verifySignature(secret, r.Header, body, now) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
if !nonces.firstSeen(r.Header.Get("X-IM-Nonce"), now) {
http.Error(w, "replayed", http.StatusConflict)
return
}
var req callbackRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
switch req.Kind {
case "verify":
json.NewEncoder(w).Encode(map[string]string{"challenge": req.Challenge})
case "hook":
// 按 req.Hook 和 req.Data 判断,例如 {"action": "reject", "reason": "not_same_org", "message": "只能给同事发消息"}
json.NewEncoder(w).Encode(map[string]string{"action": "allow"})
default:
for _, event := range req.Events {
// 先按 event.ID 去重;耗时的处理放进自己的队列异步执行,尽快返回 2xx
log.Println(event.ID, event.Type, string(event.Data))
}
w.WriteHeader(http.StatusNoContent)
}
})
addr := ":8080"
if port := os.Getenv("PORT"); port != "" {
addr = ":" + port
}
log.Fatal(http.ListenAndServe(addr, nil))
}Node.js
Node.js 18 以上,只用内置模块:
// 回调接收端示例:Node.js 18+,只用内置模块。
// 运行:CALLBACK_SECRET=cbs_... node server.js
const crypto = require('node:crypto');
const http = require('node:http');
const SECRET = process.env.CALLBACK_SECRET; // 回调地址的签名密钥,cbs_ 开头
const TOLERANCE = 300; // 时间戳与本机时间最多相差 5 分钟
const NONCE_TTL = 600 * 1000; // 随机数记住 10 分钟
const nonces = new Map();
// 校验时间戳和签名。headers 的键为小写(Node.js 的 req.headers),body 是原始请求体(Buffer)。
function verifySignature(secret, headers, body, now = Date.now() / 1000) {
const timestamp = headers['x-im-timestamp'] || '';
const nonce = headers['x-im-nonce'] || '';
if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > TOLERANCE) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`v1:${timestamp}:${nonce}:`).update(body).digest('hex'),
);
return (headers['x-im-signature'] || '').split(',').some((part) => {
part = part.trim();
if (!part.startsWith('v1=')) return false;
const signature = Buffer.from(part.slice(3));
return signature.length === expected.length && crypto.timingSafeEqual(signature, expected);
});
}
// 随机数第一次出现时返回 true。部署多个实例时改用 Redis:SET im:nonce:<nonce> 1 NX EX 600。
function firstSeen(nonce, now = Date.now()) {
for (const [key, at] of nonces) if (now - at > NONCE_TTL) nonces.delete(key);
if (nonces.has(nonce)) return false;
nonces.set(nonce, now);
return true;
}
function reply(res, status, value) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(value === undefined ? '' : JSON.stringify(value));
}
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url.split('?')[0] !== '/im/callback') return reply(res, 404);
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks); // 用原始字节校验,不要先解析再重新序列化
if (!verifySignature(SECRET, req.headers, body)) return reply(res, 401, { error: 'invalid signature' });
if (!firstSeen(req.headers['x-im-nonce'])) return reply(res, 409, { error: 'replayed' });
const payload = JSON.parse(body);
if (payload.kind === 'verify') return reply(res, 200, { challenge: payload.challenge });
// 按 payload.hook 和 payload.data 判断,例如 { action: 'reject', reason: 'not_same_org', message: '只能给同事发消息' }
if (payload.kind === 'hook') return reply(res, 200, { action: 'allow' });
for (const event of payload.events) {
// 先按 event.id 去重;耗时的处理放进自己的队列异步执行,尽快返回 2xx
console.log(event.id, event.type, JSON.stringify(event.data));
}
reply(res, 204);
});
});
if (require.main === module) server.listen(Number(process.env.PORT) || 8080);
module.exports = { verifySignature };使用 Express 时,不要让 express.json() 先解析请求体,改为用 express.raw({ type: 'application/json' }) 取得 Buffer,校验通过后再 JSON.parse;或者在 express.json({ verify: (req, res, buf) => { req.rawBody = buf } }) 中保存原始字节。
调试签名
- 先用测试发送调通:在控制台对地址点击“测试”,选择一种事件发送,结果中有本次发出的完整请求体、对方返回的状态码和响应的开头部分。测试发送不要求地址已经通过 URL 验证,可以在验证之前先调通签名。
- 对照计算示例:用上面的计算示例检查你的实现,结果一致后再排查其他原因。
校验总是失败时,常见的原因:
| 原因 | 处理 |
|---|---|
| 框架已经把 JSON 解析成对象,用重新序列化的结果计算 | 改为读取原始请求体,见上文各语言的说明 |
| 密钥复制时多了空格、换行,或用了其他地址的密钥 | 重新核对密钥,注意每个地址的密钥不同 |
| 轮换密钥后宽限期已结束,接收端仍在用旧密钥 | 换成新密钥;忘记保存新密钥时再轮换一次 |
| 服务器时钟不准,时间戳的检查不通过 | 开启 NTP 时间同步 |
只比较了 X-IM-Signature 的第一个签名 | 宽限期内有两个签名,逐个比较,有一个相同即通过 |
| 网关或代理改动了请求体(如重新编码、压缩) | 让网关原样转发请求体 |
轮换密钥
怀疑密钥泄露,或按安全规范定期更换时,owner 和 admin 可以在控制台的地址详情中轮换密钥(需要重新验证身份),见回调配置:
- 点击“轮换”,填写旧密钥的宽限期(1 到 168 小时,默认 24 小时),保存新密钥。新密钥立即生效;
- 宽限期内每个请求同时带新旧两个签名,接收端用新密钥或旧密钥都能校验通过,可以从容地把接收端的配置换成新密钥;
- 全部接收端都换成新密钥后,可以点击“提前结束宽限期”,之后只用新密钥签名;否则宽限期结束后旧密钥自动作废。
宽限期还没有结束时不能再次轮换,返回 409 slot_unavailable,先结束宽限期再轮换。怀疑密钥泄露时,轮换并把宽限期设为 1 小时,接收端换好新密钥后立即结束宽限期。
其他安全建议
- 只接受 HTTPS:回调地址只能是 HTTPS,请为接收端使用有效的证书。响应不签名,响应的真实性由 HTTPS 保证。
- 限制来源 IP:控制台的“回调”页列出了回调请求的出口 IP 时,在防火墙上只放行这些 IP,作为签名之外的又一道防线。
- 以请求体为准:请求头中的
X-IM-Kind、X-IM-Request-Id、X-IM-Endpoint-Id只是请求体中同名字段的副本,只用于分流;需要判断时请以校验过签名的请求体为准。 - URL 中的令牌:可以在 URL 的查询参数中加上接收端自己的令牌(如
?token=...),控制台中显示时会被遮掩。它只能作为附加的检查,不能代替签名。 - 保护密钥:密钥只保存在接收端,不要写进代码仓库和日志。
