目录
一、前言:
目前微信公众号开放平台上面,关于被动解析用户消息的安全模式的接口示例,只有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">>
}
}
%% 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).
最后怎么回复用户消息,其实跟明文模式差不多。