如何在JavaScript中验证HMAC SHA256以校验Todoist Webhook请求合法性
问题原因
- 密钥处理逻辑错误:你当前
sign_string函数中,将Todoist提供的client_secret默认当作Base64编码内容解析后再转UTF8字符串,但官方发放的client_secret本身就是明文字符串,不需要额外解码,这是触发Malformed UTF-8 data错误的直接原因。 - 待签名payload格式不一致:你使用
JSON.stringify(req.body)生成待签名内容,但express.json()中间件已经将原始请求体解析为JS对象,二次序列化后的结果和Todoist发出的原始请求payload的格式(空格、转义规则、键顺序等)可能存在差异,会直接导致签名校验失败。
修复方案
推荐使用Node.js原生crypto模块实现校验,无需额外依赖CryptoJS,兼容性和安全性更好,修改步骤如下:
- 调整express配置,保留原始请求体
- 替换签名校验逻辑
- 调整路由中的校验调用
完整修改后代码
const express = require('express'); const Discord = require('discord.js'); const crypto = require('crypto'); // Node.js原生模块,无需安装 const client = new Discord.Client(); require('dotenv').config(); const PORT = process.env.PORT || 3000; const app = express(); // 解析JSON同时保留原始请求体,用于签名校验 app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } })); app.use(express.urlencoded({ extended: true })); process.on('unhandledRejection', error => { console.log('unhandledRejection', error.message); }); // 签名校验函数 function verifySignature(rawBody, secret, receivedHmac) { const computedHmac = crypto.createHmac('sha256', secret) .update(rawBody) .digest('base64'); // 采用时序安全比较,避免时序攻击 return crypto.timingSafeEqual(Buffer.from(computedHmac), Buffer.from(receivedHmac)); } client.on('ready', () => { client.users.fetch(process.env.MY_USER_ID).then(user => user.send('Hey, The bot is up!')); }); app.get('', (req, res) => { res.send('Hello World'); }); app.post('', (req, res) => { if(req.get('User-Agent') === 'Todoist-Webhooks') { const deliveredHmac = req.get('X-Todoist-Hmac-SHA256'); try { const isSignatureValid = verifySignature(req.rawBody, process.env.TODOIST_CLIENT_SECRET, deliveredHmac); if(isSignatureValid) { if(req.body.event_name === 'item:added' && req.body.event_data.description === '') { // 新增任务逻辑 } else if(req.body.event_name === 'item:completed' && req.body.event_data.description !== '') { // 完成任务逻辑 } client.users.fetch(process.env.MY_USER_ID).then(user => user.send('You can update your tasklist if you want')); return res.status(200).send('Event handled'); } } catch (err) { console.error('签名校验失败', err); } // 校验失败逻辑 client.users.fetch(process.env.MY_USER_ID).then(user => user.send('A 403 (Unauthorized) status code has been sent to a request')); res.status(403).send('Unauthorized'); console.log(`delivered_hmac: ${deliveredHmac}\n`); console.log(req.body); } else { client.users.fetch(process.env.MY_USER_ID).then(user => user.send('A 400 (Bad request) status code has been sent to a request')); res.status(400).send('Bad request'); } }); client.login(process.env.BOT_TOKEN); app.listen(PORT, () => { console.log(`App up at port ${PORT}`); });
额外注意项
- 确认
.env文件中的TODOIST_CLIENT_SECRET是直接从Todoist开发者后台复制的原始值,没有多余的引号、空格等内容 - 不需要用到的
crypto-js依赖可以从项目中移除,减少依赖体积
内容的提问来源于stack exchange,提问作者Clarissa De Simoni
相关产品推荐
相关产品推荐

