PHP 微信公众号消息加解密

MaxSky 等级 331 0 0

公众号配置

根据提示设置即可:【图中信息均为无意义数据,仅供参考。注意服务器地址需可接收 GET/POST 两种请求】 PHP 微信公众号消息加解密

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 个参数分别为:

  1. signature 消息请求签名
  2. timestamp 时间戳
  3. nonce 随机数
  4. encrypt_type(加密类型固定 aes
  5. msg_signature 消息签名(不能和 signature 搞混)

解密消息需要 4 个参数,分别是:XML(密文)、msg_signaturetimestampnonce

解码函数

/**
 * @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;
}
收藏
评论区

相关推荐

php指的是什么?
PHP(全称:Hypertext Preprocessor,即“PHP:超文本预处理器”)是一种开源的通用计算机脚本语言,尤其适用于网络开发并可嵌入H
PHP对时间轮算法的简单实现
什么是时间轮算法? 把任务放到它需要被执行的时刻,然后等待时针转到这个时刻,取出该时刻的任务,执行并将任务从该时刻删除(消费)。 解决了什么问题? 以商品为例,如何实现商品的过保质期自动失效? 1:我们可以每分钟执行一个定时任务,扫描全表过期时间大于当前时间的商品,进行失效处理。(当然,也可以将该任务细化成秒级的) 2:商品添加时,将该商品的
PHP程序员必须会的 45 个PHP 面试题(第二部分)
Q20: require\_once 和 require 在什么场景下使用? Topic: PHP Difficulty: ⭐⭐⭐ require\_once() 作用与 require() 的作用是一样的,都是引用或包含外部的一个 php 文件,require\_once() 引入文件时会检查文件是否已包含,如果已包含,不再包含 (requir
PHP程序员必须会的 45 个PHP 面试题(第一部分)
Q1: 和 之间有什么区别? 话题: PHP 困难: ⭐ 如果是两个不同的类型,运算符 则在两个不同的类型之间进行强制转换 操作符执行’_类型安全比较_‘ 这意味着只有当两个操作数具有相同的类型和相同的值时,它才会返回 TRUE。 1 1: true 1 1: true 1 "1
请纠正这5个PHP编码小陋习
在做过大量的代码审查后,我经常看到一些重复的错误,以下是纠正这些错误的方法。 在循环之前测试数组是否为空 $items ; // ... if (count($items) 0) { foreach ($items as $item) { // process on $item ...
使用PHP生成网站Sitemap,Laravel风格
PHP生成网站Sitemap,包含默认、分类、文章、标签、profile等 <?php namespace AppLibs; use AppS
2017最新PHP经典面试题目汇总(上篇)
2017最新PHP经典面试题目汇总(上篇) 2017最新PHP经典面试题目汇总(上篇) 本文章将持续更新,希望能在评论区发表自己的见解和认为比较经典的题目,后续笔者会在适当的节点对本文章进行分类和层次
nginx安全配置
安全是一个重要的问题,必须引起注意。 1. nginx介绍 nginx本身不能处理PHP(http://www.ttlsa.com/php/ "php"),它只是个web服务器,当接收到请求后,如果是php请求,则发给php解释器处理,并把结果返回给客户端。nginx一般是把请求发fastcgi管理进程处理,fastcgi管理进程选择cgi子
为什么要从php 加入到 go 的潮流
为何我要说加入go开发是一种潮流,尤其是对于php开发人员,我加入了很多go的开发群或者爱好群,发现大部分人都是从php过来的,原本google开发golang是想让更多的c/c人员来使用。 PHP 语言作为当今最热门的网站程序开发语言,它也是我多年来一直使用的语言,它具有成本低、速度快、可移植性好、 内置丰富的函数库等优点,因此被越来越多的企业应用于网站
PHP学习笔记之PHP的函数应用
目录一、函数的定义 二、自定义函数 三、函数的工作原理和结构化编程 四、PHP变量的范围 五、声明及应用各种形式的PHP函数 六、递归函数 七、使用自定义函数库 一、函数的定义一个被命名的、独立的代码段,它执行特定的任务,并可能给调用它的程序返回一个值。定义中的各部分如下: 函数是被命名的:每个函数都
PHP 微信公众号消息加解密
公众号配置根据提示设置即可:【图中信息均为无意义数据,仅供参考。注意服务器地址需可接收 GET/POST 两种请求】 AESKey 直接点一下随机生成即可,Token 可以生成一个 UUID 再把 UUID 进行 MD5 一次即可。 接收关注事件消息示例 请求参数校验这一步根据项目情况,可供参考:(Lumen 框架)php$valida
PHP 获取国家、省、市、区及街道区域数据
地址: 分支 new 为全新获取方法,只需要 5 分钟,master 分支 fork 自 https://github.com/foxiswho/taobaoareaphp,补上了街道地址 该分支执行效率略低,但支持 CSV。output 中的 area.sql 文件为目前最新,可直接食用。根据淘宝开放平台获取国家、省、市、区数据,自动生成 SQL文件根
列举一些糟糕的PHP代码
10例糟糕的PHP代码 10例糟糕的PHP代码 这篇文章在很早以前就看到了,由于最近要自己做一些主题方面的东西,代码需要更加规范,用这些反面的例子来约束自己,告诉自己代码不应该这样写,虽然它也能实现功能,但那样做并不明智,
PHP 调用微信小程序 OCR 接口
添加插件在小程序后台 设置 第三方设置 插件管理 中添加 OCR支持 插件。 服务购买在 中购买接口配额。 免费版本目前配额为 100 次/日,可用 36500 天。 接入如果是小程序前端接入,参考上方网页“接入文档”即可。 定义接口常量phpconst OCRBANKCARD 'https://api.weixin.qq.com/cv/ocr
dubbo网关演进之路
本文已收录 https://github.com/lkxiaolou/lkxiaolou 欢迎star。 背景随着公司业务的飞速发展,基于php的模块化架构难以支持未来业务的发展: php模块化架远远落后于行业主流架构(微服务–云原生),而php生态的服务治理开源组件匮乏,研发投入过大 杭州php人才匮乏,导致新鲜血液招聘困难 基于php的多进程架构难以支撑