深入理解飞书 Webhook 签名验证:一次踩坑到填坑的完整记录
作为一名勤劳的牛马,我在对接飞书开放平台时遇到了一个看似简单却让人抓狂的问题——签名验证总是失败。经过一番深入研究,我发现这个问题背后隐藏着许多容易被忽视的细节。今天,我想用最通俗的语言,把这段经历记录下来。
故事的开始:一个神秘的签名验证失败
问题现场
那是一个普通的工作日下午,我正在为公司的内部系统对接飞书的事件订阅功能。一切看起来都很顺利:
- ✅ 应用创建完成
- ✅ 事件订阅配置完成
- ✅ Webhook 地址填写正确
- ✅ 代码部署上线
但是,当我满怀期待地在飞书后台点击"验证"按钮时,系统日志里出现了这样一行红色的错误:
warn: Mud.Feishu.Webhook.FeishuEventValidator[0]
请求头签名验证失败: 计算 +OGVt6ye......, 期望 bc5b503a......
什么?签名验证失败?
我检查了配置文件,密钥都填对了;我检查了代码逻辑,看起来也没问题。但就是验证不通过!
初步分析
让我们先看看日志里的其他信息:
dbug: Mud.Feishu.Webhook.FeishuEventDecryptor[0]
解密成功,结果长度: 489
dbug: Mud.Feishu.Webhook.FeishuEventDecryptor[0]
解密后的JSON数据: {"schema":"2.0","header":{"event_id":"...","token":"fCt8xobp..."}}
info: Mud.Feishu.Webhook.FeishuEventDecryptor[0]
事件数据解密成功 - EventType: [contact.department.created_v3]
有意思的是:
- ✅ 数据解密成功了
- ✅ 事件类型识别正确
- ❌ 但签名验证失败了
这说明什么?说明我的 Encrypt Key(加密密钥)是对的,但签名验证的逻辑肯定哪里出了问题。
飞书 Webhook 的安全机制
在深入问题之前,让我们先理解飞书是如何保护 Webhook 安全的。
两把钥匙的故事
飞书给每个应用配置了两把"钥匙":
简单来说:
- Verification Token 就像你家的门牌号,用来确认"这是你家"
- Encrypt Key 就像你家的钥匙,用来"开门"和"验证身份"
飞书发送请求的完整流程
当飞书要给你的服务器发送事件通知时,它会经历这样一个过程:
你的服务器需要做什么
收到飞书的请求后,你需要按照相反的顺序验证和处理:
我踩过的四个大坑
现在,让我们来看看我在实现签名验证时踩过的坑。如果你也遇到了签名验证失败的问题,很可能就是因为这些原因。
坑 #1:用错了签名算法
❌ 我最初的错误实现
// 我以为飞书用的是 HMAC-SHA256(因为很多平台都用这个)
var signString = $"{timestamp}\n{nonce}\n{body}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(encryptKey));
var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(signString));
var signature = Convert.ToBase64String(hashBytes);
为什么错了?
我参考了微信、钉钉等平台的实现,它们大多使用 HMAC-SHA256 算法。但飞书不一样!
✅ 正确的实现
// 飞书使用的是纯 SHA-256 哈希(不是 HMAC)
var signString = $"{timestamp}{nonce}{encryptKey}{body}";
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));
var signature = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
对比表格:
| 特性 | HMAC-SHA256 | SHA-256 |
|---|---|---|
| 是否需要密钥 | ✅ 需要(作为 HMAC 的密钥) | ❌ 不需要(密钥直接拼接到字符串中) |
| 算法类型 | 消息认证码 | 哈希函数 |
| 飞书使用 | ❌ | ✅ |
| 微信使用 | ✅ | ❌ |
坑 #2:签名字符串格式错误
❌ 我最初的错误实现
// 我以为各部分要用换行符分隔(因为看起来更"规范")
var signString = $"{timestamp}\n{nonce}\n{body}";
为什么错了?
我想当然地认为,既然是多个部分组成的字符串,应该用某种分隔符。换行符 \n 看起来是个不错的选择。
但实际上,飞书的签名字符串是直接拼接的,而且还要包含 Encrypt Key!
✅ 正确的实现
// 直接拼接,无任何分隔符
var signString = $"{timestamp}{nonce}{encryptKey}{body}";
示例对比:
❌ 错误格式(有换行符,缺少 encryptKey):
1768550348
149323894
{"encrypt":"Ul/tHTDEQkO..."}
✅ 正确格式(直接拼接,包含 encryptKey):
1768550348149323894go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx{"encrypt":"Ul/tHTDEQkO..."}
坑 #3:用错了密钥
❌ 我曾经的困惑
// 我看到解密后的数据里有个 token 字段
// {"header":{"token":"fCt8xobpOKdb4yA0UoKJrhNTUaXTzJnf"}}
// 我想:这个 token 应该就是用来验证签名的吧?
var signString = $"{timestamp}{nonce}{verificationToken}{body}";
为什么错了?
这是一个很容易犯的错误。因为:
- 解密后的数据里确实有个
token字段 - 这个 token 的值正好是 Verification Token
- 名字叫"验证令牌",听起来就应该用来验证
但实际上,签名验证要用 Encrypt Key!
✅ 正确的理解
| 密钥类型 | 用途 | 在签名验证中 |
|---|---|---|
| Verification Token | URL 验证请求 | ❌ 不使用 |
| Encrypt Key | 数据加密/解密 + 签名验证 | ✅ 使用这个 |
记忆技巧:
- Verification Token = 门牌号(确认地址)
- Encrypt Key = 钥匙(开门 + 验证身份)
坑 #4:输出格式不对
❌ 我最初的错误实现
// 我习惯性地把哈希结果转成 Base64
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));
var signature = Convert.ToBase64String(hashBytes);
// 结果:lL4qIgAs8Kx... (Base64 格式)
为什么错了?
Base64 是很常见的编码方式,我在其他项目中经常这样用。但飞书要的是小写十六进制字符串!
✅ 正确的实现
// 转换为小写十六进制字符串
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));
var signature = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
// 结果:f2d909fb8a7c... (小写十六进制)
格式对比:
原始哈希值(字节数组):
[242, 217, 9, 251, 138, 124, 62, 29, ...]
❌ Base64 编码:
8tkJ+4p8Ph0...
✅ 小写十六进制:
f2d909fb8a7c3e1d...
正确的实现方式
经过一番折腾,我终于搞清楚了正确的实现方式。让我用最清晰的方式展示给你。
完整的验证流程
C# 完整代码实现
public async Task<bool> ValidateSignature(
long timestamp,
string nonce,
string body,
string headerSignature,
string encryptKey)
{
try
{
// ========== 第 1 步:基础验证 ==========
// 检查必要参数
if (string.IsNullOrEmpty(headerSignature))
{
_logger.LogWarning("请求头中缺少 X-Lark-Signature");
return false;
}
if (timestamp == 0 || string.IsNullOrEmpty(nonce))
{
_logger.LogWarning("时间戳或 nonce 为空");
return false;
}
// ========== 第 2 步:防重放攻击 ==========
// 检查 nonce 是否已使用(需要配合 Redis 等缓存实现)
if (await IsNonceUsed(nonce))
{
_logger.LogWarning("Nonce {Nonce} 已使用过,拒绝重放攻击", nonce);
return false;
}
// 验证时间戳(容错 60 秒)
if (!IsTimestampValid(timestamp, toleranceSeconds: 60))
{
_logger.LogWarning("请求时间戳无效: {Timestamp}", timestamp);
return false;
}
// ========== 第 3 步:构建签名字符串 ==========
// 注意:直接拼接,无分隔符
var signString = $"{timestamp}{nonce}{encryptKey}{body}";
// 调试日志(生产环境建议关闭)
_logger.LogDebug("签名字符串前 100 字符: {SignStringPrefix}",
signString.Substring(0, Math.Min(100, signString.Length)));
// ========== 第 4 步:计算 SHA-256 哈希 ==========
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));
// ========== 第 5 步:转换为小写十六进制字符串 ==========
var computedSignature = BitConverter.ToString(hashBytes)
.Replace("-", "")
.ToLower();
_logger.LogDebug("计算的签名: {ComputedSignature}", computedSignature);
_logger.LogDebug("期望的签名: {ExpectedSignature}", headerSignature);
// ========== 第 6 步:固定时间比较 ==========
// 使用固定时间比较防止计时攻击
var isValid = FixedTimeEquals(computedSignature, headerSignature);
if (isValid)
{
_logger.LogInformation("签名验证成功");
// 标记 nonce 为已使用
await MarkNonceAsUsed(nonce);
}
else
{
_logger.LogWarning("签名验证失败");
}
return isValid;
}
catch (Exception ex)
{
_logger.LogError(ex, "验证签名时发生错误");
return false;
}
}
/// <summary>
/// 固定时间比较,防止计时攻击
/// </summary>
private static bool FixedTimeEquals(string a, string b)
{
if (a.Length != b.Length)
return false;
var result = 0;
for (var i = 0; i < a.Length; i++)
{
result |= a[i] ^ b[i];
}
return result == 0;
}
/// <summary>
/// 验证时间戳是否在有效范围内
/// </summary>
private bool IsTimestampValid(long timestamp, int toleranceSeconds)
{
var requestTime = DateTimeOffset.FromUnixTimeSeconds(timestamp);
var now = DateTimeOffset.UtcNow;
var diff = Math.Abs((now - requestTime).TotalSeconds);
return diff <= toleranceSeconds;
}
其他语言实现参考
Python 实现
import hashlib
import time
def validate_signature(timestamp, nonce, body, header_signature, encrypt_key):
"""验证飞书 Webhook 签名"""
# 1. 基础验证
if not header_signature or not nonce or timestamp == 0:
return False
# 2. 时间戳验证(容错 60 秒)
current_time = int(time.time())
if abs(current_time - timestamp) > 60:
return False
# 3. 构建签名字符串(直接拼接)
sign_string = f"{timestamp}{nonce}{encrypt_key}{body}"
# 4. 计算 SHA-256 哈希
hash_obj = hashlib.sha256(sign_string.encode('utf-8'))
# 5. 转换为小写十六进制
computed_signature = hash_obj.hexdigest().lower()
# 6. 比较签名
return computed_signature == header_signature.lower()
JavaScript/Node.js 实现
const crypto = require('crypto');
function validateSignature(timestamp, nonce, body, headerSignature, encryptKey) {
// 1. 基础验证
if (!headerSignature || !nonce || !timestamp) {
return false;
}
// 2. 时间戳验证(容错 60 秒)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - timestamp) > 60) {
return false;
}
// 3. 构建签名字符串(直接拼接)
const signString = `${timestamp}${nonce}${encryptKey}${body}`;
// 4. 计算 SHA-256 哈希并转换为小写十六进制
const computedSignature = crypto
.createHash('sha256')
.update(signString, 'utf8')
.digest('hex')
.toLowerCase();
// 5. 比较签名
return computedSignature === headerSignature.toLowerCase();
}
安全防护的艺术
签名验证只是安全防护的第一步。要构建一个真正安全可靠的 Webhook 服务,还需要考虑更多细节。
防重放攻击:Nonce 去重机制
什么是重放攻击?
想象这样一个场景:
1. 黑客截获了一个合法的飞书请求
2. 黑客重复发送这个请求 100 次
3. 你的服务器处理了 100 次相同的事件
4. 💥 业务逻辑被重复执行,造成数据混乱
如何防止?
使用 Nonce(Number used once) 机制:
代码实现(使用 Redis)
public class NonceDeduplicator
{
private readonly IDistributedCache _cache;
private readonly ILogger<NonceDeduplicator> _logger;
public async Task<bool> IsNonceUsed(string nonce)
{
var key = $"feishu:nonce:{nonce}";
var value = await _cache.GetStringAsync(key);
return value != null;
}
public async Task MarkNonceAsUsed(string nonce)
{
var key = $"feishu:nonce:{nonce}";
var options = new DistributedCacheEntryOptions
{
// 5 分钟后自动过期(与时间戳容错时间一致)
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
};
await _cache.SetStringAsync(key, "1", options);
_logger.LogDebug("Nonce {Nonce} 已标记为已使用", nonce);
}
}
防重放攻击:时间戳验证
为什么需要时间戳验证?
场景 1:网络延迟
飞书发送时间:14:00:00
到达你服务器:14:00:05
✅ 5 秒延迟,可以接受
场景 2:恶意攻击
飞书发送时间:14:00:00
黑客重放时间:15:00:00
❌ 1 小时延迟,明显异常
容错时间设置建议
| 环境 | 建议容错时间 | 原因 |
|---|---|---|
| 生产环境 | 60 秒 | 平衡安全性和可用性 |
| 测试环境 | 300 秒 | 方便调试 |
| 开发环境 | 600 秒 | 本地时间可能不准 |
代码实现
public bool IsTimestampValid(long timestamp, int toleranceSeconds = 60)
{
// 飞书的时间戳是秒级的
var requestTime = DateTimeOffset.FromUnixTimeSeconds(timestamp);
var now = DateTimeOffset.UtcNow;
// 计算时间差(绝对值)
var diff = Math.Abs((now - requestTime).TotalSeconds);
if (diff > toleranceSeconds)
{
_logger.LogWarning(
"时间戳超出容错范围: 请求时间 {RequestTime}, 当前时间 {CurrentTime}, 差异 {Diff}秒",
requestTime, now, diff);
return false;
}
return true;
}
防重放攻击:密钥管理
❌ 危险的做法
// 千万不要这样做!
public class FeishuConfig
{
public const string EncryptKey = "go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx";
public const string VerificationToken = "fCt8xobpOKdb4yA0UoKJrhNTUaXTzJnf";
}
为什么危险?
- 代码会被提交到 Git 仓库
- 任何能看到代码的人都能看到密钥
- 密钥泄露后很难追踪
✅ 安全的做法
方案 1:使用环境变量
// appsettings.json(不包含敏感信息)
{
"FeishuWebhook": {
"RoutePrefix": "feishu/webhook"
}
}
// 环境变量(在服务器上配置)
FEISHU_ENCRYPT_KEY=go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx
FEISHU_VERIFICATION_TOKEN=fCt8xobpOKdb4yA0UoKJrhNTUaXTzJnf
// 代码中读取
var encryptKey = Environment.GetEnvironmentVariable("FEISHU_ENCRYPT_KEY");
方案 2:使用密钥管理服务
// 使用 Azure Key Vault
var client = new SecretClient(vaultUri, new DefaultAzureCredential());
var secret = await client.GetSecretAsync("feishu-encrypt-key");
var encryptKey = secret.Value.Value;
// 使用 AWS Secrets Manager
var client = new AmazonSecretsManagerClient();
var request = new GetSecretValueRequest { SecretId = "feishu/encrypt-key" };
var response = await client.GetSecretValueAsync(request);
var encryptKey = response.SecretString;
防重放攻击:多应用场景
如果你的公司有多个飞书应用,可以让它们共享一个 Webhook 端点:
配置文件
{
"FeishuWebhook": {
"MultiAppEncryptKeys": {
"cli_a98ea7d1a0ba100b": "go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx",
"cli_b12345678901234c": "xY9zAbCdEfGhIjKlMnOpQrStUvWx1234",
"cli_c98765432109876d": "1234AbCdEfGhIjKlMnOpQrStUvWxYz56"
},
"DefaultAppId": "cli_a98ea7d1a0ba100b"
}
}
代码实现
private string GetEncryptKey(string appId)
{
// 尝试从多应用配置中获取
if (_options.MultiAppEncryptKeys.TryGetValue(appId, out var key))
{
_logger.LogDebug("使用应用 {AppId} 的专用密钥", appId);
return key;
}
// 回退到默认密钥
if (!string.IsNullOrEmpty(_options.DefaultAppId) &&
_options.MultiAppEncryptKeys.TryGetValue(_options.DefaultAppId, out var defaultKey))
{
_logger.LogWarning("未找到应用 {AppId} 的密钥,使用默认密钥", appId);
return defaultKey;
}
// 最后回退到主密钥
_logger.LogWarning("使用主密钥");
return _options.EncryptKey;
}
问题排查指南
当签名验证失败时,不要慌张。按照这个清单逐项检查,99% 的问题都能找到原因。
排查清单
排查技巧
技巧 1:打印关键信息
_logger.LogDebug("========== 签名验证调试信息 ==========");
_logger.LogDebug("Timestamp: {Timestamp}", timestamp);
_logger.LogDebug("Nonce: {Nonce}", nonce);
_logger.LogDebug("Encrypt Key 前 8 位: {KeyPrefix}",
encryptKey.Substring(0, 8));
_logger.LogDebug("Body 长度: {BodyLength}", body.Length);
_logger.LogDebug("Body 前 100 字符: {BodyPrefix}",
body.Substring(0, Math.Min(100, body.Length)));
_logger.LogDebug("签名字符串前 150 字符: {SignStringPrefix}",
signString.Substring(0, Math.Min(150, signString.Length)));
_logger.LogDebug("计算的签名: {ComputedSignature}", computedSignature);
_logger.LogDebug("期望的签名: {ExpectedSignature}", headerSignature);
_logger.LogDebug("========================================");
技巧 2:使用在线工具验证
你可以创建一个简单的在线工具来验证签名计算:
<!DOCTYPE html>
<html>
<head>
<title>飞书签名验证工具</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
</head>
<body>
<h1>飞书签名验证工具</h1>
<label>Timestamp:</label>
<input type="text" id="timestamp" placeholder="1768550348"><br>
<label>Nonce:</label>
<input type="text" id="nonce" placeholder="149323894"><br>
<label>Encrypt Key:</label>
<input type="text" id="encryptKey" placeholder="32位密钥"><br>
<label>Body:</label>
<textarea id="body" rows="5" placeholder='{"encrypt":"..."}'></textarea><br>
<button onclick="calculate()">计算签名</button>
<h3>结果:</h3>
<div id="result"></div>
<script>
function calculate() {
const timestamp = document.getElementById('timestamp').value;
const nonce = document.getElementById('nonce').value;
const encryptKey = document.getElementById('encryptKey').value;
const body = document.getElementById('body').value;
// 构建签名字符串
const signString = timestamp + nonce + encryptKey + body;
// 计算 SHA-256
const hash = CryptoJS.SHA256(signString);
const signature = hash.toString(CryptoJS.enc.Hex).toLowerCase();
// 显示结果
document.getElementById('result').innerHTML = `
<p><strong>签名字符串前 100 字符:</strong><br>
${signString.substring(0, 100)}...</p>
<p><strong>计算的签名:</strong><br>
<code style="color: green; font-size: 14px;">${signature}</code></p>
`;
}
</script>
</body>
</html>
技巧 3:单元测试
[Fact]
public async Task ValidateSignature_WithCorrectData_ShouldReturnTrue()
{
// Arrange
var timestamp = 1768550348L;
var nonce = "149323894";
var encryptKey = "go4kwHmzAbCdEfGhIjKlMnOpQrStUvWx";
var body = "{\"encrypt\":\"Ul/tHTDEQkOlKZuqYTS7t...\"}";
// 手动计算期望的签名
var signString = $"{timestamp}{nonce}{encryptKey}{body}";
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));
var expectedSignature = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
// Act
var result = await _validator.ValidateSignature(
timestamp, nonce, body, expectedSignature, encryptKey);
// Assert
Assert.True(result);
}
排查速查表
| 错误现象 | 可能原因 | 解决方案 | 优先级 |
|---|---|---|---|
| 签名不匹配 | 使用了 HMAC-SHA256 | 改用纯 SHA-256 | ⭐⭐⭐ |
| 签名不匹配 | 签名字符串有换行符 | 直接拼接,无分隔符 | ⭐⭐⭐ |
| 签名不匹配 | 使用了 Verification Token | 改用 Encrypt Key | ⭐⭐⭐ |
| 签名不匹配 | 输出格式为 Base64 | 改用小写十六进制 | ⭐⭐⭐ |
| 签名不匹配 | 签名字符串缺少 Encrypt Key | 添加 Encrypt Key | ⭐⭐⭐ |
| 时间戳无效 | 服务器时间不同步 | 同步服务器时间 | ⭐⭐ |
| Nonce 重复 | Redis 缓存配置错误 | 检查 Redis 连接 | ⭐⭐ |
| 解密成功但签名失败 | 密钥配置混乱 | 确认使用正确的密钥 | ⭐⭐ |
总结与思考
核心要点回顾
让我用一张图总结飞书签名验证的核心要点:
对比其他平台
为了帮助你更好地理解,我整理了几个主流平台的签名验证对比:
| 平台 | 签名算法 | 密钥类型 | 字符串格式 | 输出格式 | 分隔符 |
|---|---|---|---|---|---|
| 飞书 | SHA-256 | Encrypt Key | timestamp+nonce+key+body | 小写 Hex | 无 |
| 微信 | SHA-1 | Token | 字典序排序后拼接 | 小写 Hex | 无 |
| 钉钉 | HMAC-SHA256 | App Secret | timestamp+\n+secret | Base64 | \n |
| 企业微信 | SHA-256 | Token | 字典序排序后拼接 | 小写 Hex | 无 |
| Slack | HMAC-SHA256 | Signing Secret | version:timestamp:body | Hex | : |
关键发现:
- 飞书和微信都用纯哈希(SHA),不用 HMAC
- 钉钉和 Slack 用 HMAC-SHA256
- 大部分平台输出十六进制,只有钉钉用 Base64
- 飞书的特殊之处:签名字符串中包含密钥本身
排查经验总结
经过这次踩坑经历,我总结了几点经验:
💡 经验 1:不要想当然
"我以为飞书应该和微信一样..."
"我觉得应该用换行符分隔..."
"我猜测应该用 Verification Token..."
教训: 每个平台都有自己的实现方式,不要基于其他平台的经验做假设。仔细阅读官方文档是最重要的。
💡 经验 2:日志是你最好的朋友
在调试签名验证问题时,详细的日志帮了我大忙:
// 好的日志示例
_logger.LogDebug("签名字符串: {SignString}", signString);
_logger.LogDebug("计算的签名: {Computed}, 期望的签名: {Expected}",
computed, expected);
// 不好的日志示例
_logger.LogError("签名验证失败"); // 没有任何有用信息
💡 经验 3:安全性和可用性的平衡
- 开发环境:可以放宽限制,方便调试
- 测试环境:接近生产环境的配置
- 生产环境:严格的安全策略
var toleranceSeconds = _environment.IsProduction() ? 60 : 300;
💡 经验 4:写单元测试
签名验证的逻辑相对独立,非常适合写单元测试:
[Theory]
[InlineData(1768550348, "149323894", "go4kwHmz...", "{...}", "f2d909fb...")]
public async Task ValidateSignature_WithKnownData_ShouldMatch(
long timestamp, string nonce, string key, string body, string expected)
{
var result = await _validator.ValidateSignature(
timestamp, nonce, body, expected, key);
Assert.True(result);
}
延伸思考
🤔 为什么飞书不用 HMAC-SHA256?
HMAC-SHA256 是更标准的签名算法,为什么飞书选择了纯 SHA-256?
我的猜测:
- 性能考虑:SHA-256 比 HMAC-SHA256 稍快
- 实现简单:不需要额外的 HMAC 库
- 历史原因:可能是早期设计的遗留
但从安全角度看,HMAC-SHA256 会更好,因为它专门设计用于消息认证。
🤔 为什么要把密钥放在签名字符串里?
这是飞书的一个特殊设计。通常的做法是:
- HMAC 方式:密钥作为 HMAC 的密钥参数
- 飞书方式:密钥直接拼接到字符串中
这种方式的优点:
- 实现简单,不需要 HMAC 库
- 密钥参与哈希计算,提供了一定的安全性
缺点:
- 不如 HMAC 标准和安全
- 容易被误解(很多人会忘记加密钥)
推 荐 资 源
如果你想深入学习,这里有一些推荐资源:
📚 官方文档
🛠️ 开源项目
- Mud.Feishu SDK Github
- Mud.Feishu SDK Gitee
- 飞书官方 SDK - 多语言官方 SDK
写在最后
从最初的签名验证失败,到最终搞清楚所有细节,这个过程让我深刻体会到:
技术细节决定成败。 一个小小的算法差异、一个字符串格式的不同,都可能导致功能完全无法工作。
希望这篇文章能帮助你:
- ✅ 理解飞书 Webhook 签名验证的完整机制
- ✅ 避免我踩过的坑
- ✅ 快速定位和解决签名验证问题
- ✅ 构建安全可靠的 Webhook 服务
如果你在实现过程中遇到问题,欢迎在评论区留言讨论。如果这篇文章对你有帮助,也欢迎分享给更多需要的人。
祝你的飞书集成之旅一帆风顺! 🚀
附录:快速参考
A. 签名验证代码模板(C#)
public async Task<bool> ValidateFeishuSignature(HttpRequest request)
{
// 1. 提取请求头
var timestamp = long.Parse(request.Headers["X-Lark-Request-Timestamp"]);
var nonce = request.Headers["X-Lark-Request-Nonce"].ToString();
var signature = request.Headers["X-Lark-Signature"].ToString();
// 2. 读取请求体
request.EnableBuffering();
var body = await new StreamReader(request.Body).ReadToEndAsync();
request.Body.Position = 0;
// 3. 构建签名字符串
var signString = $"{timestamp}{nonce}{_encryptKey}{body}";
// 4. 计算 SHA-256
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(signString));
var computed = BitConverter.ToString(hash).Replace("-", "").ToLower();
// 5. 比较签名
return computed == signature;
}
B. 配置文件模板
{
"FeishuWebhook": {
"VerificationToken": "从飞书后台获取",
"EncryptKey": "从飞书后台获取(32位)",
"RoutePrefix": "feishu/webhook",
"TimestampToleranceSeconds": 60,
"EnableRequestLogging": true,
"EnableBackgroundProcessing": false
}
}
C. 术语表
| 术语 | 英文 | 解释 |
|---|---|---|
| 签名 | Signature | 用于验证数据完整性和来源的字符串 |
| 哈希 | Hash | 将任意长度数据转换为固定长度的算法 |
| HMAC | Hash-based Message Authentication Code | 基于哈希的消息认证码 |
| Nonce | Number used once | 一次性随机数,用于防重放攻击 |
| 时间戳 | Timestamp | Unix 时间戳,表示请求发送时间 |
| 重放攻击 | Replay Attack | 重复发送已截获的合法请求 |
| 计时攻击 | Timing Attack | 通过测量操作时间来推断信息 |

浙公网安备 33010602011771号