微信公众号账号登录功能实现
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> <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> 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类型的菜单那样返回一条带链接的消息给用户。