feat: 企微回调服务基建(加解密+事件分发,默认关闭)
This commit is contained in:
125
app/api/wecom-callback/route.ts
Normal file
125
app/api/wecom-callback/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
97
lib/wecom-callback-crypto.ts
Normal file
97
lib/wecom-callback-crypto.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
54
lib/wecom-callback-events.ts
Normal file
54
lib/wecom-callback-events.ts
Normal 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 };
|
||||
}
|
||||
114
tests/wecom-callback-crypto.test.mjs
Normal file
114
tests/wecom-callback-crypto.test.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user