From b243562e600c64450fa9f6911a62f10af72809be Mon Sep 17 00:00:00 2001 From: ABAPPLO <1134226402@qq.com> Date: Tue, 1 Sep 2026 15:08:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=81=E5=BE=AE=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=9F=BA=E5=BB=BA=EF=BC=88=E5=8A=A0=E8=A7=A3?= =?UTF-8?q?=E5=AF=86+=E4=BA=8B=E4=BB=B6=E5=88=86=E5=8F=91=EF=BC=8C?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/wecom-callback/route.ts | 125 +++++++++++++++++++++++++++ lib/runtime-env.ts | 3 + lib/wecom-callback-crypto.ts | 97 +++++++++++++++++++++ lib/wecom-callback-events.ts | 54 ++++++++++++ tests/wecom-callback-crypto.test.mjs | 114 ++++++++++++++++++++++++ 5 files changed, 393 insertions(+) create mode 100644 app/api/wecom-callback/route.ts create mode 100644 lib/wecom-callback-crypto.ts create mode 100644 lib/wecom-callback-events.ts create mode 100644 tests/wecom-callback-crypto.test.mjs diff --git a/app/api/wecom-callback/route.ts b/app/api/wecom-callback/route.ts new file mode 100644 index 0000000..cf39aef --- /dev/null +++ b/app/api/wecom-callback/route.ts @@ -0,0 +1,125 @@ +import { + decryptWecomCallback, + verifyWecomCallbackSignature, + wecomSignaturesMatch, + WecomCallbackCryptoError, +} from "../../../lib/wecom-callback-crypto"; +import { + extractWecomEncryptField, + handleWecomCallbackEvent, + parseWecomCallbackXml, +} from "../../../lib/wecom-callback-events"; +import { isEnabled, getRuntimeEnv } from "../../../lib/runtime-env"; + +type CallbackConfig = { + token: string; + encodingAesKey: string; + corpId: string; +}; + +function resolveCallbackConfig(): CallbackConfig | null { + const env = getRuntimeEnv(); + if (!isEnabled(env.WECOM_CALLBACK_ENABLED, false)) return null; + const token = String(env.WECOM_CALLBACK_TOKEN ?? "").trim(); + const encodingAesKey = String( + env.WECOM_CALLBACK_ENCODING_AES_KEY ?? "", + ).trim(); + if (!token || !encodingAesKey) return null; + return { + token, + encodingAesKey, + corpId: String(env.WECOM_CORP_ID ?? "").trim(), + }; +} + +function readCallbackQuery(request: Request) { + const url = new URL(request.url); + return { + msgSignature: url.searchParams.get("msg_signature") ?? "", + timestamp: url.searchParams.get("timestamp") ?? "", + nonce: url.searchParams.get("nonce") ?? "", + echostr: url.searchParams.get("echostr") ?? "", + }; +} + +export async function GET(request: Request) { + const config = resolveCallbackConfig(); + if (!config) { + return new Response("企业微信回调未启用", { status: 403 }); + } + const { msgSignature, timestamp, nonce, echostr } = + readCallbackQuery(request); + if (!msgSignature || !timestamp || !nonce || !echostr) { + return new Response("缺少回调校验参数", { status: 400 }); + } + const expected = verifyWecomCallbackSignature( + config.token, + timestamp, + nonce, + echostr, + ); + if (!wecomSignaturesMatch(expected, msgSignature)) { + return new Response("签名校验失败", { status: 403 }); + } + try { + const { message } = decryptWecomCallback(echostr, config.encodingAesKey); + return new Response(message, { + status: 200, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } catch (error) { + if (error instanceof WecomCallbackCryptoError) { + console.warn("[KOC LOOP] wecom callback echostr decrypt failed", error); + return new Response(error.message, { status: 400 }); + } + throw error; + } +} + +export async function POST(request: Request) { + const config = resolveCallbackConfig(); + if (!config) { + return new Response("企业微信回调未启用", { status: 403 }); + } + const { msgSignature, timestamp, nonce } = readCallbackQuery(request); + try { + const body = await request.text(); + const encrypt = extractWecomEncryptField(body); + if (!encrypt) { + return new Response("回调报文缺少 Encrypt 字段", { status: 400 }); + } + if (!msgSignature || !timestamp || !nonce) { + return new Response("缺少回调校验参数", { status: 400 }); + } + const expected = verifyWecomCallbackSignature( + config.token, + timestamp, + nonce, + encrypt, + ); + if (!wecomSignaturesMatch(expected, msgSignature)) { + return new Response("签名校验失败", { status: 403 }); + } + const { message, receiveId } = decryptWecomCallback( + encrypt, + config.encodingAesKey, + ); + if (config.corpId && receiveId !== config.corpId) { + console.warn( + "[KOC LOOP] wecom callback receiveid mismatch", + { expected: config.corpId }, + ); + return new Response("receiveid 校验失败", { status: 403 }); + } + const fields = parseWecomCallbackXml(message); + handleWecomCallbackEvent(fields); + return new Response("", { status: 200 }); + } catch (error) { + if (error instanceof WecomCallbackCryptoError) { + console.warn("[KOC LOOP] wecom callback decrypt failed", error); + return new Response(error.message, { status: 400 }); + } + console.error("[KOC LOOP] wecom callback failed", error); + return new Response("", { status: 200 }); + } +} diff --git a/lib/runtime-env.ts b/lib/runtime-env.ts index 5fddc62..d2f20f3 100644 --- a/lib/runtime-env.ts +++ b/lib/runtime-env.ts @@ -24,6 +24,9 @@ export type RuntimeEnv = { WECOM_ROBOT_WEBHOOK?: string; WECOM_NOTIFY_DUE_DAYS?: string; WECOM_NOTIFY_ENABLED?: string; + WECOM_CALLBACK_TOKEN?: string; + WECOM_CALLBACK_ENCODING_AES_KEY?: string; + WECOM_CALLBACK_ENABLED?: string; SEED_DEMO_DATA?: string; ENABLE_SCHEDULER?: string; }; diff --git a/lib/wecom-callback-crypto.ts b/lib/wecom-callback-crypto.ts new file mode 100644 index 0000000..f7dc5a1 --- /dev/null +++ b/lib/wecom-callback-crypto.ts @@ -0,0 +1,97 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, + timingSafeEqual, +} from "node:crypto"; + +export class WecomCallbackCryptoError extends Error { + status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = "WecomCallbackCryptoError"; + this.status = status; + } +} + +export function resolveWecomCallbackAesKey(encodingAesKey: string): Buffer { + const key = String(encodingAesKey ?? "").trim(); + if (!/^[A-Za-z0-9]{43}$/.test(key)) { + throw new WecomCallbackCryptoError( + "企业微信回调 EncodingAESKey 非法(需 43 位字母数字)", + 500, + ); + } + return Buffer.from(`${key}=`, "base64"); +} + +export function verifyWecomCallbackSignature( + token: string, + timestamp: string, + nonce: string, + encrypt: string, +): string { + return createHash("sha1") + .update([token, timestamp, nonce, encrypt].sort().join("")) + .digest("hex"); +} + +export function wecomSignaturesMatch( + expected: string, + received: string, +): boolean { + const a = Buffer.from(String(expected ?? "")); + const b = Buffer.from(String(received ?? "")); + if (a.length !== b.length || a.length === 0) return false; + return timingSafeEqual(a, b); +} + +export function encryptWecomCallback( + plainMessage: string, + receiveId: string, + encodingAesKey: string, +): string { + const key = resolveWecomCallbackAesKey(encodingAesKey); + const random = randomBytes(16); + const message = Buffer.from(String(plainMessage ?? ""), "utf8"); + const receive = Buffer.from(String(receiveId ?? ""), "utf8"); + const length = Buffer.alloc(4); + length.writeUInt32BE(message.length, 0); + const cipher = createCipheriv("aes-256-cbc", key, key.subarray(0, 16)); + const encrypted = Buffer.concat([ + cipher.update(Buffer.concat([random, length, message, receive])), + cipher.final(), + ]); + return encrypted.toString("base64"); +} + +export function decryptWecomCallback( + encrypted: string, + encodingAesKey: string, +): { message: string; receiveId: string } { + const key = resolveWecomCallbackAesKey(encodingAesKey); + const data = Buffer.from(String(encrypted ?? "").trim(), "base64"); + if (data.length === 0 || data.length % 16 !== 0) { + throw new WecomCallbackCryptoError("企业微信回调密文长度非法"); + } + let raw: Buffer; + try { + const decipher = createDecipheriv("aes-256-cbc", key, key.subarray(0, 16)); + raw = Buffer.concat([decipher.update(data), decipher.final()]); + } catch { + throw new WecomCallbackCryptoError("企业微信回调密文解密失败"); + } + if (raw.length < 20) { + throw new WecomCallbackCryptoError("企业微信回调明文长度非法"); + } + const messageLength = raw.readUInt32BE(16); + if (messageLength <= 0 || 20 + messageLength > raw.length) { + throw new WecomCallbackCryptoError("企业微信回调消息长度字段非法"); + } + return { + message: raw.subarray(20, 20 + messageLength).toString("utf8"), + receiveId: raw.subarray(20 + messageLength).toString("utf8"), + }; +} diff --git a/lib/wecom-callback-events.ts b/lib/wecom-callback-events.ts new file mode 100644 index 0000000..58a9c84 --- /dev/null +++ b/lib/wecom-callback-events.ts @@ -0,0 +1,54 @@ +export type WecomCallbackFields = Record; + +export type WecomCallbackEventResult = { + recognized: boolean; + action: string; + taskId: string; + btnKey: string; + operator: string; +}; + +export function parseWecomCallbackXml(xml: string): WecomCallbackFields { + const fields: WecomCallbackFields = {}; + const source = String(xml ?? ""); + const pattern = + /<([A-Za-z][A-Za-z0-9_]*)>(?:|([^<]*))<\/\1>/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null) { + const tag = match[1]; + const value = match[2] ?? match[3] ?? ""; + if (!(tag in fields)) fields[tag] = value.trim(); + } + return fields; +} + +export function extractWecomEncryptField(body: string): string { + const fields = parseWecomCallbackXml(body); + return fields.Encrypt ?? ""; +} + +export function handleWecomCallbackEvent( + fields: WecomCallbackFields, +): WecomCallbackEventResult { + const event = fields.Event ?? ""; + const taskId = fields.TaskId ?? ""; + const btnKey = fields.BtnKey ?? ""; + const operator = fields.FromUserName ?? ""; + + if (fields.MsgType === "event" && event === "template_card_event") { + if (btnKey === "mark_handled") { + console.info( + "[KOC LOOP] wecom card ack", + { taskId, operator }, + ); + return { recognized: true, action: "card_ack", taskId, btnKey, operator }; + } + return { recognized: true, action: "ignored", taskId, btnKey, operator }; + } + + console.info("[KOC LOOP] wecom callback event ignored", { + msgType: fields.MsgType ?? "", + event, + }); + return { recognized: false, action: "ignored", taskId, btnKey, operator }; +} diff --git a/tests/wecom-callback-crypto.test.mjs b/tests/wecom-callback-crypto.test.mjs new file mode 100644 index 0000000..ed31073 --- /dev/null +++ b/tests/wecom-callback-crypto.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import { + decryptWecomCallback, + encryptWecomCallback, + resolveWecomCallbackAesKey, + verifyWecomCallbackSignature, + wecomSignaturesMatch, + WecomCallbackCryptoError, +} from "../lib/wecom-callback-crypto.ts"; +import { + extractWecomEncryptField, + handleWecomCallbackEvent, + parseWecomCallbackXml, +} from "../lib/wecom-callback-events.ts"; + +const AES_KEY = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ"; +const CORP_ID = "wwCallbackCorp01"; +const TOKEN = "callback-token-test"; + +test("aes key resolution accepts 43-char key and rejects invalid input", () => { + const key = resolveWecomCallbackAesKey(AES_KEY); + assert.equal(key.length, 32); + assert.throws( + () => resolveWecomCallbackAesKey("short"), + WecomCallbackCryptoError, + ); + assert.throws( + () => resolveWecomCallbackAesKey("abcdefghij;klmnopqrstuvwxyzABCDEFGHIJKLMNOP"), + WecomCallbackCryptoError, + ); +}); + +test("encrypt → decrypt roundtrip preserves chinese message and receiveId", () => { + const plain = + ''; + const encrypted = encryptWecomCallback(plain, CORP_ID, AES_KEY); + const decrypted = decryptWecomCallback(encrypted, AES_KEY); + assert.equal(decrypted.message, plain); + assert.equal(decrypted.receiveId, CORP_ID); +}); + +test("decrypt rejects tampered ciphertext", () => { + const encrypted = encryptWecomCallback("hello", CORP_ID, AES_KEY); + const corrupted = `${encrypted.slice(0, -4)}AAAA`; + assert.throws( + () => decryptWecomCallback(corrupted, AES_KEY), + WecomCallbackCryptoError, + ); +}); + +test("decrypt rejects non-block-aligned ciphertext", () => { + assert.throws( + () => decryptWecomCallback("AAAAAAAA", AES_KEY), + WecomCallbackCryptoError, + ); +}); + +test("signature = sha1 of sorted join, order independent", () => { + const encrypted = encryptWecomCallback("hello", CORP_ID, AES_KEY); + const expected = createHash("sha1") + .update([TOKEN, "1756700000", "nonce-abc", encrypted].sort().join("")) + .digest("hex"); + const actual = verifyWecomCallbackSignature( + TOKEN, + "1756700000", + "nonce-abc", + encrypted, + ); + assert.equal(actual, expected); + assert.ok(wecomSignaturesMatch(actual, expected)); + assert.ok(!wecomSignaturesMatch(actual, `${expected}x`)); + assert.ok(!wecomSignaturesMatch(actual, "")); +}); + +test("parse callback xml handles CDATA and plain fields", () => { + const body = + "1756700000"; + const fields = parseWecomCallbackXml(body); + assert.equal(fields.ToUserName, "wwCorp"); + assert.equal(fields.Encrypt, "RW5jcnlwdGVkQmFzZTY0PT0="); + assert.equal(fields.CreateTime, "1756700000"); + assert.equal(extractWecomEncryptField(body), "RW5jcnlwdGVkQmFzZTY0PT0="); +}); + +test("event dispatcher classifies template_card mark_handled and ignores others", () => { + const ack = handleWecomCallbackEvent({ + MsgType: "event", + Event: "template_card_event", + TaskId: "task:t123:due", + BtnKey: "mark_handled", + FromUserName: "opsUser01", + }); + assert.equal(ack.recognized, true); + assert.equal(ack.action, "card_ack"); + assert.equal(ack.taskId, "task:t123:due"); + assert.equal(ack.operator, "opsUser01"); + + const view = handleWecomCallbackEvent({ + MsgType: "event", + Event: "template_card_event", + TaskId: "task:t123:due", + BtnKey: "view_task", + }); + assert.equal(view.recognized, true); + assert.equal(view.action, "ignored"); + + const other = handleWecomCallbackEvent({ + MsgType: "event", + Event: "change_contact", + }); + assert.equal(other.recognized, false); +});