微信掃碼登錄
1. 使用背景
如今開發業務系統,已不是一個單獨的系統。往往需要同多個不同系統相互調用,甚至有時還需要跟微信,釘釘,飛書這樣平台對接。目前我開發的部分業務系統,已經完成微信公眾平台對接。作為知識總結,接下來,我們探討下對接微信公眾平台的一小部分功能,微信掃碼登錄。其中的關鍵點是獲取openid。我仔細查找了微信提供的開發文檔,主要有以下三個方式可實現。
- 通過微信公眾平台生成帶參數的二維
- 通過微信公眾平台微信網頁授權登錄
- 通過微信開發平台微信登錄功能
2. 開發環境搭建
2.1 內網穿透
微信所有的接口訪問,都要求使用域名。但多數開發者是沒有域名,給很多開發者測試帶來了麻煩。不過有以下兩種方案可以嘗試:
- 使用公司域名,讓公司管理員配置一個子域名指向你公司公網的一個ip的80端口。然后通過Nginx或者通過nat命令,將改域名定位到您的開發環境
- 使用內網穿透工具,目前市面有很多都可以使用免費的隧道。不過就是不穩定,不支持指定固定子域名或者已經被微信限制訪問。經過我大量收集資料,發現釘釘開發平台提供的內網穿透工具,比較不錯。用阿里的東西來對接微信東西,想想都為微信感到恥辱。你微信不為開發者提供便利,就讓對手來實現。
那釘釘的內網穿透工具具體怎么使用用的呢?
首先使用git下載釘釘內網穿透工具,下載好后找到windows_64
目錄,在這里新建一個start.bat
文件,內容為
ding -config=ding.cfg -subdomain=pro 8080
其中-subdomain
是用來生成子域名8080
表示隱射本地8080端口
雙擊start.bat
文件,最終啟動成功界面如下
經過我測試,這個相當穩定,並且可以指定靜態子域名。簡直就是業界良心
2.2 公眾號測試環境
訪問公眾平台測試賬號系統,可以通過微信登錄,可快速得到一個測試賬號。然后我們需要以下兩個配置
- 接口配置信息
在點擊提交按鈕時,微信服務器會驗證我們配置的這個URL是否有效。這個URL有兩個用途
- 通過簽名驗證地址是否有效
- 接收微信推送的信息,比如用戶掃碼后通知
簽名生成邏輯是用配置的token
結合微信回傳的timestamp
,nonce
,通過字符串數組排序形成新的字符串,做SHA簽名,再將簽名后的二進制數組轉換成十六進制字符串。最終的內容就是具體的簽名信息。對應的java代碼如下
// author: herbert 公眾號:小院不小 20210424
public static String getSignature(String token, String timestamp, String nonce) {
String[] array = new String[] { token, timestamp, nonce };
Arrays.sort(array);
StringBuffer sb = new StringBuffer();
for (String str : array) {
sb.append(str);
}
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(sb.toString().getBytes());
byte[] digest = md.digest();
StringBuffer hexStr = new StringBuffer();
String shaHex = "";
for (int i = 0; i < digest.length; i++) {
shaHex = Integer.toHexString(digest[i] & 0xFF);
if (shaHex.length() < 2) {
hexStr.append(0);
}
hexStr.append(shaHex);
}
return hexStr.toString();
} catch (NoSuchAlgorithmException e) {
logger.error("獲取簽名信息失敗", e.getCause());
}
return "";
}
對應GET請求代碼如下
// author: herbert 公眾號:小院不小 20210424
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
logger.info("微信在配置服務器傳遞驗證參數");
Map<String, String[]> reqParam = request.getParameterMap();
for (String key : reqParam.keySet()) {
logger.info(" {} = {}", key, reqParam.get(key));
}
String signature = request.getParameter("signature");
String echostr = request.getParameter("echostr");
String timestamp = request.getParameter("timestamp");
String nonce = request.getParameter("nonce");
String buildSign = WeixinUtils.getSignature(TOKEN, timestamp, nonce);
logger.info("服務器生成簽名信息:{}", buildSign);
if (buildSign.equals(signature)) {
response.getWriter().write(echostr);
logger.info("服務生成簽名與微信服務器生成簽名相等,驗證成功");
return;
}
}
微信服務器驗證規則是原樣返回echostr
,如果覺得簽名麻煩,直接返回echostr
也是可以的。
- JS接口安全域名
這個配置主要用途是解決H5與微信JSSDK集成。微信必須要求指定的域名下,才能調用JSSDK
3. 測試項目搭建
為了測試掃碼登錄效果,我們需要搭建一個簡單的maven工程。工程中具體文件目錄如下
用戶掃描二維碼得到對應的openid
,然后在userdata.json
文件中,根據openid
查找對應的用戶。找到了,就把用戶信息寫入緩存。沒找到,就提醒用戶綁定業務賬號信息。前端通過定時輪詢,從服務緩存中查找對應掃碼的用戶信息
userdata.json
文件中的內容如下
[{
"userName": "張三",
"password":"1234",
"userId": "000001",
"note": "掃碼登錄",
"openId": ""
}]
從代碼可以知道,后端提供了5個Servlet,其作用分別是
- WeixinMsgEventServlet 完成微信服務器驗證,接收微信推送消息。
- WeixinQrCodeServlet 完成帶參數二維碼生成,以及完成登錄輪詢接口
- WeixinBindServlet 完成業務信息與用戶openid綁定操作
- WeixinWebQrCodeServlet 完成網頁授權登錄的二維碼生成
- WeixinRedirectServlet 完成網頁授權接收微信重定向回傳參數
需要調用微信接口信息如下
// author: herbert 公眾號小院不小 20210424
private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={0}&secret={1}";
private static final String QRCODE_TICKET_URL = "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token={0}";
private static final String QRCODE_SRC_URL = "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={0}";
private static final String STENDTEMPLATE_URL = "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={0}";
private static final String WEB_AUTH_URL = "https://open.weixin.qq.com/connect/oauth2/authorize?appid={0}&redirect_uri={1}&response_type=code&scope=snsapi_base&state={2}#wechat_redirect";
private static final String WEB_AUTH_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token?appid={0}&secret={1}&code={2}&grant_type=authorization_code";
前端對應的三個頁面分別是
- login.html 用於展現登錄的二維碼,以及實現輪詢邏輯
- index.html 用於登錄成功后,顯示用戶信息
- weixinbind.html 用於用戶綁定業務信息
最終實現的效果如下
已綁定openid直接跳轉到首頁
未綁定用戶,在手機到會收到邀請微信綁定鏈接
4. 帶參數二維碼登錄
生成帶參數的二維碼主要通過以下三個步驟來實現
- 使用APPID和APPSECRET換取ACCESSTOKEN
- 使用ACCESSTOKEN換取對應二維碼的TICKET
- 使用TICKET獲取具體的二維圖片返回給前端
4.1 獲取公眾號ACCESSTOKEN
換取ACCESSTOKEN 代碼如下
// author: herbert 公眾號小院不小 20210424
public static String getAccessToken() {
if (ACCESSTOKEN != null) {
logger.info("從內存中獲取到AccessToken:{}", ACCESSTOKEN);
return ACCESSTOKEN;
}
String access_token_url = MessageFormat.format(ACCESS_TOKEN_URL, APPID, APPSECRET);
logger.info("access_token_url轉換后的訪問地址");
logger.info(access_token_url);
Request request = new Request.Builder().url(access_token_url).build();
OkHttpClient httpClient = new OkHttpClient();
Call call = httpClient.newCall(request);
try {
Response response = call.execute();
String resBody = response.body().string();
logger.info("獲取到相應正文:{}", resBody);
JSONObject jo = JSONObject.parseObject(resBody);
String accessToken = jo.getString("access_token");
String errCode = jo.getString("errcode");
if (StringUtils.isBlank(errCode)) {
errCode = "0";
}
if ("0".equals(errCode)) {
logger.info("獲取accessToken成功,值為:{}", accessToken);
ACCESSTOKEN = accessToken;
}
return accessToken;
} catch (IOException e) {
logger.error("獲取accessToken出現錯誤", e.getCause());
}
return null;
}
4.2 獲取二維碼TICKET
根據ACCESSTOKEN獲取二維碼TICKET代碼如下
// author: herbert 公眾號:小院不小 20210424
public static String getQrCodeTiket(String accessToken, String qeCodeType, String qrCodeValue) {
String qrcode_ticket_url = MessageFormat.format(QRCODE_TICKET_URL, accessToken);
logger.info("qrcode_ticket_url轉換后的訪問地址");
logger.info(qrcode_ticket_url);
JSONObject pd = new JSONObject();
pd.put("expire_seconds", 604800);
pd.put("action_name", "QR_STR_SCENE");
JSONObject sence = new JSONObject();
sence.put("scene", JSONObject
.parseObject("{\"scene_str\":\"" + MessageFormat.format("{0}#{1}", qeCodeType, qrCodeValue) + "\"}"));
pd.put("action_info", sence);
logger.info("提交內容{}", pd.toJSONString());
RequestBody body = RequestBody.create(JSON, pd.toJSONString());
Request request = new Request.Builder().url(qrcode_ticket_url).post(body).build();
OkHttpClient httpClient = new OkHttpClient();
Call call = httpClient.newCall(request);
try {
Response response = call.execute();
String resBody = response.body().string();
logger.info("獲取到相應正文:{}", resBody);
JSONObject jo = JSONObject.parseObject(resBody);
String qrTicket = jo.getString("ticket");
String errCode = jo.getString("errcode");
if (StringUtils.isBlank(errCode)) {
errCode = "0";
}
if ("0".equals(jo.getString(errCode))) {
logger.info("獲取QrCodeTicket成功,值為:{}", qrTicket);
}
return qrTicket;
} catch (IOException e) {
logger.error("獲取QrCodeTicket出現錯誤", e.getCause());
}
return null;
}
4.3 返回二維圖片
獲取二維碼圖片流代碼如下
// author: herbert 公眾號:小院不小 20210424
public static InputStream getQrCodeStream(String qrCodeTicket) {
String qrcode_src_url = MessageFormat.format(QRCODE_SRC_URL, qrCodeTicket);
logger.info("qrcode_src_url轉換后的訪問地址");
logger.info(qrcode_src_url);
Request request = new Request.Builder().url(qrcode_src_url).get().build();
OkHttpClient httpClient = new OkHttpClient();
Call call = httpClient.newCall(request);
try {
Response response = call.execute();
return response.body().byteStream();
} catch (IOException e) {
logger.error("獲取qrcode_src_url出現錯誤", e.getCause());
}
return null;
}
最終二維碼圖片通過servlet
中的get方法返回到前端,需要注意的地方就是為當前session添加key用於存儲掃碼用戶信息或openid
// author: herbert 公眾號:小院不小 20210424
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
accessToken = WeixinUtils.getAccessToken();
String cacheKey = request.getParameter("key");
logger.info("當前用戶緩存key:{}", cacheKey);
WeixinCache.put(cacheKey, "none");
WeixinCache.put(cacheKey + "_done", false);
if (qrCodeTicket == null) {
qrCodeTicket = WeixinUtils.getQrCodeTiket(accessToken, QRCODETYPE, cacheKey);
}
InputStream in = WeixinUtils.getQrCodeStream(qrCodeTicket);
response.setContentType("image/jpeg; charset=utf-8");
OutputStream os = null;
os = response.getOutputStream();
byte[] buffer = new byte[1024];
int len = 0;
while ((len = in.read(buffer)) != -1) {
os.write(buffer, 0, len);
}
os.flush();
}
4.4 前端顯示二維圖片
前端可以使用image
標簽,src
指向這個servlet
地址就可以了
<div class="loginPanel" style="margin-left: 25%;">
<div class="title">微信登錄(微信場景二維碼)</div>
<div class="panelContent">
<div class="wrp_code"><img class="qrcode lightBorder" src="/weixin-server/weixinqrcode?key=herbert_test_key"></div>
<div class="info">
<div id="wx_default_tip">
<p>請使用微信掃描二維碼登錄</p>
<p>“掃碼登錄測試系統”</p>
</div>
</div>
</div>
</div>
4.5 前端輪詢掃碼情況
pc端訪問login
頁面時,除了顯示對應的二維碼,也需要開啟定時輪詢操作。查詢到掃碼用戶信息就跳轉到index
頁面,沒有就間隔2秒繼續查詢。輪詢的代碼如下
// author: herbert 公眾號:小院不小 20210424
function doPolling() {
fetch("/weixin-server/weixinqrcode?key=herbert_test_key", { method: 'POST' }).then(resp => resp.json()).then(data => {
if (data.errcode == 0) {
console.log("獲取到綁定用戶信息")
console.log(data.binduser)
localStorage.setItem("loginuser", JSON.stringify(data.binduser));
window.location.replace("index.html")
}
setTimeout(() => {
doPolling()
}, 2000);
})
}
doPolling()
可以看到前端訪問了后台一個POST接口,這個對應的后台代碼如下
// author: herbert 公眾號:小院不小 20210424
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String cacheKey = request.getParameter("key");
logger.info("登錄輪詢讀取緩存key:{}", cacheKey);
Boolean cacheDone = (Boolean) WeixinCache.get(cacheKey + "_done");
response.setContentType("application/json;charset=utf-8");
String rquestBody = WeixinUtils.InPutstream2String(request.getInputStream(), charSet);
logger.info("獲取到請求正文");
logger.info(rquestBody);
logger.info("是否掃碼成功:{}", cacheDone);
JSONObject ret = new JSONObject();
if (cacheDone != null && cacheDone) {
JSONObject bindUser = (JSONObject) WeixinCache.get(cacheKey);
ret.put("binduser", bindUser);
ret.put("errcode", 0);
ret.put("errmsg", "ok");
WeixinCache.remove(cacheKey);
WeixinCache.remove(cacheKey + "_done");
logger.info("已移除緩存數據,key:{}", cacheKey);
response.getWriter().write(ret.toJSONString());
return;
}
ret.put("errcode", 99);
ret.put("errmsg", "用戶還未掃碼");
response.getWriter().write(ret.toJSONString());
}
通過以上的操作,完美解決了二維顯示和輪詢功能。但用戶掃描了我們提供二維碼,我們系統怎么知道呢?還記得我們最初配置的URL么,微信會把掃描情況通過POST的方式發送給我們。對應接收的POST代碼如下
// author: herbert 公眾號:小院不小 20210424
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String rquestBody = WeixinUtils.InPutstream2String(request.getInputStream(), charSet);
logger.info("獲取到微信推送消息正文");
logger.info(rquestBody);
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
DocumentBuilder db = dbf.newDocumentBuilder();
StringReader sr = new StringReader(rquestBody);
InputSource is = new InputSource(sr);
Document document = db.parse(is);
Element root = document.getDocumentElement();
NodeList fromUserName = document.getElementsByTagName("FromUserName");
String openId = fromUserName.item(0).getTextContent();
logger.info("獲取到掃碼用戶openid:{}", openId);
NodeList msgType = root.getElementsByTagName("MsgType");
String msgTypeStr = msgType.item(0).getTextContent();
if ("event".equals(msgTypeStr)) {
NodeList event = root.getElementsByTagName("Event");
String eventStr = event.item(0).getTextContent();
logger.info("獲取到event類型:{}", eventStr);
if ("SCAN".equals(eventStr)) {
NodeList eventKey = root.getElementsByTagName("EventKey");
String eventKeyStr = eventKey.item(0).getTextContent();
logger.info("獲取到掃碼場景值:{}", eventKeyStr);
if (eventKeyStr.indexOf("QRCODE_LOGIN") == 0) {
String cacheKey = eventKeyStr.split("#")[1];
scanLogin(openId, cacheKey);
}
}
}
if ("text".equals(msgTypeStr)) {
NodeList content = root.getElementsByTagName("Content");
String contentStr = content.item(0).getTextContent();
logger.info("用戶發送信息:{}", contentStr);
}
} catch (Exception e) {
logger.error("微信調用服務后台出現錯誤", e.getCause());
}
}
我們需要的掃碼數據是 MsgType=="event" and Event=="SCAN"
,找到這條數據,解析出我們在生成二維碼時傳遞的key
值,再寫入緩存即可。代碼中的 scanLogin(openId, cacheKey)
完成具體業務邏輯,如果用戶已經綁定業務賬號,則直接發送模板消息登錄成功,否則發送模板消息邀請微信綁定,對應的代碼邏輯如下
// author: herbert 公眾號:小院不小 20210424
private void scanLogin(String openId, String cacheKey) throws IOException {
JSONObject user = findUserByOpenId(openId);
if (user == null) {
// 發送消息讓用戶綁定賬號
logger.info("用戶還未綁定微信,正在發送邀請綁定微信消息");
WeixinUtils.sendTempalteMsg(WeixinUtils.getAccessToken(), openId,
"LWP44mgp0rEGlb0pK6foatU0Q1tWhi5ELiAjsnwEZF4",
"http://pro.vaiwan.com/weixin-server/weixinbind.html?key=" + cacheKey, null);
WeixinCache.put(cacheKey, openId);
return;
}
// 更新緩存
WeixinCache.put(cacheKey, user);
WeixinCache.put(cacheKey + "_done", true);
logger.info("已將緩存標志[key]:{}設置為true", cacheKey + "_done");
logger.info("已更新緩存[key]:{}", cacheKey);
logger.info("已發送登錄成功微信消息");
WeixinUtils.sendTempalteMsg(WeixinUtils.getAccessToken(), openId, "MpiOChWEygaviWsIB9dUJLFGXqsPvAAT2U5LcIZEf_o",
null, null);
}
以上就完成了通過場景二維實現微信登錄的邏輯
5. 網頁授權登錄
網頁授權登錄的二維碼需要我們構建好具體的內容,然后使用二維碼代碼庫生成二維碼
5.1 生成網頁授權二維碼
// author: herbert 公眾號:小院不小 20210424
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String cacheKey = request.getParameter("key");
logger.info("當前用戶緩存key:{}", cacheKey);
BufferedImage bImg = WeixinUtils.buildWebAuthUrlQrCode("http://pro.vaiwan.com/weixin-server/weixinredirect",
cacheKey);
if (bImg != null) {
response.setContentType("image/png; charset=utf-8");
OutputStream os = null;
os = response.getOutputStream();
ImageIO.write(bImg, "png", os);
os.flush();
}
}
可以看到,我們這里緩存key
值,通過state
方式傳遞給微信服務器。微信服務器會將該值原樣返回給我我們的跳轉地址,並且附帶上授權碼。我們通過二維碼庫生成二維碼,然后直接返回二維碼圖。前端直接指向這個地址就可顯示圖片了。對應前端代碼如下
<div class="loginPanel">
<div class="title">微信登錄(微信網頁授權)</div>
<div class="panelContent">
<div class="wrp_code"><img class="qrcode lightBorder" src="/weixin-server/weixinwebqrcode?key=herbert_test_key"></div>
<div class="info">
<div id="wx_default_tip">
<p>請使用微信掃描二維碼登錄</p>
<p>“掃碼登錄測試系統”</p>
</div>
</div>
</div>
</div>
5.2 獲取openid並驗證
用戶掃描我們生成的二維碼以后,微信服務器會發送一個GET請求到我們配置的跳轉地址,我們在這里完成openid
的驗證和業務系統用戶信息獲取操作,對應代碼如下
// author: herbert 公眾號:小院不小 20210424
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String code = request.getParameter("code");
String state = request.getParameter("state");
logger.info("獲取到微信回傳參數code:{},state:{}", code, state);
JSONObject webTokenInfo = WeixinUtils.getWebAuthTokenInfo(code);
if (webTokenInfo != null && !webTokenInfo.containsKey("errcode")) {
String openId = webTokenInfo.getString("openid");
logger.info("獲取到用opeind", openId);
JSONObject user = findUserByOpenId(openId);
if (user == null) {
//用戶未綁定 將openid存入緩存方便下一步綁定用戶
WeixinCache.put(state, openId);
response.sendRedirect("weixinbind.html?key=" + state);
return;
}
WeixinCache.put(state, user);
WeixinCache.put(state + "_done", true);
logger.info("已將緩存標志[key]:{}設置為true", state + "_done");
logger.info("已更新緩存[key]:{}", state);
response.setCharacterEncoding("GBK");
response.getWriter().print("掃碼成功,已成功登錄系統");
}
}
用戶掃描這個二維碼后,邏輯跟場景二維碼一樣,找到用戶信息就提示用戶已成功登陸系統,否則就跳轉到微信綁定頁面
6. 開發平台登錄
開放平台登錄需要認證過后才能測試,認證需要交錢。對不起,我不配測試。
7. 總結
掃描登錄主要邏輯是生成帶key值二維,然后一直輪詢服務器查詢登錄狀態。以上兩個方式各有優劣,主要區別如下
- 帶參數二維碼方式,微信負責生成二維。網頁授權需要我們自己生成二維
- 帶參數二維掃碼成功或邀請綁定采用模板消息推送,網頁授權可以直接跳轉,體驗更好
- 帶參數二維碼用途更多,比如像ngork.cc網站,實現關注了公眾號才能加隧道功能
這里涉及到的知識點有
- Oauth認證流程
- 二維碼生成邏輯
- 內網穿透原理
- Javaservlet開發
開發過程中,需要多查幫助文檔。開發過程中的各種環境配置,對開發者來說,也是不小的挑戰。做微信開發也有好多年,從企業微信,到公眾號,到小程序,到小游戲,一直沒有總結。這次專門做了一個微信掃碼登錄專題。先寫代碼,再寫總結也花費了數周時間。如果覺得好,還望關注公眾號支持下,您的點贊和在看是我寫作力量的源泉。對微信集成和企業微信集成方面有問題的,也歡迎在公眾號回復,我看到了會第一時間力所能及的為您解答。需要文中提及的項目,請掃描下方的二維碼,關注公眾號[小院不小],回復wxqrcode獲取.