超详细 erlang服务器之微信公众号被动解析用户消息(明文模式&安全模式)

目录

一、前言:

二、配置微信公众号基础接口

(1)填写IP白名单和App Secret

(2)配置微信公众号服务器URL​编辑

(3)配置微信公众号网页授权域名

(4)自定义菜单

(4)微信公众号推送的消息是xml

(5)获取access_token的函数

(6) 将binary转为16进制字符串的函数

三、明文模式

(1)验证消息安全签名

(2)被动解析用户消息

四、密文模式

(1)验证消息安全签名

(2)解析密文消息为明文

(3)加密明文消息为密文


一、前言:

        目前微信公众号开放平台上面,关于被动解析用户消息的安全模式的接口示例,只有C/C++/PHP/Java/Python,因此我在阅读了这几份接口文档之后,写了一份适合erlang接入的模块(其实就是我觉得官网这是无视我们大erlang群体吗——(〃>目<))。这里不讲兼容模式,因为兼容模式其实也是明文模式和安全模式都有的一个模式。

        erlang 版本 OTP 25,不同于这版本的话,有些函数方法是不一定兼容的,因此请谨慎。

二、配置微信公众号基础接口

(1)填写IP白名单和App Secret

         之后创建自定义菜单时,需要获取access_token,而获取access_token则需要AppID和App Secret。不过现在微信公众平台已经不会主动储存App Secret,因此需要我们开发者妥善保管好。

(2)配置微信公众号服务器URL

        之后微信公众号消息都会推送到这个服务器地址URL上,但是不会有QueryString,因此填写的URL也需要谨慎。

(3)配置微信公众号网页授权域名

         之后微信公众号的图文网页指定的存放域名就是这里,但是可以填写多个,因此不用担心文件管理问题。

(4)自定义菜单

        自定义菜单,其实不算很麻烦,官网的文档理解起来还不算太难。需要注意的是,这里只接受json格式的自定义菜单。

{
    "button": [
        {
            "name": "扫码", 
            "sub_button": [
                {
                    "type": "scancode_waitmsg", 
                    "name": "扫码带提示", 
                    "key": "rselfmenu_0_0", 
                    "sub_button": [ ]
                }, 
                {
                    "type": "scancode_push", 
                    "name": "扫码推事件", 
                    "key": "rselfmenu_0_1", 
                    "sub_button": [ ]
                }
            ]
        }, 
        {
            "name": "发图", 
            "sub_button": [
                {
                    "type": "pic_sysphoto", 
                    "name": "系统拍照发图", 
                    "key": "rselfmenu_1_0", 
                   "sub_button": [ ]
                 }, 
                {
                    "type": "pic_photo_or_album", 
                    "name": "拍照或者相册发图", 
                    "key": "rselfmenu_1_1", 
                    "sub_button": [ ]
                }, 
                {
                    "type": "pic_weixin", 
                    "name": "微信相册发图", 
                    "key": "rselfmenu_1_2", 
                    "sub_button": [ ]
                }
            ]
        }, 
        {
            "name": "发送位置", 
            "type": "location_select", 
            "key": "rselfmenu_2_0"
        },
        {
           "type": "media_id", 
           "name": "图片", 
           "media_id": "MEDIA_ID1"
        }, 
        {
           "type": "view_limited", 
           "name": "图文消息", 
           "media_id": "MEDIA_ID2"
        },
        {
            "type": "article_id",
            "name": "发布后的图文消息",
            "article_id": "ARTICLE_ID1"
        },
        {
            "type": "article_view_limited",
            "name": "发布后的图文消息",
            "article_id": "ARTICLE_ID2"
        }
    ]
}

(4)微信公众号推送的消息是xml

        xml格式如下(官网可查):

<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[FromUser]]></FromUserName>
<CreateTime>123456789</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[CLICK]]></Event>
<EventKey><![CDATA[EVENTKEY]]></EventKey>
</xml>

PS:前面的这些步骤,其实官网都可查,只是怕不细心的小伙伴没查就来看这篇文章,容易迷糊。链接在此

(5)获取access_token的函数

        此处再附上,获取access_token的函数,因为接下来介绍的方法中会用上:

%% 获取公众号access_token
get_oa_access_token() ->
    NowTime = util:unixtime(),
    Method = post,
    {ok, AppID} = api_sys:get_oa_app_id(), %% AppID
    {ok, Secret} = api_sys:get_oa_secret(), %% App Secret
    URL = "https://api.weixin.qq.com/cgi-bin/token?"
        ++ "&appid=" ++ AppID
        ++ "&secret=" ++ Secret
        ++ "&grant_type=client_credential",
    Header = [],
    Type = "application/json; encoding=utf-8",
    Body = "",
    HTTPOptions = [],
    Options = [],
    case httpc:request(Method, {URL, Header, Type, Body}, HTTPOptions, Options) of
        {ok, {
   
   {_, 200, "OK"}, _, Return}} ->
            case jsx:decode(erlang:list_to_binary(Return)) of
                #{<<"access_token">> := AccToken} ->
                    {ok, AccToken};
                Error ->
                    ?ERR("请求微信接口调用凭证失败 ~p~n", [Error]),
                    error
            end;
        Error ->
            ?ERR("请求微信接口调用凭证失败 ~p~n", [Error]),
            error
    end.

(6) 将binary转为16进制字符串的函数

        此处再附上,将binary转为16进制字符串的函数,因为接下来介绍的方法中会用上:

%% 二进制转16进制
binary_to_hex(Bin) ->
	binary_to_list(iolist_to_binary([io_lib:format("~2.16.0b", [A]) || A <- binary_to_list(Bin)])).

三、明文模式

(1)验证消息安全签名

        消息安全签名的验证数据是存放在QueryString中,即Http/Https中的qs,这里将qs格式转化为map格式。明文模式的map格式大致如下:

#{
    <<"nonce">> => <<"123123435">>,
    <<"openid">> => <<"test_asda123123sdas123">>,
    <<"signature">> => <<"sdasdasd2341232134dfqw534tvfg456tgvd">>,
    <<"timestamp">> => <<"1231245254">>
}

        只有验证了消息安全签名准确,才能说明得到的消息可信。验证安全签名的函数如下: 

get_oa_msgs_sign(
    #{
        <<"nonce">> := Nonce,
        <<"signature">> := Sign,
        <<"timestamp">> := TimeStamp
    } = _QSMap, _BodyMap) ->
    {ok, Token} = api_sys:get_oa_token(), %% 配置服务器URL的token令牌
    List = lists:concat(lists:sort([?IF(erlang:is_binary(A), erlang:binary_to_list(A), A) || A <- [Token, TimeStamp, Nonce]])),
    ShaSign = util:binary_to_hex(crypto:hash(sha, List)),
    erlang:binary_to_list(Sign) == ShaSign.

(2)被动解析用户消息

        从上面的知识,其实我们知道微信公众号推送到服务器的用户消息是xml格式,转化为map格式,大概如下:

#{
	<<"xml">> =>
		#{
			<<"CreateTime">> => <<"1673589909">>,
			<<"Event">> => <<"CLICK">>,
			<<"EventKey">> => <<"test">>,
			<<"FromUserName">> => <<"test_dasdasdasdasd">>,
			<<"MsgType">> => <<"event">>,
			<<"ToUserName">> => <<"gh_12312dasdas">>
		}
}
        上面这xml格式不是所有的消息都是这种格式,具体可以看 这里
        而这里我们也简单用回复用户纯文本的方式,写了个测试函数,如下:
%% FromUserID : xml中FromUserName
get_oa_auto_response_click_text(FromUserID, Response) ->
    BodyMap = #{
        <<"touser">> => unicode:characters_to_binary(FromUserID, utf8),
        <<"msgtype">> => <<"text">>,
        <<"text">> => #{<<"content">> => unicode:characters_to_binary(Response, utf8)}
    },
    do_oa_auto_response_sender(jsx:encode(BodyMap)).

do_oa_auto_response_sender(BodyData) ->
    {ok, AccToken} = get_oa_access_token(), %% 获取access_token
    Method = post,
    URL = "https://api.weixin.qq.com/cgi-bin/message/custom/send?"
        ++ "&access_token=" ++ AccToken,
    Header = [],
    Type = "application/json; encoding=utf-8",
    HTTPOptions = [],
    Options = [],
    case httpc:request(Method, {URL, Header, Type, BodyData}, HTTPOptions, Options) of
        {ok, {
    
    {_, 200, "OK"}, _, Return}} ->
            Return;
        Error ->
            ?ERR("自动回复失败...~p~n", [Error]),
            <<>>
    end.

四、密文模式

        公众号在正式运营的情况下,为了避免公众号消息被有心人监听破解,因此,我们会选择微信提供的消息密文模式进行消息监听,但是这部分也是是比较麻烦且让人头疼的部分,因为官网并没有相关代码模块,其他语言示例中的函数方法也跟erlang提供的函数命名差距较大,因此此处是我近期整理的函数模块,希望对大家有所帮助。

        同样再次强调一下,我用的erlang版本是OTP 25,其他的版本分支的函数会有所出入,请大家参考的时候多研究一下接口说明。

(1)验证消息安全签名

        密文模式下的安全签名验证方式与明文模式下的不同,具体可以看如下代码,同样将qs格式转化为map格式:

%% 验证公众号消息安全签名
get_oa_msgs_sign(
    #{
        <<"msg_signature">> := MgsSign,
        <<"nonce">> := Nonce,
        <<"timestamp">> := TimeStamp
    } = _QSMap,
    #{
        <<"Encrypt">> := Encrypt
    } = _BodyMap) ->
    {ok, Token} = api_sys:get_oa_token(), %% 配置服务器URL的token令牌
    List = lists:concat(lists:sort([?IF(erlang:is_binary(A), erlang:binary_to_list(A), A) || A <- [Token, TimeStamp, Nonce, Encrypt]])),
    ShaSign = util:binary_to_hex(crypto:hash(sha, List)),
    erlang:binary_to_list(MgsSign) == ShaSign;

        细心的小伙伴就会发现,密文模式下的验证字段是 msg_signature ,而明文模式下,则验证字段是 signature

(2)解析密文消息为明文

废话不多说,上代码,如下:

%% 解密公众号密文消息
get_oa_decrypt_msgs(EncryptMsgs) ->
    %% 配置服务器URL时,消息加解密密钥(EncodingAESKey)
    {ok, OldAesKey} = api_sys:get_oa_aes_key(),
    %% 密钥 ++ "=",然后转为binary,再base64解码,得到新的AesKey
    AesKey = base64:decode(unicode:characters_to_binary(OldAesKey ++ "=")),
    %% 新的AesKey的前16位是aes加密需要用的 iv值
    <<IV:16/binary, _/binary>> = AesKey,
    %% 用base64将密文进行解码,得到新的密文PlainText
    PlainText = base64:decode(EncryptMsgs),
    %% 函数选择OTP 25 版的crypto:crypto_one_time/5,解密方式用aes_cbc
    %% 中间三个参数AesKey, IV, PlainText已在前面已经获得了
    %% 最后一个参数,解密用false,加密则用true
    %% 得到全部解密后的明文DecryptText
    DecryptText = crypto:crypto_one_time(aes_cbc, AesKey, IV, PlainText, false),
    %% 明文需要剔除后面的明文补位字符
    ContentBin = get_oa_pkcs7_decoder(DecryptText),
    %% 然后前面是补位16位的随机字符,和4位的正确明文长度,由此可得正确的明文长度XmlContentLen
    <<XmlContentLen:32>> = binary:part(ContentBin, 16, 4),
    %% 截取20位之后,到XmlContentLen的明文内容
    XmlContent = binary:part(ContentBin, 20, XmlContentLen),
    {ok, XmlContent}.

%% 获取删除补位字符的明文
get_oa_pkcs7_decoder(DecryptText) ->
    %% 最后一位是明文最后的补位字符长度
    BlockSize = 32,
    Pad = binary:last(DecryptText),
    %% Pad小于1或者大于32位,Pad=0,反之不变
    NPad = ?IF(Pad < 1 orelse Pad > BlockSize, 0, Pad),
    ContentLen = erlang:byte_size(DecryptText),
    %% 剔除后面的明文补位字符
    ContentBin = binary:part(DecryptText, 0, ContentLen - NPad),
    ContentBin.

(3)加密明文消息为密文

%% 加密公众号明文消息
get_oa_encrypt_msgs(DecryptMsgs) ->
    %% 随机16位字符,用于填充在密文前面
    RandomText = util:rand_list_repeat(16, "1234567890qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM"),
    %% 配置服务器URL时,消息加解密密钥(EncodingAESKey)
    {ok, OldAesKey} = api_sys:get_oa_aes_key(),
    %% 密钥 ++ "=",然后转为binary,再base64解码,得到新的AesKey
    AesKey = base64:decode(unicode:characters_to_binary(OldAesKey ++ "=")),
    %% 新的AesKey的前16位是aes加密需要用的 iv值
    <<IV:16/binary, _/binary>> = AesKey,
    %% 公众号的AppID
    {ok, AppID} = api_sys:get_oa_app_id(),
    %% 密文填充顺序:randomStr + textLen + text + appid
    RandomTextBin = unicode:characters_to_binary(RandomText),
    DecryptMsgsBin = unicode:characters_to_binary(DecryptMsgs),
    DecryptMsgsBinLen = erlang:byte_size(DecryptMsgsBin),
    AppIDBin = unicode:characters_to_binary(AppID),
    %% 得到没加密的明文binary
    UnBin = <<RandomTextBin/binary, DecryptMsgsBinLen:32, DecryptMsgsBin/binary, AppIDBin/binary>>,
    BinCount = erlang:byte_size(UnBin),
    %% 获取补位字符
    PKCS7Bin = get_oa_pkcs7_encoder(BinCount),
    %% 明文后面填充补位字符
    PlainText = <<UnBin/binary, PKCS7Bin/binary>>,
    %% 函数选择OTP 25 版的crypto:crypto_one_time/5,加密方式用aes_256_cbc,如果用aes_cbc,会提示找不到加密类型
    %% 中间三个参数AesKey, IV, PlainText已在前面已经获得了
    %% 最后一个参数,解密用false,加密则用true
    %% 得到全部加密后的密文EncryptMsgs
    EncryptMsgs = crypto:crypto_one_time(aes_256_cbc, AesKey, IV, PlainText, true),
    %% 最后再将密文EncryptMsgs进行base64加码,得到最终的密文Base64EncryptMsgs
    Base64EncryptMsgs = base64:encode(EncryptMsgs),
    {ok, Base64EncryptMsgs}.

%% 获取补位字符
get_oa_pkcs7_encoder(Count) ->
    BlockSize = 32,
    %% 32 - 明文长度除以32取余,如果余数=0,则补位字符位数=32,反之不变
    Pad = BlockSize  - (Count rem BlockSize),
    NPad = ?IF(Pad == 0, BlockSize, Pad),
    %% 复制补位字符,长度为前面得到的补位字符位数NPad,复制所用的字符则是补位字符位数所对应的字符
    PadStr = lists:duplicate(NPad, NPad),
    erlang:list_to_binary(PadStr).

最后怎么回复用户消息,其实跟明文模式差不多。

猜你喜欢

转载自blog.csdn.net/qq_15855921/article/details/128673257
今日推荐