1.分析
現在我們需要為已有項目添加一個微信公眾號,公眾號部分功能需要用戶進行登錄才能操作。對於微信用戶來說,每個用戶有一個唯一的標識OpenId,我們只需要在原本的userInfo表中添加一個openId字段,將微信用戶openId和用戶名、密碼綁定就可以了。
具體的實現有以下兩種方式:
第一種:1)用戶點擊“賬號綁定”,菜單,開始綁定賬號;
2)公眾號回復一條包含賬號綁定頁面鏈接的文本消息,鏈接中包含openId參數;
3)用戶點擊文本消息中的網頁鏈接,進入賬號綁定頁面。填寫用戶名和密碼,點擊提交。需要注意的是,openId是綁定頁面的一個隱藏域,用戶名和密碼需要用戶填寫;
4)后台獲取openId,用戶名,密碼,調用業務系統的登錄接口驗證用戶名和密碼是否正確,正確則將openId添加在數據庫,不正確則提示用戶名和密碼不正確。
例子:花生殼公眾號
第二種:1)沒有“賬號綁定”菜單,當用戶點擊需要綁定才能操作的菜單時,頁面重定向到賬號綁定頁面;
2)填寫用戶名和密碼,點擊提交。需要注意的是,openId是綁定頁面的一個隱藏域,用戶名和密碼需要用戶填寫;
3)后台獲取openId,用戶名,密碼,調用業務系統的登錄接口驗證用戶名和密碼是否正確,正確則將openId添加在數據庫,不正確則提示用戶名和密碼不正確。
對於賬號綁定功能來說,其實就兩個關鍵點:1.如何獲取openId;2.如何過濾需要登錄的頁面。
以上兩種方式在實現上第一種方式比較簡單,而第二種方式需要用到微信網頁授權獲取openId,所以這邊重點介紹第二種方式,但是第一種方式也會稍微說明。
2.第一種方式關鍵點分析
這里需要提前了解自定義菜單的創建和各種消息的接收和響應。
可以參考柳峰大神寫的專欄:http://blog.csdn.net/column/details/wechatmp.html
2.1獲取openId
首先需要創建類型為click的自定義菜單。當用戶在微信上點擊菜單時,微信會向我們推送xml數據包,這個數據包中有一個字段FromUserName,也就是用戶的openId。詳細的數據包信息可到微信公眾號開發文檔查看。這里說的數據包推送到的地址是我們在微信公眾號管理上配置的接入url,如下
2.2過濾需要登錄的頁面
在創建click類型的自定義菜單時,可以設置菜單的key值,這個key微信也會在用戶點擊之后,通過上述的數據包推送給我們,對應的字段是EventKey。拿到這個key值,我們就能區分哪些菜單需要登錄,哪些不需要。當點擊需要登錄的菜單時,后台判斷openId是否綁定,沒有綁定就回復一條包含賬號綁定頁面鏈接的文本消息,鏈接中包含openId參數,有綁定就回復一條包含對應頁面的鏈接的文本消息。
3.第二種方式關鍵點分析
這里需要提前了解自定義菜單的創建和微信網頁授權。
3.1獲取openId
3.1.1獲取code
首先先創建類型為view的自定義菜單,不需要登錄的url配置成對應的controller地址就可以了,需要登錄的url配置的是網頁授權獲取code的url鏈接,鏈接如下
//(APPID:替換實際appId,REDIRECT_URI:替換成對應的回調地址,SCOPE:填寫snsapi_base或snsapi_userinfo,STATE:可選參數,可填寫a-zA-Z0-9 https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
因為我們只獲取openId,所以SCOPE替換成“snsapi_base”,而回調地址REDIRECT_URI替換成我們點擊菜單需要跳轉的網頁鏈接(也就是對應Controller的鏈接)。
需要注意的是這個回調地址需要進行urlEncode編碼。如下:
redirect_url就是用來接收微信發送過來的coed的地址,在對應的controller中我們可以通過request.getParameter("code")獲取code,所以,對於不同的菜單,我們只需
要配置不同的redirect_url就能在菜單對應的controller下獲取code,並通過code獲取openId。
3.1.2通過code換取網頁授權憑證access_token
獲取到code之后,我們需要以code為參數向微信提供的接口發起https get請求,獲取包含openId的網頁授權憑證。
接口:https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
具體代碼如下:
1)編寫發送https請求工具
import com.alibaba.fastjson.JSONObject; import com.iport.framework.util.JsonUtil; import com.tmall.wechat.model.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import java.io.*; import java.net.ConnectException; import java.net.URL; import java.net.URLEncoder; import java.util.HashMap; import java.util.Map; /** * 微信工具類 * */ public class WechatUtil { private static Logger logger = LoggerFactory.getLogger(WechatUtil.class); /** * 發送https請求 * @param requestUrl 請求地址 * @param requestMethod 請求方式(GET、POST) * @param outputStr 提交的數據 * @return JSONObject(通過JSONObject.get(key)的方式獲取json對象的屬性值) */ public static JSONObject httpsRequest(String requestUrl, String requestMethod, String outputStr) { JSONObject jsonObject = null; try { // 創建SSLContext對象,並使用我們指定的信任管理器初始化 TrustManager[] tm = { new MyX509TrustManager() }; SSLContext sslContext = SSLContext.getInstance("SSL", "SunJSSE"); sslContext.init(null, tm, new java.security.SecureRandom()); // 從上述SSLContext對象中得到SSLSocketFactory對象 SSLSocketFactory ssf = sslContext.getSocketFactory(); URL url = new URL(requestUrl); HttpsURLConnection conn = (HttpsURLConnection) url.openConnection(); conn.setSSLSocketFactory(ssf); conn.setDoOutput(true); conn.setDoInput(true); conn.setUseCaches(false); // 設置請求方式(GET/POST) conn.setRequestMethod(requestMethod); // 當有數據需要提交時 if (null != outputStr) { OutputStream outputStream = conn.getOutputStream(); // 注意編碼格式 outputStream.write(outputStr.getBytes("UTF-8")); outputStream.close(); } // 將返回的輸入流轉換成字符串 InputStream inputStream = conn.getInputStream(); InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "utf-8"); BufferedReader bufferedReader = new BufferedReader(inputStreamReader); String str = null; StringBuffer buffer = new StringBuffer(); while ((str = bufferedReader.readLine()) != null) { buffer.append(str); } // 釋放資源 bufferedReader.close(); inputStreamReader.close(); inputStream.close(); inputStream = null; conn.disconnect(); jsonObject = JSONObject.parseObject(buffer.toString()); } catch (ConnectException ce) { ce.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } return jsonObject; } }
import javax.net.ssl.X509TrustManager; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; /** * 證書信任管理器(用於https請求) * 這個證書管理器的作用就是讓它信任我們指定的證書,上面的代碼意味着信任所有證書,不管是否權威機構頒發 */ public class MyX509TrustManager implements X509TrustManager { // 檢查客戶端證書 @Override public void checkClientTrusted(X509Certificate[] arg0, String arg1) throws CertificateException { } // 檢查服務器端證書 @Override public void checkServerTrusted(X509Certificate[] arg0, String arg1) throws CertificateException { } // 返回受信任的X509證書數組 @Override public X509Certificate[] getAcceptedIssuers() { return null; } }
2)獲取微信網頁授權憑證的工具類
import com.alibaba.fastjson.JSONObject; import com.tmall.wechat.model.WeixinOauth2Token; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * 微信網頁授權工具類 */ public class AdvancedUtil { private static Logger logger = LoggerFactory.getLogger(AdvancedUtil.class); /** * 獲取網頁授權憑證 * @param appId 公眾賬號的唯一標識 * @param appSecret 公眾賬號的密鑰 * @param code * @return WeixinAouth2Token */ public static WeixinOauth2Token getOauth2AccessToken(String appId, String appSecret, String code) { WeixinOauth2Token wat = null; // 拼接請求地址:該地址參數順序固定 String requestUrl = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code"; requestUrl = requestUrl.replace("APPID", appId); requestUrl = requestUrl.replace("SECRET", appSecret); requestUrl = requestUrl.replace("CODE", code); // 獲取網頁授權憑證 JSONObject jsonObject = WechatUtil.httpsRequest(requestUrl,"GET", null); if (null != jsonObject) { try { wat = new WeixinOauth2Token(); wat.setAccessToken(jsonObject.getString("access_token")); wat.setExpiresIn(jsonObject.getIntValue("expires_in")); wat.setRefreshToken(jsonObject.getString("refresh_token")); wat.setOpenId(jsonObject.getString("openid")); wat.setScope(jsonObject.getString("scope")); } catch (Exception e) { e.printStackTrace(); } } return wat; } }
/** * 通過code換取網頁授權access_token返回的 * 網頁授權信息 */ public class WeixinOauth2Token { // 網頁授權接口調用憑證 private String accessToken; // 憑證有效時長(單位:秒) private int expiresIn; // 用於刷新憑證 private String refreshToken; // 用戶唯一標識 private String openId; // 用戶授權作用域 private String scope; public String getAccessToken() { return accessToken; } public void setAccessToken(String accessToken) { this.accessToken = accessToken; } public int getExpiresIn() { return expiresIn; } public void setExpiresIn(int expiresIn) { this.expiresIn = expiresIn; } public String getRefreshToken() { return refreshToken; } public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } public String getOpenId() { return openId; } public void setOpenId(String openId) { this.openId = openId; } public String getScope() { return scope; } public void setScope(String scope) { this.scope = scope; } }
3.2對需要登錄的頁面進行過濾
這里我們通過spring攔截器來實現登錄攔截。首先我們對需要攔截的地址進行配置,當我們訪問這些地址時,會首先進入到攔截器方法中。因為在第一步創建自定義菜單時我們已經將這些需要登錄攔截的頁面配置成了獲取code的回調地址,所以我們可以在攔截器中獲取到code。然后利用第二部寫的方法獲取到包含openId的access_token憑證信息,拿到openId就能判斷用戶是否綁定,有綁定放過,沒綁定就將openId作為參數,轉發到登錄界面。
具體代碼如下:
攔截器配置:
<!--配置攔截器, 多個攔截器,順序執行 --> <mvc:interceptors> <mvc:interceptor> <!-- 匹配的是url路徑, 如果不配置或/**,將攔截所有的Controller --> <mvc:mapping path="/wechat/**/*.html" /> <!-- 不進行攔截 --> <mvc:exclude-mapping path="/wechat/index.html"/> <mvc:exclude-mapping path="/wechat/createMenu.html"/> <bean class="com.wechat.interceptor.LoginInterceptor"></bean> </mvc:interceptor> <!-- 當設置多個攔截器時,先按順序調用preHandle方法,然后逆序調用每個攔截器的postHandle和afterCompletion方法 --> </mvc:interceptors>
登錄攔截器:
import com.iport.cm.model.po.CmLoginAccount; import com.iport.cm.service.ICmLoginAccountServiceEx; import com.iport.framework.cache.redis.JedisTemplate; import com.iport.framework.context.Sc; import com.iport.framework.util.ValidateUtil; import com.iport.park.wechat.model.WeixinOauth2Token; import com.iport.park.wechat.util.AdvancedUtil; import com.iport.park.wechat.util.WechatConstants; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * 微信登錄攔截器 * Created by caiyl on 2017/12/6. */ public class LoginInterceptor extends HandlerInterceptorAdapter { @Autowired private ICmLoginAccountServiceEx cmLoginAccountServiceEx; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 用戶同意授權后,能獲取到code String code = request.getParameter("code"); JedisTemplate jedisTemplate = (JedisTemplate) Sc.getBean("jedisTemplate"); //根據code獲取openId String oldOpenId = jedisTemplate.get(code); String openId = null; boolean ret = false; //openId為空,表示code沒有重復,重新獲得openId if (ValidateUtil.isEmpty(oldOpenId)) { response.setCharacterEncoding("utf-8"); // 用戶同意授權 if (!"authdeny".equals(code)) { // 獲取網頁授權access_token WeixinOauth2Token weixinOauth2Token = AdvancedUtil.getOauth2AccessToken(WechatConstants.APPID, WechatConstants.APPSECERT, code); //用戶唯一表示openId openId = weixinOauth2Token.getOpenId(); jedisTemplate.setex(code,openId,5*60);//code有效期5分鍾 } } else { openId = oldOpenId; } //獲取用戶信息,判斷是否綁定 CmLoginAccount userInfo = cmLoginAccountServiceEx.getAccountByOpenId(openId); if (null != userInfo) { ret = true; } if (!ret) { request.setAttribute("openId",openId); request.getRequestDispatcher(WechatConstants.WECHAT_BASE_PATH+"/login.jsp").forward(request, response); } return ret; } }
補充:可以看到攔截器代碼中我們用到了redis,redis的作用是對code進行去重,解決微信服務器多次請求獲取code回調方法,造成code失效的問題
4.總結
使用第一種方法有一個弊端:當我們未登錄時,點擊菜單,公眾號回復一條帶有登錄頁面的鏈接,而當我們已登錄,點擊菜單,公眾號同樣回復一條帶有對應頁面的鏈接,而沒有辦法實現在已登錄狀態下直接跳轉響應頁面。為什么呢?因為這種方式用的是類型為click的菜單,click只能用來回復各種消息,不能跳轉頁面,即使使用了轉發或重定向也沒用。
剛開始進行微信開發的第一步我們需要在微信管理后台配置一個鏈接,用來驗證我們服務器的有效性。當用戶在微信公眾號操作時,不管進行什么操作,都會觸發該鏈接對應的controller方法,只不過是post請求,而驗證服務器有效性是get請求。所以該鏈接也是所有消息接收和響應總入口。當用戶在公眾號上操作時,微信服務器會返回給我們一個數據包,數據包中包含了FromUserName(用戶openId),在方法一中我們就是在這邊獲取openId來判斷用戶是否綁定的。那我們第二種方法是否也可以在接收和響應消息總入口這邊獲取openId實現登錄驗證呢?這樣不就不用編寫什么過濾器了嗎?畢竟這是總入口。答案是否定的。為什么呢?因為這種方式使用的是view類型的菜單,我們在創建菜單的時候已經指定了跳轉的url了,所以沒有辦法使用轉發或重定向到登錄界面。而view類型的菜單也不能向用戶返回消息,所以也就不能像click類型的菜單那樣返回一條帶鏈接的消息給用戶。