feat: 微信信息验证 & echo功能
This commit is contained in:
75
src/index.ts
75
src/index.ts
@ -1,8 +1,79 @@
|
||||
import { defineEndpoint } from "@directus/extensions-sdk";
|
||||
import { parseStringXML, parseWechatEncrypt, parseWechatMessage } from "./utils/xml-parser";
|
||||
import { verifyEncrypt, verifySignature } from "./utils/verification";
|
||||
import crypto from 'crypto'
|
||||
import { WechatCrypto } from "./utils/wechatCrypto";
|
||||
|
||||
export default defineEndpoint({
|
||||
id: "wechat-service",
|
||||
handler: (router) => {
|
||||
router.get("/", (_req, res) => res.send("Hello, Wechat!"));
|
||||
handler: (router, { env }) => {
|
||||
router.get('/', async (req, res) => {
|
||||
const { signature, timestamp, nonce, echostr } = req.query;
|
||||
|
||||
const token = env.WECHAT_TOKEN;
|
||||
|
||||
if (verifySignature(token as string, signature as string, timestamp as string, nonce as string)) {
|
||||
return res.send(echostr);
|
||||
}
|
||||
return res.status(403).send('Invalid signature');
|
||||
});
|
||||
|
||||
router.post("/echo", async (req, res) => {
|
||||
const jsonData = await parseWechatMessage(req);
|
||||
return res.json({ received: jsonData });
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
// 验证Encrypt签名
|
||||
const { timestamp, nonce, msg_signature } = req.query;
|
||||
const token = env.WECHAT_TOKEN;
|
||||
|
||||
const encryptData = await parseWechatEncrypt(req);
|
||||
const encrypt = encryptData.Encrypt;
|
||||
|
||||
if (!verifyEncrypt(token as string, encrypt as string, timestamp as string, nonce as string, msg_signature as string)) {
|
||||
console.error("Invalid Signature");
|
||||
return res.status(403).send('Invalid Signature');
|
||||
}
|
||||
|
||||
// 处理加密信息体
|
||||
const encodingAESKey = env.WECHAT_AESKEY;
|
||||
const appId = env.WECHAT_APPID;
|
||||
const cryptoUtil = new WechatCrypto(encodingAESKey, appId);
|
||||
const decryptXml = cryptoUtil.decrypt(encrypt);
|
||||
const msg = await parseStringXML(decryptXml);
|
||||
let replyContent;
|
||||
if (msg.MsgType !== 'text') {
|
||||
replyContent = "暂不支持该消息类型";
|
||||
} else {
|
||||
replyContent = `已收到你的加密消息:${msg.Content}`;
|
||||
|
||||
}
|
||||
|
||||
// 回复消息(示例:文本)
|
||||
const reply = `<xml>
|
||||
<ToUserName><![CDATA[${msg.FromUserName}]]></ToUserName>
|
||||
<FromUserName><![CDATA[${msg.ToUserName}]]></FromUserName>
|
||||
<CreateTime>${Date.now()}</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[${replyContent}]]></Content>
|
||||
</xml>`;
|
||||
|
||||
const encryptedReply = cryptoUtil.encrypt(reply);
|
||||
|
||||
// 再次签名
|
||||
const replyArr = [token, timestamp, nonce, encryptedReply].sort();
|
||||
const replySig = crypto.createHash('sha1').update(replyArr.join('')).digest('hex');
|
||||
|
||||
const responseXml = `<xml>
|
||||
<Encrypt><![CDATA[${encryptedReply}]]></Encrypt>
|
||||
<MsgSignature><![CDATA[${replySig}]]></MsgSignature>
|
||||
<TimeStamp>${timestamp}</TimeStamp>
|
||||
<Nonce><![CDATA[${nonce}]]></Nonce>
|
||||
</xml>`;
|
||||
|
||||
res.set('Content-Type', 'application/xml');
|
||||
return res.send(responseXml);
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
30
src/types/wechat-message.ts
Normal file
30
src/types/wechat-message.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export interface WechatEncryptMessage {
|
||||
ToUserName: string;
|
||||
Encrypt: string;
|
||||
}
|
||||
|
||||
export interface WechatBaseMessage {
|
||||
/** 接收方微信号 */
|
||||
ToUserName: string;
|
||||
/** 发送方微信号 */
|
||||
FromUserName: string;
|
||||
/** 消息创建时间 */
|
||||
CreateTime: number;
|
||||
/** 消息类型 */
|
||||
MsgType: string;
|
||||
}
|
||||
|
||||
export interface WechatTextMessage extends WechatBaseMessage {
|
||||
/** 文本消息类型 */
|
||||
MsgType: "text";
|
||||
/** 文本消息内容 */
|
||||
Content: string;
|
||||
/** 消息id,64位整型 */
|
||||
MsgId: string;
|
||||
/** 消息数据id,消息来自文章时存在 */
|
||||
MsgDataId?: string;
|
||||
/** 多图文时的文章索引,从1开始,消息来自文章时存在 */
|
||||
Idx?: string;
|
||||
}
|
||||
|
||||
export type WechatMessage = WechatTextMessage;
|
||||
17
src/utils/verification.ts
Normal file
17
src/utils/verification.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function verifySignature(token: string, signature: string, timestamp: string, nonce: string): boolean {
|
||||
const tempArray = [token, timestamp, nonce].sort();
|
||||
const str = tempArray.join('');
|
||||
const sha1 = crypto.createHash('sha1');
|
||||
const hash = sha1.update(str).digest('hex');
|
||||
return hash === signature
|
||||
}
|
||||
|
||||
export function verifyEncrypt(token: string, encrypt: string, timestamp: string, nonce: string, msg_signature: string): boolean {
|
||||
const tempArray = [token, timestamp, nonce, encrypt].sort();
|
||||
const str = tempArray.join('');
|
||||
const sha1 = crypto.createHash('sha1');
|
||||
const hash = sha1.update(str).digest('hex');
|
||||
return hash === msg_signature
|
||||
}
|
||||
59
src/utils/wechatCrypto.ts
Normal file
59
src/utils/wechatCrypto.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 微信AES加解密工具
|
||||
*/
|
||||
export class WechatCrypto {
|
||||
AESKey: Buffer;
|
||||
iv: Buffer;
|
||||
AppId: string;
|
||||
|
||||
constructor(encodingAESKey: string, appId: string) {
|
||||
this.AESKey = Buffer.from(encodingAESKey + '=', 'base64');
|
||||
this.iv = this.AESKey.subarray(0, 16);
|
||||
this.AppId = appId;
|
||||
}
|
||||
|
||||
/** 信息体解密 */
|
||||
decrypt(encrypt: string) {
|
||||
const TmpMsg = Buffer.from(encrypt, 'base64');
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', this.AESKey, this.iv);
|
||||
decipher.setAutoPadding(false);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(TmpMsg),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
let pad = decrypted[decrypted.length - 1]!;
|
||||
if (pad < 1 || pad > 32) pad = 0;
|
||||
const content = decrypted.subarray(16, decrypted.length - pad);
|
||||
const msgLength = content.readUInt32BE(0);
|
||||
const msg = content.subarray(4, 4 + msgLength).toString('utf-8');
|
||||
const appId = content.subarray(4 + msgLength).toString('utf-8');
|
||||
|
||||
if (appId !== this.AppId) {
|
||||
throw new Error('AppID mismatch');
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** 信息体加密 */
|
||||
encrypt(replyMsg: string) {
|
||||
const random16 = crypto.randomBytes(16);
|
||||
const msg = Buffer.from(replyMsg);
|
||||
const msgLength = Buffer.alloc(4);
|
||||
msgLength.writeUInt32BE(msg.length, 0);
|
||||
const appIdBuffer = Buffer.from(this.AppId);
|
||||
|
||||
const raw = Buffer.concat([random16, msgLength, msg, appIdBuffer]);
|
||||
const padLen = 32 - (raw.length % 32);
|
||||
const pad = Buffer.alloc(padLen, padLen);
|
||||
const content = Buffer.concat([raw, pad]);
|
||||
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', this.AESKey, this.iv);
|
||||
cipher.setAutoPadding(false);
|
||||
const encrypted = Buffer.concat([cipher.update(content), cipher.final()]);
|
||||
return encrypted.toString('base64');
|
||||
}
|
||||
}
|
||||
103
src/utils/xml-parser.ts
Normal file
103
src/utils/xml-parser.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import getRawBody from "raw-body";
|
||||
import type { Request, Response } from "express";
|
||||
import { WechatEncryptMessage, WechatMessage } from "../types/wechat-message";
|
||||
|
||||
export async function parseWechatEncrypt(req: Request): Promise<WechatEncryptMessage> {
|
||||
const raw = await getRawBody(req, { encoding: "utf-8" });
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
cdataPropName: '__cdata'
|
||||
})
|
||||
const result = parser.parse(raw.toString());
|
||||
|
||||
const xml = result.xml as Record<string, unknown>;
|
||||
|
||||
// 将可能存在的 __cdata 提取
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const key in xml) {
|
||||
const value = xml[key];
|
||||
if (typeof value === 'object' && value && '__cdata' in value) {
|
||||
normalized[key] = (value as { __cdata: string }).__cdata;
|
||||
} else {
|
||||
normalized[key] = value as string;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ToUserName: normalized.ToUserName,
|
||||
...normalized,
|
||||
} as WechatEncryptMessage;
|
||||
}
|
||||
|
||||
export async function parseStringXML(raw: string): Promise<WechatMessage> {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
cdataPropName: '__cdata',
|
||||
});
|
||||
const result = parser.parse(raw.toString());
|
||||
|
||||
const xml = result.xml as Record<string, unknown>;
|
||||
|
||||
console.log("Parsed XML:", xml);
|
||||
|
||||
// 将可能存在的 __cdata 提取
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const key in xml) {
|
||||
const value = xml[key];
|
||||
if (typeof value === 'object' && value && '__cdata' in value) {
|
||||
normalized[key] = (value as { __cdata: string }).__cdata;
|
||||
} else {
|
||||
normalized[key] = value as string;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(normalized.CreateTime)
|
||||
|
||||
return {
|
||||
ToUserName: normalized.ToUserName,
|
||||
FromUserName: normalized.FromUserName,
|
||||
CreateTime: Number(normalized.CreateTime),
|
||||
MsgType: normalized.MsgType,
|
||||
...normalized,
|
||||
} as WechatMessage;
|
||||
}
|
||||
|
||||
export async function parseWechatMessage(
|
||||
req: Request,
|
||||
): Promise<WechatMessage> {
|
||||
const raw = await getRawBody(req, { encoding: "utf-8" });
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
cdataPropName: '__cdata',
|
||||
});
|
||||
const result = parser.parse(raw.toString());
|
||||
|
||||
const xml = result.xml as Record<string, unknown>;
|
||||
|
||||
console.log("Parsed XML:", xml);
|
||||
|
||||
// 将可能存在的 __cdata 提取
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const key in xml) {
|
||||
const value = xml[key];
|
||||
if (typeof value === 'object' && value && '__cdata' in value) {
|
||||
normalized[key] = (value as { __cdata: string }).__cdata;
|
||||
} else {
|
||||
normalized[key] = value as string;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(normalized.CreateTime)
|
||||
|
||||
return {
|
||||
ToUserName: normalized.ToUserName,
|
||||
FromUserName: normalized.FromUserName,
|
||||
CreateTime: Number(normalized.CreateTime),
|
||||
MsgType: normalized.MsgType,
|
||||
...normalized,
|
||||
} as WechatMessage;
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user