You need to enable JavaScript to run this app. 如何在JavaScript中验证HMAC SHA256以校验Todoist Webhook请求合法性
优惠活动
大模型
产品
解决方案
定价
更多

如何在JavaScript中验证HMAC SHA256以校验Todoist Webhook请求合法性

问题原因

  1. 密钥处理逻辑错误:你当前sign_string函数中,将Todoist提供的client_secret默认当作Base64编码内容解析后再转UTF8字符串,但官方发放的client_secret本身就是明文字符串,不需要额外解码,这是触发Malformed UTF-8 data错误的直接原因。
  2. 待签名payload格式不一致:你使用JSON.stringify(req.body)生成待签名内容,但express.json()中间件已经将原始请求体解析为JS对象,二次序列化后的结果和Todoist发出的原始请求payload的格式(空格、转义规则、键顺序等)可能存在差异,会直接导致签名校验失败。

修复方案

推荐使用Node.js原生crypto模块实现校验,无需额外依赖CryptoJS,兼容性和安全性更好,修改步骤如下:

  1. 调整express配置,保留原始请求体
  2. 替换签名校验逻辑
  3. 调整路由中的校验调用

完整修改后代码

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.16 03:06:08