公众号配置
根据提示设置即可:【图中信息均为无意义数据,仅供参考。注意服务器地址需可接收 GET/POST 两种请求】
AESKey 直接点一下随机生成即可,Token 可以生成一个 UUID 再把 UUID 进行 MD5 一次即可。
接收关注事件消息示例
请求参数校验
这一步根据项目情况,可供参考:(Lumen 框架)
$validateData = Validator::validate($request->all(), [
'signature' => 'required|string|size:40',
'timestamp' => 'required|string|size:10',
'nonce' => 'required|numeric',
'echostr' => 'filled|string',
'openid' => 'filled|string',
'encrypt_type' => 'filled|string|in:aes',
'msg_signature' => 'filled|string|size:40',
]);
消息签名校验
/**
* 消息签名验证
*
* @param string $signature 签名
* @param string $timestamp 10 位时间戳
* @param string $nonce 随机数
* @param string|null $encrypt_msg 加密消息
*
* @return bool
*/
public function checkSignature(string $signature, string $timestamp, string $nonce, ?string $encrypt_msg = null): bool {
$array = [$this->serverToken, $timestamp, $nonce];
if ($encrypt_msg) {
$array[] = $encrypt_msg;
}
sort($array, SORT_STRING);
return sha1(implode($array)) === $signature;
}
通过公众号配置
在公众号后台配置服务器地址时,需要进行一次 Token 响应的校验,所以我们应该在 checkSignature
只上再添加一层用以通过验证并保存配置。
/**
* @param array $data 请求参数,传入通过校验的请求参数 $validateData
*
* @return bool|int|string
*/
public function checkSign(array $data) {
if ($this->checkSignature($data['signature'], $data['timestamp'], $data['nonce'])) {
return (isset($data['echostr']) && !isset($data['msg_signature'])) ? $data['echostr'] : true;
}
return -40001;
}
随后在控制器中,只要请求 $request->method()
是个 GET
就可以直接返回 echostr
字符串了。
错误码返回值参考下方附录。
消息解密
加密消息中,有 5 个参数通过 query
的形式请求,而密文则为 XML
格式通过 POST
请求。
5 个参数分别为:
- signature 消息请求签名
- timestamp 时间戳
- nonce 随机数
- encrypt_type(加密类型固定
aes
) - msg_signature 消息签名(不能和 signature 搞混)
解密消息需要 4 个参数,分别是:XML
(密文)、msg_signature
、timestamp
、nonce
。
解码函数
/**
* @param string $text
*
* @return string
*/
public function decode(string $text): string {
$pad = ord(substr($text, -1));
if ($pad < 1 || $pad > 32) {
$pad = 0;
}
return substr($text, 0, (strlen($text) - $pad));
}
解密
AESKey 处理
此处是重点,必须提前处理 AESKey,否则将影响解密结果。
$this->aesKey = base64_decode('U2FsdGVkX18lt9IhqeRHnImsi6D3Q+8Xo0YYZGmQZSa' . '=');
解密函数
/**
* 密文解密
* $this->aesKey 以及 $this->appId 自行调整配置
*
* @param string $encrypted
*
* @return int|string
*/
public function decrypt(string $encrypted) {
$iv = substr($this->aesKey, 0, 16);
// decrypt
$decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $this->aesKey, OPENSSL_ZERO_PADDING, $iv);
if (!$decrypted) {
return -40007;
}
$result = $this->decode($decrypted);
if (strlen($result) < 16) {
return '';
}
$content = substr($result, 16, strlen($result));
$lenList = unpack('N', substr($content, 0, 4));
$lenXML = $lenList[1];
$fromAppId = substr($content, $lenXML + 4);
if ($fromAppId !== $this->appId) {
return -40001;
}
return substr($content, 4, $lenXML);
}
消息解密处理
/**
* 消息解密
*
* @param string $message
* @param string $msg_signature
* @param string $timestamp
* @param string $nonce
*
* @return SimpleXMLElement|int
*/
public function decryptMessage(string $message, string $msg_signature, string $timestamp, string $nonce) {
// get message
try {
$message = simplexml_load_string($message, 'SimpleXMLElement', LIBXML_COMPACT + LIBXML_NOCDATA);
} catch (Exception $e) {
return -40002;
}
// get encrypt text
$encrypt = $message->Encrypt->__toString();
if (!$encrypt) {
return -40002;
}
// check sign
if (!$this->checkSignature($msg_signature, $timestamp, $nonce, $encrypt)) {
return -40001;
}
$decrypted = $this->decrypt($encrypt);
if (is_int($decrypted)) {
return $decrypted;
}
try {
return simplexml_load_string($decrypted, 'SimpleXMLElement', LIBXML_COMPACT + LIBXML_NOCDATA);
} catch (Exception $e) {
return -40002;
}
}
至此消息解密已经成功,例如微信用户 OpenID 可通过 decryptMessage
方法的返回值获取:
$openId = $decrypted_msg->FromUserName->__toString();
对于解密后消息内所含属性,参阅:基础消息能力 | 微信开放文档
消息加密
当微信用户首次关注公众号时,微信会发送“关注事件”消息到服务端,我们可以使用和公众号后台内相同的“自动回复”功能响应回复内容给微信,从而实现自动回复,这时我们需要对响应的消息进行加密。
根据文档所述,除了消息加密后的响应,如果无需响应任何操作可返回字符串 success
或长度为 0
的空内容,但微信推荐的是 success
。所以我们应当保证服务端仅会响应两种结果:一是 success
字符串;二是 XML
格式内容。
以下内容使用了 Laravel/Lumen 的 View
功能,供参考。
构建响应内容
文件路径:Project/resources/views/wechat/subscribe/default.blade.php
嗨,终于等到你啦!🌹
关注 XX 公众号~
了解更多请点击下方分类菜单吧!
注意:底部如有空行,会在响应给微信用户时显示。文本消息内容是支持 Emoji、超链接的。
构建消息模板
明文消息模板
末尾不能存在空行。
<!-- 文件路径 Project/resources/xml/WeChatReplyMsg.xml -->
<xml>
<ToUserName><![CDATA[%s]]></ToUserName>
<FromUserName><![CDATA[%s]]></FromUserName>
<CreateTime>%d</CreateTime>
<MsgType><![CDATA[$s]]></MsgType>
<Content><![CDATA[%s]]></Content>
</xml>
加密消息模板
末尾不能存在空行。
<!-- 文件路径 Project/resources/xml/WeChatReplyMsgCrypt.xml -->
<xml>
<Encrypt><![CDATA[%s]]></Encrypt>
<MsgSignature><![CDATA[%s]]></MsgSignature>
<TimeStamp>%s</TimeStamp>
<Nonce><![CDATA[%s]]></Nonce>
</xml>
填充响应消息
$retMsg = view('wechat.subscribe.default')->render();
// $openId 和 $toUser 可以通过已解密的消息获得,$timestamp 可以自己生成或直接取微信请求中的 timestamp
// 此处的 text 根据需要进行影响的消息进行调整,内容同理
$replyMessage = sprintf(
file_get_contents(resource_path('xml/WeChatReplyMsg.xml')), $openId, $toUser, $timestamp, 'text', $retMsg
);
加密
编码函数
// 固定值
$this->blockSize = 32;
/**
* @param string $text
*
* @return string
*/
public function encode(string $text): string {
$text_length = strlen($text);
$amount_to_pad = $this->blockSize - ($text_length % $this->blockSize);
if ($amount_to_pad == 0) {
$amount_to_pad = $this->blockSize;
}
$pad_chr = chr($amount_to_pad);
$tmp = '';
for ($index = 0; $index < $amount_to_pad; $index++) {
$tmp .= $pad_chr;
}
return $text . $tmp;
}
加密函数
/**
* @param string $text
*
* @return int|string
*/
public function encrypt(string $text) {
// Laravel/Lumen 中可直接生成 16 位随机字符串
// 如非该框架请参考附录
$random = Illuminate\Support\Str::random();
$text = $random . pack('N', strlen($text)) . $text . $this->appId;
$text = $this->encode($text);
$iv = substr($this->aesKey, 0, 16);
// encrypt
$encrypted = openssl_encrypt($text, 'AES-256-CBC', $this->aesKey, OPENSSL_ZERO_PADDING, $iv);
return $encrypted ?: -40006;
}
生成签名
/**
* @param string $encrypt_msg
* @param string $timestamp
* @param string $nonce
*
* @return string
*/
public function generateSignature(string $encrypt_msg, string $timestamp, string $nonce): string {
$array = [$encrypt_msg, $this->serverToken, $timestamp, $nonce];
sort($array, SORT_STRING);
return sha1(implode($array));
}
消息加密处理
/**
* @param string $reply_message
* @param string $timestamp
* @param string $nonce
*
* @return int|string
*/
public function encryptMessage(string $reply_message, string $timestamp, string $nonce) {
// encrypt
$encrypted = $this->encrypt($reply_message);
if (is_int($encrypted)) {
return $encrypted;
}
// $nonce 同 $timestamp 可以自己生成或直接取微信请求中的 nonce
$signature = $this->generateSignature($encrypted, $timestamp, $nonce);
if (!$signature) {
return -40001;
}
return sprintf(
file_get_contents(resource_path('xml/WeChatReplyMsgCrypt.xml')),
$encrypted, $signature, $timestamp, $nonce
);
}
最后将加密结果响应即可,注意响应头需加上 Content-Type: application/xml
。
多说两句
在接收到微信发来的请求后,根据场景进行业务逻辑处理,在无需响应任何消息(被动回复)时,应直接在方法里返回 success
或空字符串、null
之类。上层根据返回情况判断是否加密消息并返回,尽可能满足 5
秒内响应微信。
附录
错误码
const RET_ERRCODE = [
-40001 => '签名验证错误',
-40002 => 'XML 解析失败',
-40003 => '生成签名失败',
-40004 => 'EncodingAESKey 错误',
-40005 => 'AppID 校验错误',
-40006 => 'AES 加密失败',
-40007 => 'AES 解密失败',
-40008 => 'Buffer 非法',
-40009 => 'Base64 编码失败',
-40010 => 'Base64 解码失败',
-40011 => '生成 XML 失败'
];
// 可以通过 self::RET_ERRCODE[-40001] 的形式返回字符串
随机字符串
/**
* 随机生成 16 位字符串
*
* @return string 生成的字符串
*/
function getRandomStr(): string {
$str = '';
$str_pol = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz';
$max = strlen($str_pol) - 1;
for ($i = 0; $i < 16; $i++) {
$str .= $str_pol[mt_rand(0, $max)];
}
return $str;
}