微信三方登录
文章介绍
-
三方登录概述;(理解)
-
三方登录协议-Oauth2.0;(了解)
-
三方登录选择-微信三方登录;(掌握)
-
微信三方登录实战;(掌握)
1. 三方登录概述
1.1. 什么是三方登录
用户可以在三方主流平台(微信,qq,支付宝。。)登录,然后自己平台(ronghuanet-car)就不需要登录了。基于用户在主流平台上已有的账号和密码来快速完成己方应用的登录或者注册的功能。而这里的主流平台,一般是已经拥有大量用户的平台,国外的比如Facebook,Twitter等,国内的比如微博、微信、QQ等。 第三方登录的目的是使用用户在其他平台上频繁使用的账号,来快速登录己方产品,也可以实现不用注册就能登录,好处就是登录比较快捷,不用注册。
1.2. 为什么需要
少记忆账号了
1.3. 优缺点
优点:这些系统有很大的用户群体;有这些大平台背景,用户的接收度和认可度较好;直接登录,避免用户注册和登录的繁琐过程。这样,可以比较好的推广自己的网站和粘住用户。
缺点:要交钱,除了三方登录还要实现其他的。
1.4. 使用场景
面向互联网用户一般都需要三方登录。
2. 三方登录协议-OAuth2.0
OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。与以往的授权方式不同之处是OAUTH的授权不会使第三方(容车宝)触及到用户的帐号信息(如用户名与密码),即第三方(容车宝)无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此OAUTH是安全的。oAuth是Open Authorization的简写,目前的版本是2.0版。
https://oauth.net/2/
2.1. 应用场景
为了让大家理解OAuth的适用场合,下面我先举一个例子。
有一个“云打印”的网站,可以将用户存储在QQ的照片打印出来。用户为了使用该服务,必须让“云打印”读取自己存储在QQ上的照片。
问题是,只有得到用户的授权,QQ才同意“云打印”读取这些照片。那么“云打印”如何才能获得QQ用户的授权呢?有比较传统的方法,用户自己将QQ的用户名和密码告诉“云打印”,后者就可以读取用户的照片了。但是,这么做会有以下几个严重的缺点:
(1) “云打印”为了后续的服务,会保存用户的用户名和密码,这样很不安全。
(2) “云打印”拥有了获取用户在QQ上所有资料的权利,用户没有办法限制“云打印”的访问资源的权限和有限期。
(3) 用户只有修改密码,才能收回“云打印”的权限。但是这么做,会使得其他所有获得用户授权 的第三方应用程序全部失效。
(4) 只要有一个第三方应用程序被破解,就会导致用户密码泄露,以及所有被这个密码保护的数据 也会泄露
OAuth就是为了解决上面这些问题而诞生的。 我们平台不需要保存三方平台账号和密码
2.2. 名词定义
在详细介绍OAuth2.0之前,需要了解几个专有名词,这些名词经常出现在各种应用场合,对于我们理解RCF 6749的内容至关重要。
(1)Third-party application:第三方应用程序,本文中又称"客户端"(client),即例子中的"云打印"。
(2)HTTP service:HTTP服务提供商,本文中简称"服务提供商",即上一节例子中的QQ。
(3)Resource Owner:资源所有者,本文中又称"用户"(user)。
(4)User Agent:用户代理,本文中就是指浏览器。
(5)Authorization server:认证服务器,即服务提供商专门用来处理认证的服务器。
(6)Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器
了解了上面的名词,就不难理解OAuth的作用就是让“客户端”安全可控的获取“用户”的授权,与“服务提供商”进行互动。
2.3. OAuth的思路
OAuth在"客户端"与"服务提供商"之间,设置了一个授权层(authorization layer)。“客户端"不能直接登录"服务提供商”,只能登录授权层,以此将用户与客户端区分开来。"客户端"登录授权层使用的是令牌(token),与用户的密码不同。用户可以在登录的时候,指定令牌的权限范围和有效期。
"客户端"登录授权层以后,"服务提供商"根据令牌的权限范围和有效期,向"客户端"开放用户储存的资料。
2.4. 运行流程
OAuth 2.0的运行流程如下图,摘自RCF 6749。
用户授权
获取令牌
使用令牌访问受限资源
(A)用户打开客户端以后,客户端要求用户给予授权。
(B)用户同意给予客户端授权。
(C)客户端使用上一步获得的授权,向认证服务器申请令牌。
(D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。
(E)客户端使用令牌,向资源服务器申请获取资源。
(F)资源服务器确认令牌无误,同意向客户端开放资源。
上述六个步骤中,B是关键,即用户怎样才能给客户端授权。有了这个授权以后,客户端就可以获取令牌,进而凭借令牌获取资源。
2.5. 客户端授权 - 授权码模式
客户端必须得到用户的授权,才能获得令牌,OAuth2.0定义了四种授权方式:
-
授权码模式(authorization code)
-
简化模式(implicit)
-
密码模式(resource owner password credentials)
-
客户端模式(client credentials)
这里,我们主要介绍一下授权码模式:
授权码模式是功能最完整,流程最严密的授权模式。它的特点是通过客户端的后台服务器,与“服务提供商”的认证服务器进行互动,先看下面一张图。
(A)用户访问客户端,后者将前者导向认证服务器。
(B)用户选择是否给予客户端授权。
(C)假设用户给予授权,认证服务器将用户导向客户端事先指定的"重定向URI",同时附上一个授权码。
(D)客户端收到授权码,附上早先的"重定向URI",向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。
(E)认证服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。
3. 三方登录服务商选择-微信三方登录
3.1. 为什么选择微信作为三方登录服务提供方
三方登录产品有很多,比如微信登录,qq登录,支付宝登录,新浪微博登录.但是都是遵循oauth2.0协议,只要学会一个其他就触类旁通.
微信,在做的各位都使用很频繁的社交软件,老少皆宜。今天我们以微信为例进行讲解。
微信开放平台供开发者可以基于微信做很多事情:
https://open.weixin.qq.com/
自己的网站可以接入网站应用开发,为用户提供了微信登录功能,降低了注册门槛,并可在用户授权后,获取用户基本信息,包括头像、昵称、性别、地区。出于安全考虑,网站应用的微信登录,需通过微信扫描二维码来实现。
3.2. 注册账号
要想接入微信的登录功能,首先需要在微信开发平台进行用户的注册,同时需要认证为开发者,再创建网站应用,等待微信审批,审批过后,就可以使用相关功能。
3.2.1.注册
打开微信开发平台(https://open.weixin.qq.com/):
使用邮箱进行注册(最好使用企业邮箱,避免使用私人邮箱,后续需要企业的资质认证等,避免你离职后的交接麻烦):
3.2.2.开发者认证
注册完成后,需要进行开发者的认证:
登录后,点击登录名,进入:
大致需要个人身份证等真实信息,还需要企业签字盖章等流程,认证一次300人民币。
3.2.3.创建网站应用
认证成功后,创建网站应用,也需要企业签字盖章,还需要备案的域名,作为微信的回调。
创建完成后,获取到appid和appsecret,配置好回调域名。
3.3. 网站微信登录原理
一切参照官方文档:
3.4. 配置回调域名
上线:
开发阶段:
路由解析原理
6.4 配置电脑HOST文件
Host文件配置
127.0.0.1 wx.rongcarservice.com
通过原理分析,要发三个请求
1)授权请求-a标签链接过去就OK
2)获取AccessToken等信息请求-Httpclient
在我们项目中以后这样用代码来发送请求的情况有可能很多,所以抽取一个工具类,以后专门发请求。
4. 微信三方登录项目实战
4.1. 设计
4.1.1.数据库设计
WxUser 骨架 分页列表 自己做
4.1.2.第三方登录流程分析
4.2. 实现准备
4.2.1.工具类的封装
package cn.ronghuanet.basic.utils;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.params.HttpMethodParams;
import java.io.IOException;
/**
* 使用httpclient组件发送http请求
* get:现在只用到get
* post
*/
public class HttpClientUtils {
/**
* 发送get请求
* @param url 请求地址
* @return 返回内容 json
*/
public static String httpGet(String url){
// 1 创建发起请求客户端
try {
HttpClient client = new HttpClient();
// 2 创建要发起请求-tet
GetMethod getMethod = new GetMethod(url);
// getMethod.addRequestHeader("Content-Type",
// "application/x-www-form-urlencoded;charset=UTF-8");
getMethod.getParams().setParameter(HttpMethodParams.HTTP_CONTENT_CHARSET,"utf8");
// 3 通过客户端传入请求就可以发起请求,获取响应对象
client.executeMethod(getMethod);
// 4 提取响应json字符串返回
String result = new String(getMethod.getResponseBodyAsString().getBytes("utf8"));
return result;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
4.2.2.常量封装
package cn.ronghuanet.basic.constants;
public class WxConstants {
public static final String APPID = "wx03bd86056bd3fc0e";
public static final String SECRET = "f52fb26ebd494fe6f44eb4da50bd22da";
public static final String GET_ACK_URL = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code";
public static final String GET_USER_URL = "https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID";
}
4.2.3.检查配置回调域名hosts配置
4.3. 核心代码实现
<!--处理json-->
<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.58</version>
</dependency>
4.3.1.跳转授权界面
index.html
<a :href="wxUrl" id="b-login" class="b-login">微信登录</a>
new Vue({
el:"#userDiv",
data:{
loginForm:{
username:"",
password:"",
loginType:1
},
loginUser:{
},
wxUrl:"https://open.weixin.qq.com/connect/qrconnect?appid=wx03bd86056bd3fc0e&redirect_uri=http://wx.rongcarservice.com/callback.html&response_type=code&scope=snsapi_login&state=1#wechat_redirect",
imageCode:"",
imageCodePrefix:"data:image/png;base64,",
formParams:{
mobile:"",
imageCode:"",
smsCode:"",
username:"",
password:"",
confirmPassword: ""
}
},
4.3.2.回调页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>回调地址</title>
<script src="plugins/axios/dist/axios.js"></script>
<script src="plugins/vue/dist/vue.js"></script>
<script src="js/common.js"></script>
<script type="text/javascript" src="script/jquery-1.8.0.min.js"></script>
</head>
<body>
<div id="myDiv">
</div>
<script type="text/javascript">
new Vue({
el:"#myDiv",
data:{},
methods:{
},
mounted(){
// 解析url,拿到微信传给我的授权code
let urlObj = str2Obj(location.href);
// 调用后端接口,通过授权code,获取微信的accessToken和用户的openid
let requestObj = {code:urlObj.code};
this.$http.post("/logininfo/wechat/login",requestObj)
.then(result => {
result = result.data;
if(result.success){
// 说明后端已经绑定,并且已经做了免密登录
let {token,loginInfo} = result.result;
localStorage.setItem("loginInfo", JSON.stringify(loginInfo));
localStorage.setItem("token", token);
location.href = "/index.html";
}else{
// 说明该微信用户还没有绑定,需要跳转到绑定页面进行绑定
let {accessToken,openid} = result.result;
location.href = "/binder.html?openid="+openid+"&accessToken="+accessToken;
}
}).catch(result => {
})
}
})
</script>
</body>
</html>
4.3.3.跳转后台微信登录
LoginController
@PostMapping("/wechat/login")
public AjaxResult wechatLogin(@RequestBody Map<String,Object> map){
try {
String code = map.get("code").toString();
return logininfoService.wechatLogin(code);
} catch (Exception e) {
e.printStackTrace();
return AjaxResult.me().setSuccess(false).setMessage("登录失败!"+e.getMessage());
}
}
LoginServiceImpl
@Override
public AjaxResult wechatLogin(String code) {
// 1 根据授权code获取微信用户的accessToken和用户的openid
String accessUrl = WxConstants.GET_ACK_URL
.replace("APPID",WxConstants.APPID)
.replace("SECRET", WxConstants.SECRET)
.replace("CODE", code);
String accessStr = HttpClientUtils.httpGet(accessUrl);
JSONObject accessObj = JSONObject.parseObject(accessStr);
// 2 根据openid判断是否已经绑定用户
String accessToken = accessObj.getString("access_token");
String openid = accessObj.getString("openid");
Wxuser wxuser = wxuserMapper.loadWxUserByOpenid(openid);
if(Objects.nonNull(wxuser)){
// 2.1 已经绑定用户,就做免密登录
// 2.1.1 通过用户ID获取logininfo信息
Logininfo logininfo = logininfoMapper.loadByUserId(wxuser.getUserId());
// 2.1.2 免密登录
Map<String, Object> map = loginSuccessHandle(logininfo);
return AjaxResult.me().setResult(map);
}else{
// 2.2 如果没有绑定过,就携带着accessToken和openid跳转到绑定页面,去绑定手机号
Map<String,Object> map = new HashMap<>();
map.put("accessToken",accessToken);
map.put("openid",openid);
return AjaxResult.me().setSuccess(false).setResult(map);
}
}
private Map<String, Object> loginSuccessHandle(Logininfo logininfo) {
// 4 redis操作
// 4.1 生成一个随机字符串
String token = UUID.randomUUID().toString();
// 4.2 通过uuid作为redis的key,存入用户信息和权限信息
redisTemplate.opsForValue().set(token, logininfo,30, TimeUnit.MINUTES);
// 5 返回token和用户信息
Map<String,Object> map = new HashMap<>();
map.put("token",token);
map.put("loginInfo", logininfo);
return map;
}
4.3.4.绑定页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>微信用户绑定</title>
<script src="plugins/axios/dist/axios.js"></script>
<script src="plugins/vue/dist/vue.js"></script>
<script src="js/common.js"></script>
<script type="text/javascript" src="script/jquery-1.8.0.min.js"></script>
<style>
.myDiv{
width: 400px;
height: 300px;
float: right;
border: 1px solid #5c5c5c;
background: white;
margin-right: 100px;
margin-top: 100px;
}
.inputDiv{
height: 80px;
line-height: 80px;
margin-left: 40px;
}
.inputDiv .input{
height: 30px;
width: 200px;
}
.btnInput{
height: 30px;
width: 100px;
}
</style>
</head>
<body style="background-image: url('images/logo.jpg')">
<div class="myDiv">
<form class="registForm">
<div>
<div class="inputDiv">
<input class="input" name="mobile" v-model="bindParam.phone" type="text" value="" placeholder="请输入要绑定的手机号">
</div>
<div class="inputDiv">
<input class="input left" type="text" value="" v-model="bindParam.verifyCode" name="verify" placeholder="输入验证码" />
<div id="send" style="display: inline;">
<input class="btnInput" type="button" @click="getSmsCode($event)" value="获取验证码"/>
</div>
</div>
</div>
<div class="login-button" style="text-align: center">
<input type="button" class="btnInput" value="绑定" class="fM" @click="bind" />
</div>
</form>
</div>
<script type="text/javascript">
new Vue({
el:".myDiv",
data:{
bindParam:{
phone:"",
verifyCode:"",
accessToken:"",
openid:""
}
},
methods:{
getSmsCode(event){
//1.判断手机号不为空
if(!this.bindParam.phone){
alert("手机号不能为空");
return;
}
//2.获取按钮,禁用按钮
var sendBtn = $(event.target);
sendBtn.attr("disabled",true);
var param = {
phone: this.bindParam.phone,
};
//4.发送ajax请求
this.$http.post("/verifyCode/sms/bind",param).then(res=>{
var ajaxResult = res.data;
if(ajaxResult.success){
alert("手机验证码已经发送到您的手机,请在5分钟内使用");
//4.1.发送成:倒计时
var time = 60;
var interval = window.setInterval( function () {
//每一条倒计时减一
time = time - 1 ;
//把倒计时时间搞到按钮上
sendBtn.val(time+"秒后重发");
//4.2.倒计时完成恢复按钮
if(time <= 0){
sendBtn.val("重新发送");
sendBtn.attr("disabled",false);
//清除定时器
window.clearInterval(interval);
}
},1000);
}else{
//4.3.发送失败:提示,恢复按钮
sendBtn.attr("disabled",false);
alert("发送失败:"+ajaxResult.message);
}
})
},
bind(){
this.$http.post("/user/bind/wechat",this.bindParam)
.then(result => {
result = result.data;
if(result.success){
// 绑定成功,说明也登录成功
let {token,loginInfo} = result.result;
localStorage.setItem("loginInfo", JSON.stringify(loginInfo));
localStorage.setItem("token", token);
location.href = "/index.html";
}else{
alert(result.message);
}
})
}
},
mounted(){
// 解析url地址中的accessToken,openid
let urlObj = str2Obj(location.href);
this.bindParam.accessToken = urlObj.accessToken;
this.bindParam.openid = urlObj.openid;
}
})
</script>
</body>
</html>
4.3.5.绑定实现
UserController
@PostMapping("/bind/wechat")
public AjaxResult bindWechat(@RequestBody UserBindWechatDTO dto) {
try {
return userService.bindWechat(dto);
} catch (Exception e) {
e.printStackTrace();
return AjaxResult.me().setSuccess(false).setMessage("绑定失败!"+e.getMessage());
}
}
UserServiceImpl
@Override
public AjaxResult bindWechat(UserBindWechatDTO dto) {
String phone = dto.getPhone();
String verifyCode = dto.getVerifyCode();
String accessToken = dto.getAccessToken();
String openid = dto.getOpenid();
// 1 参数空校验
// 2 校验验证码是否一致
String verifyByRedis = (String)redisTemplate.opsForValue().get("sms_bind_verifycode_" + phone);
verifyByRedis = verifyByRedis.split(":")[0];
if(!verifyCode.equalsIgnoreCase(verifyByRedis)){
throw new RuntimeException("手机验证码错误");
}
// 3 通过acccessToken和openid拿到微信用户信息
String getUserUrl = WxConstants.GET_USER_URL
.replace("ACCESS_TOKEN", accessToken)
.replace("OPENID", openid);
String wxUserStr = HttpClientUtils.httpGet(getUserUrl);
Wxuser wxuser = wechatstr2Wxuser(wxUserStr);
// 4 判断手机号是否已注册
User user = userMapper.loadByPhone(phone);
Logininfo logininfo = null;
if(Objects.nonNull(user)){
// 4.1 如果已注册,就直接将wx用户绑定到已存在的账号,并免密登录
wxuser.setUserId(user.getId());
logininfo = logininfoMapper.loadByUserId(user.getId());
}else{
// 4.2 如果不存在,就先保存logininfo user,再将WxUser保存到数据库
logininfo = buildLoginInfo(phone);
logininfoMapper.insert(logininfo);
user = logininfo2User(logininfo);
user.setLogininfoId(logininfo.getId());
userMapper.insert(user);
wxuser.setUserId(user.getId());
}
wxuserMapper.insert(wxuser);
// 免密登录
Map<String, Object> map = loginSuccessHandle(logininfo);
return AjaxResult.me().setResult(map);
}
private Map<String, Object> loginSuccessHandle(Logininfo logininfo) {
// 4 redis操作
// 4.1 生成一个随机字符串
String token = UUID.randomUUID().toString();
// 4.2 通过uuid作为redis的key,存入用户信息和权限信息
redisTemplate.opsForValue().set(token, logininfo,30, TimeUnit.MINUTES);
// 5 返回token和用户信息
Map<String,Object> map = new HashMap<>();
map.put("token",token);
map.put("loginInfo", logininfo);
return map;
}
private User logininfo2User(Logininfo logininfo) {
User user = new User();
BeanUtils.copyProperties(logininfo, user);
user.setState(2);
user.setCreatetime(new Date());
return user;
}
private Logininfo buildLoginInfo(String phone) {
Logininfo logininfo = new Logininfo();
logininfo.setUsername(UUID.randomUUID().toString());
logininfo.setPhone(phone);
String salt = UUID.randomUUID().toString();
String password = StrUtils.getComplexRandomString(6);
password = MD5Utils.encrypByMd5("123"+salt+"987"+password);
logininfo.setSalt(salt);
logininfo.setPassword(password);
logininfo.setType(1);
logininfo.setDisable(1);
return logininfo;
}
private Wxuser wechatstr2Wxuser(String wxUserStr) {
JSONObject wxUserObj = JSONObject.parseObject(wxUserStr);
Wxuser result = new Wxuser();
result.setOpenid(wxUserObj.getString("openid"));
result.setNickname(wxUserObj.getString("nickname"));
result.setSex(wxUserObj.getInteger("sex"));
result.setAddress(wxUserObj.getString("country")+wxUserObj.getString("province")+wxUserObj.getString("city"));
result.setHeadimgurl(wxUserObj.getString("headimgurl"));
result.setUnionid(wxUserObj.getString("unionid"));
return result;
}