feat: 企微回调服务基建(加解密+事件分发,默认关闭)

This commit is contained in:
ABAPPLO
2026-09-01 15:08:00 +08:00
parent ca3810a3d0
commit b243562e60
5 changed files with 393 additions and 0 deletions

View File

@@ -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 });
}
}

View File

@@ -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;
};

View File

@@ -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"),
};
}

View File

@@ -0,0 +1,54 @@
export type WecomCallbackFields = Record<string, string>;
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_]*)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\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 };
}

View File

@@ -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 =
'<xml><ToUserName><![CDATA[wwCallbackCorp01]]></ToUserName><Event><![CDATA[模板卡片]]></Event></xml>';
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 =
"<xml><ToUserName><![CDATA[wwCorp]]></ToUserName><Encrypt><![CDATA[RW5jcnlwdGVkQmFzZTY0PT0=]]></Encrypt><CreateTime>1756700000</CreateTime></xml>";
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);
});